diff --git a/Core/Resgrid.AdminAssist/CapabilitySetupEvaluator.cs b/Core/Resgrid.AdminAssist/CapabilitySetupEvaluator.cs new file mode 100644 index 000000000..89d68a62d --- /dev/null +++ b/Core/Resgrid.AdminAssist/CapabilitySetupEvaluator.cs @@ -0,0 +1,42 @@ +using System; +using System.Linq; +using Resgrid.Model.AdminAssist; + +namespace Resgrid.AdminAssist +{ + /// Learning, purchase, recorded configuration and supported verification remain separate dimensions. + public static class CapabilitySetupEvaluator + { + public static CapabilitySetupAssessment Evaluate(ProductCapability capability, CapabilityAccess access, ConfigurationReport report, DateTime now, TimeSpan freshness) + { + var state = CapabilitySetupState.NotAssessed; + var definition = capability.Setup; + var accessFresh = access != null && access.AsOfUtc <= now && now - access.AsOfUtc <= freshness; + if (definition != null && accessFresh && access.State == EvidenceState.Known && report.Snapshot.Consistent) + { + var fact = report.Snapshot.Find(definition.EvidenceId); + if (fact.IsFresh(now, freshness) && fact.Number.HasValue && fact.Number >= 0) + { + state = fact.Number < definition.Minimum ? CapabilitySetupState.NotConfigured : CapabilitySetupState.ConfigurationPresent; + if (state == CapabilitySetupState.ConfigurationPresent && definition.RuleIds.Count > 0) + { + var checks = definition.RuleIds.Select(id => report.Findings.SingleOrDefault(f => f.RuleId == id)).ToArray(); + bool current(ConfigurationFinding check) => check != null && check.SnapshotRevision == report.Snapshot.Revision && + check.EvaluatedOnUtc <= now && now - check.EvaluatedOnUtc <= freshness; + if (checks.Any(check => current(check) && check.Result == RuleResult.Fail)) state = CapabilitySetupState.NeedsAttention; + // N/A, missing or stale checks do not verify a capability. No data-usage or delivery claim. + else if (checks.All(check => current(check) && check.Result == RuleResult.Pass)) state = CapabilitySetupState.ChecksPassed; + } + } + } + var addon = capability.Requirements.Any(r => r.Kind == "addon"); + var opportunity = capability.ReleaseStatus is not ("available" or "preview") ? "Ui.OpportunityPlanned" : + !accessFresh || addon && (access.CommercialState == null || access.CommercialState == EvidenceState.Unknown || access.CommercialState == EvidenceState.Redacted) ? "Ui.OpportunityUnknown" : + addon && access.CommercialState == EvidenceState.Unavailable ? "Ui.OpportunityNotOwned" : + state == CapabilitySetupState.NotConfigured ? addon ? "Ui.OpportunitySubscribedNotConfigured" : "Ui.OpportunityIncludedNotConfigured" : + addon ? "Ui.OpportunitySubscribed" : "Ui.OpportunityNoAddon"; + return new(capability.Id, state, opportunity, definition?.GuidanceKey ?? "Ui.ConfigurationNotAssessed", + definition?.RuleIds ?? Array.Empty(), report.Snapshot.Revision, report.Snapshot.AsOfUtc); + } + } +} diff --git a/Core/Resgrid.AdminAssist/CapacityImpactEvaluator.cs b/Core/Resgrid.AdminAssist/CapacityImpactEvaluator.cs new file mode 100644 index 000000000..89e9fa808 --- /dev/null +++ b/Core/Resgrid.AdminAssist/CapacityImpactEvaluator.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using Resgrid.Model.AdminAssist; + +namespace Resgrid.AdminAssist +{ + /// Headroom for proposed total personnel/unit counts; no provisioning, price estimate or entitlement change. + public static class CapacityImpactEvaluator + { + public static ConfigurationImpactReport Evaluate(ConfigurationSnapshot snapshot, CapacityImpactRequest request, DateTime now, TimeSpan maximumAge) + { + if (request == null || request.ProposedPersonnelCount < 0 || request.ProposedPersonnelCount > 1000000 || request.ProposedUnitCount < 0 || request.ProposedUnitCount > 1000000) + throw new ArgumentException("Proposed total counts must be between zero and one million."); + if (!snapshot.Consistent || snapshot.Revision != request.ExpectedRevision) throw new AdminAssistConcurrencyException(); + decimal? Value(string id) { var value = snapshot.Find(id); return value.IsFresh(now, maximumAge) && value.Number >= 0 ? value.Number : null; } + var personnel = Value("capacityPersonnelCount"); var units = Value("capacityUnitCount"); + var metrics = new List(); + void Add(string label, decimal? before, decimal? after) => metrics.Add(new(label, before.HasValue && after.HasValue ? EvidenceState.Known : EvidenceState.Unknown, before, after)); + Add("Impact.PersonnelTotal", personnel, request.ProposedPersonnelCount); + Add("Impact.UnitTotal", units, request.ProposedUnitCount); + var kind = snapshot.Find("capacityKind"); + if (kind.IsFresh(now, maximumAge) && kind.Code == "entities") + { + var cap = Value("capacityEntityLimit"); + // Zero is not a verified entity allowance; the owning limit service changes behavior at zero. + if (cap == 0) cap = null; + Add("Impact.SharedHeadroom", cap - personnel - units, cap - request.ProposedPersonnelCount - request.ProposedUnitCount); + } + else if (kind.IsFresh(now, maximumAge) && kind.Code == "separate") + { + Add("Impact.PersonnelHeadroom", Value("capacityPersonnelLimit") - personnel, Value("capacityPersonnelLimit") - request.ProposedPersonnelCount); + Add("Impact.UnitHeadroom", Value("capacityUnitLimit") - units, Value("capacityUnitLimit") - request.ProposedUnitCount); + } + else Add("Impact.CapacityHeadroom", null, null); + return new("plan.capacity", snapshot.Revision, snapshot.AsOfUtc, "capacity-v1", + new SettingImpact("Medium", "Impact.Audience", "Area.plans", "Impact.Timing", "Impact.CapacityReversal", "Impact.Verify"), + metrics.AsReadOnly(), Array.Empty(), new[] { "Impact.NoMutation", "Impact.CapacityScope", "Impact.CapacityTiming" }, "/User/Subscription/Index"); + } + } +} diff --git a/Core/Resgrid.AdminAssist/Catalog/automation.yaml b/Core/Resgrid.AdminAssist/Catalog/automation.yaml new file mode 100644 index 000000000..3b9148f9e --- /dev/null +++ b/Core/Resgrid.AdminAssist/Catalog/automation.yaml @@ -0,0 +1,851 @@ +{ + "Version": "2026.09.24.1", + "Areas": [ + { + "Id": "automation", + "LabelKey": "Area.automation", + "PurposeKey": "AreaPurpose.automation", + "Order": 10, + "Archetypes": [] + } + ], + "Settings": [ + { + "Id": "table.ChatbotDepartmentConfig.IsEnabled", + "AreaId": "automation", + "LabelKey": "TableField.ChatbotDepartmentConfig.IsEnabled", + "HelpKey": "TableHelp.ChatbotDepartmentConfig.IsEnabled", + "Binding": "ChatbotDepartmentConfig.IsEnabled", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "ChatbotSettings", + "Action": "Index", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.automation", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.ChatbotDepartmentConfig.IsEnabled", + "DefaultValue": "false", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "ChatbotDepartmentConfig", + "Availability": "reference-only" + }, + { + "Id": "table.ChatbotDepartmentConfig.AllowedPlatforms", + "AreaId": "automation", + "LabelKey": "TableField.ChatbotDepartmentConfig.AllowedPlatforms", + "HelpKey": "TableHelp.ChatbotDepartmentConfig.AllowedPlatforms", + "Binding": "ChatbotDepartmentConfig.AllowedPlatforms", + "ValueType": "list", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "ChatbotSettings", + "Action": "Index", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.automation", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.ChatbotDepartmentConfig.AllowedPlatforms", + "DefaultValue": "*", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "ChatbotDepartmentConfig", + "Availability": "reference-only" + }, + { + "Id": "table.ChatbotDepartmentConfig.MaxSessionsPerUser", + "AreaId": "automation", + "LabelKey": "TableField.ChatbotDepartmentConfig.MaxSessionsPerUser", + "HelpKey": "TableHelp.ChatbotDepartmentConfig.MaxSessionsPerUser", + "Binding": "ChatbotDepartmentConfig.MaxSessionsPerUser", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "ChatbotSettings", + "Action": "Index", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.automation", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.ChatbotDepartmentConfig.MaxSessionsPerUser", + "DefaultValue": "3", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "ChatbotDepartmentConfig", + "Availability": "reference-only" + }, + { + "Id": "table.ChatbotDepartmentConfig.SessionTtlMinutes", + "AreaId": "automation", + "LabelKey": "TableField.ChatbotDepartmentConfig.SessionTtlMinutes", + "HelpKey": "TableHelp.ChatbotDepartmentConfig.SessionTtlMinutes", + "Binding": "ChatbotDepartmentConfig.SessionTtlMinutes", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "ChatbotSettings", + "Action": "Index", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.automation", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.ChatbotDepartmentConfig.SessionTtlMinutes", + "DefaultValue": "30", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "ChatbotDepartmentConfig", + "Availability": "reference-only" + }, + { + "Id": "table.ChatbotDepartmentConfig.AllowDispatchViaChatbot", + "AreaId": "automation", + "LabelKey": "TableField.ChatbotDepartmentConfig.AllowDispatchViaChatbot", + "HelpKey": "TableHelp.ChatbotDepartmentConfig.AllowDispatchViaChatbot", + "Binding": "ChatbotDepartmentConfig.AllowDispatchViaChatbot", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "ChatbotSettings", + "Action": "Index", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.automation", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.ChatbotDepartmentConfig.AllowDispatchViaChatbot", + "DefaultValue": "false", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "ChatbotDepartmentConfig", + "Availability": "reference-only" + }, + { + "Id": "table.ChatbotDepartmentConfig.RequireConfirmationForStatusChange", + "AreaId": "automation", + "LabelKey": "TableField.ChatbotDepartmentConfig.RequireConfirmationForStatusChange", + "HelpKey": "TableHelp.ChatbotDepartmentConfig.RequireConfirmationForStatusChange", + "Binding": "ChatbotDepartmentConfig.RequireConfirmationForStatusChange", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "ChatbotSettings", + "Action": "Index", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.automation", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.ChatbotDepartmentConfig.RequireConfirmationForStatusChange", + "DefaultValue": "false", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "ChatbotDepartmentConfig", + "Availability": "reference-only" + }, + { + "Id": "table.ChatbotDepartmentConfig.LlmApiEndpoint", + "AreaId": "automation", + "LabelKey": "TableField.ChatbotDepartmentConfig.LlmApiEndpoint", + "HelpKey": "TableHelp.ChatbotDepartmentConfig.LlmApiEndpoint", + "Binding": "ChatbotDepartmentConfig.LlmApiEndpoint", + "ValueType": "restricted", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "ChatbotSettings", + "Action": "Index", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.automation", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.ChatbotDepartmentConfig.LlmApiEndpoint", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "ChatbotDepartmentConfig", + "Availability": "reference-only" + }, + { + "Id": "table.ChatbotDepartmentConfig.LlmApiKey", + "AreaId": "automation", + "LabelKey": "TableField.ChatbotDepartmentConfig.LlmApiKey", + "HelpKey": "TableHelp.ChatbotDepartmentConfig.LlmApiKey", + "Binding": "ChatbotDepartmentConfig.LlmApiKey", + "ValueType": "secret", + "Classification": "Restricted", + "Secret": true, + "Location": { + "Controller": "ChatbotSettings", + "Action": "Index", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.automation", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.ChatbotDepartmentConfig.LlmApiKey", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "ChatbotDepartmentConfig", + "Availability": "reference-only" + }, + { + "Id": "table.ChatbotDepartmentConfig.LlmModelName", + "AreaId": "automation", + "LabelKey": "TableField.ChatbotDepartmentConfig.LlmModelName", + "HelpKey": "TableHelp.ChatbotDepartmentConfig.LlmModelName", + "Binding": "ChatbotDepartmentConfig.LlmModelName", + "ValueType": "text", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "ChatbotSettings", + "Action": "Index", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.automation", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.ChatbotDepartmentConfig.LlmModelName", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "ChatbotDepartmentConfig", + "Availability": "reference-only" + }, + { + "Id": "table.ChatbotDepartmentConfig.MessagesPerUserPerMinute", + "AreaId": "automation", + "LabelKey": "TableField.ChatbotDepartmentConfig.MessagesPerUserPerMinute", + "HelpKey": "TableHelp.ChatbotDepartmentConfig.MessagesPerUserPerMinute", + "Binding": "ChatbotDepartmentConfig.MessagesPerUserPerMinute", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "ChatbotSettings", + "Action": "Index", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.automation", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.ChatbotDepartmentConfig.MessagesPerUserPerMinute", + "DefaultValue": "host default", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "ChatbotDepartmentConfig", + "Availability": "reference-only" + }, + { + "Id": "table.ChatbotDepartmentConfig.MessagesPerDepartmentPerMinute", + "AreaId": "automation", + "LabelKey": "TableField.ChatbotDepartmentConfig.MessagesPerDepartmentPerMinute", + "HelpKey": "TableHelp.ChatbotDepartmentConfig.MessagesPerDepartmentPerMinute", + "Binding": "ChatbotDepartmentConfig.MessagesPerDepartmentPerMinute", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "ChatbotSettings", + "Action": "Index", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.automation", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.ChatbotDepartmentConfig.MessagesPerDepartmentPerMinute", + "DefaultValue": "host default", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "ChatbotDepartmentConfig", + "Availability": "reference-only" + }, + { + "Id": "table.ChatbotDepartmentConfig.RequireLinkingConfirmation", + "AreaId": "automation", + "LabelKey": "TableField.ChatbotDepartmentConfig.RequireLinkingConfirmation", + "HelpKey": "TableHelp.ChatbotDepartmentConfig.RequireLinkingConfirmation", + "Binding": "ChatbotDepartmentConfig.RequireLinkingConfirmation", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "ChatbotSettings", + "Action": "Index", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.automation", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.ChatbotDepartmentConfig.RequireLinkingConfirmation", + "DefaultValue": "true", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "ChatbotDepartmentConfig", + "Availability": "reference-only" + }, + { + "Id": "table.ChatbotDepartmentConfig.ProactiveNotificationsEnabled", + "AreaId": "automation", + "LabelKey": "TableField.ChatbotDepartmentConfig.ProactiveNotificationsEnabled", + "HelpKey": "TableHelp.ChatbotDepartmentConfig.ProactiveNotificationsEnabled", + "Binding": "ChatbotDepartmentConfig.ProactiveNotificationsEnabled", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "ChatbotSettings", + "Action": "Index", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.automation", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.ChatbotDepartmentConfig.ProactiveNotificationsEnabled", + "DefaultValue": "false", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "ChatbotDepartmentConfig", + "Availability": "reference-only" + } + ], + "Capabilities": [ + { + "Id": "workflows", + "AreaId": "automation", + "LabelKey": "Feature.workflows", + "PurposeKey": "FeaturePurpose.workflows", + "ValueKey": "AreaValue.automation", + "ExampleKey": "AreaExample.automation", + "AdoptionKey": "FeatureAdoption.workflows", + "ReleaseStatus": "available", + "Location": { + "Controller": "Workflows", + "Action": "Index" + }, + "Requirements": [], + "SettingIds": [], + "RuleIds": [ + "workflow-failures" + ] + }, + { + "Id": "new-workflow", + "AreaId": "automation", + "LabelKey": "Feature.new-workflow", + "PurposeKey": "FeaturePurpose.new-workflow", + "ValueKey": "AreaValue.automation", + "ExampleKey": "AreaExample.automation", + "AdoptionKey": "FeatureAdoption.new-workflow", + "ReleaseStatus": "available", + "Location": { + "Controller": "Workflows", + "Action": "New" + }, + "Requirements": [], + "SettingIds": [], + "RuleIds": [] + }, + { + "Id": "workflow-runs", + "AreaId": "automation", + "LabelKey": "Feature.workflow-runs", + "PurposeKey": "FeaturePurpose.workflow-runs", + "ValueKey": "AreaValue.automation", + "ExampleKey": "AreaExample.automation", + "AdoptionKey": "FeatureAdoption.workflow-runs", + "ReleaseStatus": "available", + "Location": { + "Controller": "Workflows", + "Action": "Runs" + }, + "Requirements": [], + "SettingIds": [], + "RuleIds": [] + }, + { + "Id": "protected-workflows", + "AreaId": "automation", + "LabelKey": "Feature.protected-workflows", + "PurposeKey": "FeaturePurpose.protected-workflows", + "ValueKey": "FeatureValue.protected-workflows", + "ExampleKey": "AreaExample.automation", + "AdoptionKey": "FeatureAdoption.protected-workflows", + "ReleaseStatus": "available", + "Location": { + "Controller": "ProtectedWorkflows", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "addon", + "Id": "ADP" + }, + { + "Kind": "protection", + "Id": "Enabled" + } + ], + "SettingIds": [], + "RuleIds": [] + }, + { + "Id": "user-defined-fields", + "AreaId": "automation", + "LabelKey": "Feature.user-defined-fields", + "PurposeKey": "FeaturePurpose.user-defined-fields", + "ValueKey": "FeatureValue.user-defined-fields", + "ExampleKey": "AreaExample.automation", + "AdoptionKey": "FeatureAdoption.user-defined-fields", + "ReleaseStatus": "available", + "Location": { + "Controller": "UserDefinedFields", + "Action": "Index" + }, + "Requirements": [], + "SettingIds": [], + "RuleIds": [] + }, + { + "Id": "api-mcp", + "AreaId": "automation", + "LabelKey": "Feature.api-mcp", + "PurposeKey": "FeaturePurpose.api-mcp", + "ValueKey": "FeatureValue.api-mcp", + "ExampleKey": "AreaExample.automation", + "AdoptionKey": "FeatureAdoption.api-mcp", + "ReleaseStatus": "available", + "Location": { + "Controller": "Department", + "Action": "Settings" + }, + "Requirements": [], + "SettingIds": [ + "field.DepartmentSuppressStaffingInfo.EnableSupressStaffing", + "field.DepartmentSuppressStaffingInfo.StaffingLevelsToSupress", + "field.PersonnelListStatusOrder.StatusId", + "field.PersonnelListStatusOrder.Weight", + "field.PersonnelListStatusOrderSetting.Orders", + "setting.BigBoardHideUnavailable", + "setting.BigBoardMapCenterAddress", + "setting.BigBoardMapCenterGpsCoordinates", + "setting.BigBoardMapZoomLevel", + "setting.BigBoardPageRefresh", + "setting.CallsSortOrder", + "setting.DisabledAutoAvailable", + "setting.EnableModernNotifications", + "setting.ForceChatbotSecurityPin", + "setting.PersonnelListStatusSortOrder", + "setting.PersonnelSortOrder", + "setting.RequirePasswordResetViaEmail", + "setting.StaffingSuppressStaffingLevels", + "setting.TestEnabled", + "setting.UnitsSortOrder", + "setting.UpdateTimestamp", + "table.Department.ApiKey", + "table.Department.Code", + "table.Department.LinkCode", + "table.Department.ManagingUserId", + "table.Department.PublicApiKey", + "table.Department.SharedSecret", + "table.Department.ShowWelcome", + "table.Department.TimeZone", + "table.Department.Use24HourTime" + ], + "RuleIds": [] + }, + { + "Id": "import-migration", + "AreaId": "automation", + "LabelKey": "Feature.import-migration", + "PurposeKey": "FeaturePurpose.import-migration", + "ValueKey": "FeatureValue.import-migration", + "ExampleKey": "AreaExample.automation", + "AdoptionKey": "FeatureAdoption.import-migration", + "ReleaseStatus": "available", + "Location": { + "Controller": "Department", + "Action": "SetupWizard" + }, + "Requirements": [], + "SettingIds": [], + "RuleIds": [] + } + ], + "Rules": [ + { + "Id": "workflow-failures", + "AreaId": "automation", + "Severity": "Warning", + "TitleKey": "Rule.workflow-failures", + "ExplanationKey": "RuleWhy.workflow-failures", + "NextActionKey": "RuleNext.workflow-failures", + "Location": { + "Controller": "Workflows", + "Action": "Index" + }, + "AppliesWhen": [], + "FailsWhen": [ + { + "EvidenceId": "failedWorkflowCount", + "Comparison": "Greater", + "Number": 0 + } + ] + } + ], + "Packs": [], + "Articles": [ + { + "Id": "table.ChatbotDepartmentConfig.IsEnabled", + "Locale": "en", + "TitleKey": "TableField.ChatbotDepartmentConfig.IsEnabled", + "Body": "Chatbot Department Config / Is Enabled. Department chatbot availability. Platform feature rollout, account linking and action permissions are separate gates.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-chatbotdepartmentconfig-isenabled", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.ChatbotDepartmentConfig.AllowedPlatforms", + "Locale": "en", + "TitleKey": "TableField.ChatbotDepartmentConfig.AllowedPlatforms", + "Body": "Chatbot Department Config / Allowed Platforms. Allowed chatbot platform codes; the stored asterisk represents the platform default. Review each connected provider and its account-linking requirements.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-chatbotdepartmentconfig-allowedplatforms", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.ChatbotDepartmentConfig.MaxSessionsPerUser", + "Locale": "en", + "TitleKey": "TableField.ChatbotDepartmentConfig.MaxSessionsPerUser", + "Body": "Chatbot Department Config / Max Sessions Per User. Maximum chatbot sessions per member in supported consumers. This is separate from authenticated web/mobile session limits.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-chatbotdepartmentconfig-maxsessionsperuser", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.ChatbotDepartmentConfig.SessionTtlMinutes", + "Locale": "en", + "TitleKey": "TableField.ChatbotDepartmentConfig.SessionTtlMinutes", + "Body": "Chatbot Department Config / Session Ttl Minutes. Chatbot session lifetime in minutes. Expiry does not revoke an unrelated Resgrid sign-in session.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-chatbotdepartmentconfig-sessionttlminutes", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.ChatbotDepartmentConfig.AllowDispatchViaChatbot", + "Locale": "en", + "TitleKey": "TableField.ChatbotDepartmentConfig.AllowDispatchViaChatbot", + "Body": "Chatbot Department Config / Allow Dispatch Via Chatbot. Permits supported chatbot dispatch actions subject to their own authorization and confirmation. Enabling it may allow real calls and pages.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-chatbotdepartmentconfig-allowdispatchviachatbot", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.ChatbotDepartmentConfig.RequireConfirmationForStatusChange", + "Locale": "en", + "TitleKey": "TableField.ChatbotDepartmentConfig.RequireConfirmationForStatusChange", + "Body": "Chatbot Department Config / Require Confirmation For Status Change. Requests confirmation for supported status changes. Review each platform consumer before assuming all commands are covered.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-chatbotdepartmentconfig-requireconfirmationforstatuschange", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.ChatbotDepartmentConfig.LlmApiEndpoint", + "Locale": "en", + "TitleKey": "TableField.ChatbotDepartmentConfig.LlmApiEndpoint", + "Body": "Chatbot Department Config / Llm Api Endpoint. Optional existing chatbot model endpoint. Phase 0 Admin Assist does not call it and does not use chatbot configuration as an AI entitlement.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-chatbotdepartmentconfig-llmapiendpoint", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.ChatbotDepartmentConfig.LlmApiKey", + "Locale": "en", + "TitleKey": "TableField.ChatbotDepartmentConfig.LlmApiKey", + "Body": "Chatbot Department Config / Llm Api Key. Credential for the chatbot model provider. Never copy it into setup metadata or public reference content.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-chatbotdepartmentconfig-llmapikey", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.ChatbotDepartmentConfig.LlmModelName", + "Locale": "en", + "TitleKey": "TableField.ChatbotDepartmentConfig.LlmModelName", + "Body": "Chatbot Department Config / Llm Model Name. Model identifier used by the existing chatbot integration. This is separate from the later Admin Assist open-source inference deployment.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-chatbotdepartmentconfig-llmmodelname", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.ChatbotDepartmentConfig.MessagesPerUserPerMinute", + "Locale": "en", + "TitleKey": "TableField.ChatbotDepartmentConfig.MessagesPerUserPerMinute", + "Body": "Chatbot Department Config / Messages Per User Per Minute. Optional per-member chatbot rate bound. Review provider limits and burst behavior; this is not a delivery guarantee.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-chatbotdepartmentconfig-messagesperuserperminute", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.ChatbotDepartmentConfig.MessagesPerDepartmentPerMinute", + "Locale": "en", + "TitleKey": "TableField.ChatbotDepartmentConfig.MessagesPerDepartmentPerMinute", + "Body": "Chatbot Department Config / Messages Per Department Per Minute. Optional department chatbot rate bound. Consider shared demand and provider quotas before changing it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-chatbotdepartmentconfig-messagesperdepartmentperminute", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.ChatbotDepartmentConfig.RequireLinkingConfirmation", + "Locale": "en", + "TitleKey": "TableField.ChatbotDepartmentConfig.RequireLinkingConfirmation", + "Body": "Chatbot Department Config / Require Linking Confirmation. Requires confirmation in the supported account-linking flow. Linking must not bypass verified identity or departmental authorization.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-chatbotdepartmentconfig-requirelinkingconfirmation", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.ChatbotDepartmentConfig.ProactiveNotificationsEnabled", + "Locale": "en", + "TitleKey": "TableField.ChatbotDepartmentConfig.ProactiveNotificationsEnabled", + "Body": "Chatbot Department Config / Proactive Notifications Enabled. Allows supported proactive chatbot notifications. Recipient permissions, channel configuration and sending workflows remain authoritative.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-chatbotdepartmentconfig-proactivenotificationsenabled", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "workflows", + "Locale": "en", + "TitleKey": "Feature.workflows", + "Body": "Workflows Automations triggered by department events Reduce repeated administrative work while retaining ownership and review of external effects. Review a Workflow and its permissions before explicitly enabling it. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "workflows", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "new-workflow", + "Locale": "en", + "TitleKey": "Feature.new-workflow", + "Body": "New Workflow Create an automation workflow Reduce repeated administrative work while retaining ownership and review of external effects. Review a Workflow and its permissions before explicitly enabling it. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "new-workflow", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "workflow-runs", + "Locale": "en", + "TitleKey": "Feature.workflow-runs", + "Body": "Workflow Runs Workflow execution history Reduce repeated administrative work while retaining ownership and review of external effects. Review a Workflow and its permissions before explicitly enabling it. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "workflow-runs", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "protected-workflows", + "Locale": "en", + "TitleKey": "Feature.protected-workflows", + "Body": "Protected workflows Configure approved releases to supported external workflow destinations. Keep protected-data egress scoped, reviewed and auditable. Review a Workflow and its permissions before explicitly enabling it. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "protected-workflows", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "user-defined-fields", + "Locale": "en", + "TitleKey": "Feature.user-defined-fields", + "Body": "User-defined fields Define additional fields for supported forms and records. Collect the information the department actually needs with appropriate classification. Review a Workflow and its permissions before explicitly enabling it. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "user-defined-fields", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "api-mcp", + "Locale": "en", + "TitleKey": "Feature.api-mcp", + "Body": "API and MCP integrations Use supported APIs and MCP with authorized credentials and permissions. Connect approved systems without placing credentials in setup examples. Review a Workflow and its permissions before explicitly enabling it. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "api-mcp", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "import-migration", + "Locale": "en", + "TitleKey": "Feature.import-migration", + "Body": "Import and migration planning Plan source ownership and review existing import tools before copying data. Avoid duplicate resources and verify resulting configuration in Setup Report. Review a Workflow and its permissions before explicitly enabling it. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "import-migration", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + } + ] +} diff --git a/Core/Resgrid.AdminAssist/Catalog/business.yaml b/Core/Resgrid.AdminAssist/Catalog/business.yaml new file mode 100644 index 000000000..16cce1575 --- /dev/null +++ b/Core/Resgrid.AdminAssist/Catalog/business.yaml @@ -0,0 +1,1156 @@ +{ + "Version": "2026.09.24.1", + "Areas": [ + { + "Id": "business", + "LabelKey": "Area.business", + "PurposeKey": "AreaPurpose.business", + "Order": 9, + "Archetypes": [] + } + ], + "Settings": [ + { + "Id": "module.BusinessOperationsDisabled", + "AreaId": "business", + "LabelKey": "Module.BusinessOperationsDisabled", + "HelpKey": "ModuleHelp.BusinessOperationsDisabled", + "Binding": "DepartmentModuleSettings.BusinessOperationsDisabled", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "ModuleSettings", + "Field": "BusinessOperationsEnabled" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.business", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.ModuleSettings" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "module.BusinessOperationsDisabled", + "DefaultValue": "false", + "AllowedValues": "true or false", + "EvidenceSource": "DepartmentSettingsService.GetDepartmentModuleSettingsAsync", + "Availability": "available" + } + ], + "Capabilities": [ + { + "Id": "invoices", + "AreaId": "business", + "LabelKey": "Feature.invoices", + "PurposeKey": "FeaturePurpose.invoices", + "ValueKey": "AreaValue.business", + "ExampleKey": "AreaExample.business", + "AdoptionKey": "FeatureAdoption.invoices", + "ReleaseStatus": "available", + "Location": { + "Controller": "Invoicing", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "flag", + "Id": "Invoicing.CustomerInvoicing" + }, + { + "Kind": "module", + "Id": "BusinessOperations" + }, + { + "Kind": "addon", + "Id": "BusinessOperations" + } + ], + "SettingIds": [ + "module.BusinessOperationsDisabled" + ], + "RuleIds": [] + }, + { + "Id": "new-invoice", + "AreaId": "business", + "LabelKey": "Feature.new-invoice", + "PurposeKey": "FeaturePurpose.new-invoice", + "ValueKey": "AreaValue.business", + "ExampleKey": "AreaExample.business", + "AdoptionKey": "FeatureAdoption.new-invoice", + "ReleaseStatus": "available", + "Location": { + "Controller": "Invoicing", + "Action": "New" + }, + "Requirements": [ + { + "Kind": "flag", + "Id": "Invoicing.CustomerInvoicing" + }, + { + "Kind": "module", + "Id": "BusinessOperations" + }, + { + "Kind": "addon", + "Id": "BusinessOperations" + } + ], + "SettingIds": [ + "module.BusinessOperationsDisabled" + ], + "RuleIds": [] + }, + { + "Id": "rate-cards", + "AreaId": "business", + "LabelKey": "Feature.rate-cards", + "PurposeKey": "FeaturePurpose.rate-cards", + "ValueKey": "AreaValue.business", + "ExampleKey": "AreaExample.business", + "AdoptionKey": "FeatureAdoption.rate-cards", + "ReleaseStatus": "available", + "Location": { + "Controller": "Invoicing", + "Action": "RateCards" + }, + "Requirements": [ + { + "Kind": "flag", + "Id": "Invoicing.CustomerInvoicing" + }, + { + "Kind": "module", + "Id": "BusinessOperations" + }, + { + "Kind": "addon", + "Id": "BusinessOperations" + } + ], + "SettingIds": [ + "module.BusinessOperationsDisabled" + ], + "RuleIds": [] + }, + { + "Id": "invoice-aging", + "AreaId": "business", + "LabelKey": "Feature.invoice-aging", + "PurposeKey": "FeaturePurpose.invoice-aging", + "ValueKey": "AreaValue.business", + "ExampleKey": "AreaExample.business", + "AdoptionKey": "FeatureAdoption.invoice-aging", + "ReleaseStatus": "available", + "Location": { + "Controller": "Invoicing", + "Action": "Aging" + }, + "Requirements": [ + { + "Kind": "flag", + "Id": "Invoicing.CustomerInvoicing" + }, + { + "Kind": "module", + "Id": "BusinessOperations" + }, + { + "Kind": "addon", + "Id": "BusinessOperations" + } + ], + "SettingIds": [ + "module.BusinessOperationsDisabled" + ], + "RuleIds": [] + }, + { + "Id": "billing-settings", + "AreaId": "business", + "LabelKey": "Feature.billing-settings", + "PurposeKey": "FeaturePurpose.billing-settings", + "ValueKey": "AreaValue.business", + "ExampleKey": "AreaExample.business", + "AdoptionKey": "FeatureAdoption.billing-settings", + "ReleaseStatus": "available", + "Location": { + "Controller": "Invoicing", + "Action": "Settings" + }, + "Requirements": [ + { + "Kind": "flag", + "Id": "Invoicing.CustomerInvoicing" + }, + { + "Kind": "module", + "Id": "BusinessOperations" + }, + { + "Kind": "addon", + "Id": "BusinessOperations" + } + ], + "SettingIds": [ + "module.BusinessOperationsDisabled" + ], + "RuleIds": [] + }, + { + "Id": "online-payment-settings", + "AreaId": "business", + "LabelKey": "Feature.online-payment-settings", + "PurposeKey": "FeaturePurpose.online-payment-settings", + "ValueKey": "AreaValue.business", + "ExampleKey": "AreaExample.business", + "AdoptionKey": "FeatureAdoption.online-payment-settings", + "ReleaseStatus": "available", + "Location": { + "Controller": "Invoicing", + "Action": "Settings" + }, + "Requirements": [ + { + "Kind": "flag", + "Id": "Invoicing.OnlinePayments" + }, + { + "Kind": "module", + "Id": "BusinessOperations" + }, + { + "Kind": "addon", + "Id": "BusinessOperations" + } + ], + "SettingIds": [ + "module.BusinessOperationsDisabled" + ], + "RuleIds": [] + }, + { + "Id": "deployments", + "AreaId": "business", + "LabelKey": "Feature.deployments", + "PurposeKey": "FeaturePurpose.deployments", + "ValueKey": "AreaValue.business", + "ExampleKey": "AreaExample.business", + "AdoptionKey": "FeatureAdoption.deployments", + "ReleaseStatus": "available", + "Location": { + "Controller": "Deployments", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "flag", + "Id": "Operations.Deployments" + } + ], + "SettingIds": [], + "RuleIds": [] + }, + { + "Id": "new-deployment", + "AreaId": "business", + "LabelKey": "Feature.new-deployment", + "PurposeKey": "FeaturePurpose.new-deployment", + "ValueKey": "AreaValue.business", + "ExampleKey": "AreaExample.business", + "AdoptionKey": "FeatureAdoption.new-deployment", + "ReleaseStatus": "available", + "Location": { + "Controller": "Deployments", + "Action": "New" + }, + "Requirements": [ + { + "Kind": "flag", + "Id": "Operations.Deployments" + } + ], + "SettingIds": [], + "RuleIds": [] + }, + { + "Id": "deployment-from-external-order", + "AreaId": "business", + "LabelKey": "Feature.deployment-from-external-order", + "PurposeKey": "FeaturePurpose.deployment-from-external-order", + "ValueKey": "AreaValue.business", + "ExampleKey": "AreaExample.business", + "AdoptionKey": "FeatureAdoption.deployment-from-external-order", + "ReleaseStatus": "available", + "Location": { + "Controller": "Deployments", + "Action": "FromExternalOrder" + }, + "Requirements": [ + { + "Kind": "flag", + "Id": "Operations.Deployments" + } + ], + "SettingIds": [], + "RuleIds": [] + }, + { + "Id": "bids", + "AreaId": "business", + "LabelKey": "Feature.bids", + "PurposeKey": "FeaturePurpose.bids", + "ValueKey": "AreaValue.business", + "ExampleKey": "AreaExample.business", + "AdoptionKey": "FeatureAdoption.bids", + "ReleaseStatus": "available", + "Location": { + "Controller": "Bids", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "flag", + "Id": "Invoicing.ContractorBilling" + }, + { + "Kind": "module", + "Id": "BusinessOperations" + }, + { + "Kind": "addon", + "Id": "BusinessOperations" + } + ], + "SettingIds": [ + "module.BusinessOperationsDisabled" + ], + "RuleIds": [] + }, + { + "Id": "new-bid", + "AreaId": "business", + "LabelKey": "Feature.new-bid", + "PurposeKey": "FeaturePurpose.new-bid", + "ValueKey": "AreaValue.business", + "ExampleKey": "AreaExample.business", + "AdoptionKey": "FeatureAdoption.new-bid", + "ReleaseStatus": "available", + "Location": { + "Controller": "Bids", + "Action": "New" + }, + "Requirements": [ + { + "Kind": "flag", + "Id": "Invoicing.ContractorBilling" + }, + { + "Kind": "module", + "Id": "BusinessOperations" + }, + { + "Kind": "addon", + "Id": "BusinessOperations" + } + ], + "SettingIds": [ + "module.BusinessOperationsDisabled" + ], + "RuleIds": [] + }, + { + "Id": "contracts", + "AreaId": "business", + "LabelKey": "Feature.contracts", + "PurposeKey": "FeaturePurpose.contracts", + "ValueKey": "AreaValue.business", + "ExampleKey": "AreaExample.business", + "AdoptionKey": "FeatureAdoption.contracts", + "ReleaseStatus": "available", + "Location": { + "Controller": "Contracts", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "flag", + "Id": "Invoicing.ContractorBilling" + }, + { + "Kind": "module", + "Id": "BusinessOperations" + }, + { + "Kind": "addon", + "Id": "BusinessOperations" + } + ], + "SettingIds": [ + "module.BusinessOperationsDisabled" + ], + "RuleIds": [] + }, + { + "Id": "new-contract", + "AreaId": "business", + "LabelKey": "Feature.new-contract", + "PurposeKey": "FeaturePurpose.new-contract", + "ValueKey": "AreaValue.business", + "ExampleKey": "AreaExample.business", + "AdoptionKey": "FeatureAdoption.new-contract", + "ReleaseStatus": "available", + "Location": { + "Controller": "Contracts", + "Action": "New" + }, + "Requirements": [ + { + "Kind": "flag", + "Id": "Invoicing.ContractorBilling" + }, + { + "Kind": "module", + "Id": "BusinessOperations" + }, + { + "Kind": "addon", + "Id": "BusinessOperations" + } + ], + "SettingIds": [ + "module.BusinessOperationsDisabled" + ], + "RuleIds": [] + }, + { + "Id": "compliance-documents", + "AreaId": "business", + "LabelKey": "Feature.compliance-documents", + "PurposeKey": "FeaturePurpose.compliance-documents", + "ValueKey": "AreaValue.business", + "ExampleKey": "AreaExample.business", + "AdoptionKey": "FeatureAdoption.compliance-documents", + "ReleaseStatus": "available", + "Location": { + "Controller": "Contracts", + "Action": "Compliance" + }, + "Requirements": [ + { + "Kind": "flag", + "Id": "Invoicing.ContractorBilling" + }, + { + "Kind": "module", + "Id": "BusinessOperations" + }, + { + "Kind": "addon", + "Id": "BusinessOperations" + } + ], + "SettingIds": [ + "module.BusinessOperationsDisabled" + ], + "RuleIds": [] + }, + { + "Id": "rate-schedules", + "AreaId": "business", + "LabelKey": "Feature.rate-schedules", + "PurposeKey": "FeaturePurpose.rate-schedules", + "ValueKey": "AreaValue.business", + "ExampleKey": "AreaExample.business", + "AdoptionKey": "FeatureAdoption.rate-schedules", + "ReleaseStatus": "available", + "Location": { + "Controller": "RateSchedules", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "flag", + "Id": "Invoicing.ContractorBilling" + }, + { + "Kind": "module", + "Id": "BusinessOperations" + }, + { + "Kind": "addon", + "Id": "BusinessOperations" + } + ], + "SettingIds": [ + "module.BusinessOperationsDisabled" + ], + "RuleIds": [] + }, + { + "Id": "cal-oes-mars", + "AreaId": "business", + "LabelKey": "Feature.cal-oes-mars", + "PurposeKey": "FeaturePurpose.cal-oes-mars", + "ValueKey": "AreaValue.business", + "ExampleKey": "AreaExample.business", + "AdoptionKey": "FeatureAdoption.cal-oes-mars", + "ReleaseStatus": "available", + "Location": { + "Controller": "CalOesMars", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "flag", + "Id": "CostRecovery.CalOesMars" + }, + { + "Kind": "module", + "Id": "BusinessOperations" + }, + { + "Kind": "addon", + "Id": "BusinessOperations" + } + ], + "SettingIds": [ + "module.BusinessOperationsDisabled" + ], + "RuleIds": [] + }, + { + "Id": "cal-oes-mars-queue", + "AreaId": "business", + "LabelKey": "Feature.cal-oes-mars-queue", + "PurposeKey": "FeaturePurpose.cal-oes-mars-queue", + "ValueKey": "AreaValue.business", + "ExampleKey": "AreaExample.business", + "AdoptionKey": "FeatureAdoption.cal-oes-mars-queue", + "ReleaseStatus": "available", + "Location": { + "Controller": "CalOesMars", + "Action": "Queue" + }, + "Requirements": [ + { + "Kind": "flag", + "Id": "CostRecovery.CalOesMars" + }, + { + "Kind": "module", + "Id": "BusinessOperations" + }, + { + "Kind": "addon", + "Id": "BusinessOperations" + } + ], + "SettingIds": [ + "module.BusinessOperationsDisabled" + ], + "RuleIds": [] + }, + { + "Id": "cal-oes-mars-rates", + "AreaId": "business", + "LabelKey": "Feature.cal-oes-mars-rates", + "PurposeKey": "FeaturePurpose.cal-oes-mars-rates", + "ValueKey": "AreaValue.business", + "ExampleKey": "AreaExample.business", + "AdoptionKey": "FeatureAdoption.cal-oes-mars-rates", + "ReleaseStatus": "available", + "Location": { + "Controller": "CalOesMars", + "Action": "Rates" + }, + "Requirements": [ + { + "Kind": "flag", + "Id": "CostRecovery.CalOesMars" + }, + { + "Kind": "module", + "Id": "BusinessOperations" + }, + { + "Kind": "addon", + "Id": "BusinessOperations" + } + ], + "SettingIds": [ + "module.BusinessOperationsDisabled" + ], + "RuleIds": [] + }, + { + "Id": "cal-oes-mars-reconciliation", + "AreaId": "business", + "LabelKey": "Feature.cal-oes-mars-reconciliation", + "PurposeKey": "FeaturePurpose.cal-oes-mars-reconciliation", + "ValueKey": "AreaValue.business", + "ExampleKey": "AreaExample.business", + "AdoptionKey": "FeatureAdoption.cal-oes-mars-reconciliation", + "ReleaseStatus": "available", + "Location": { + "Controller": "CalOesMars", + "Action": "Reconciliation" + }, + "Requirements": [ + { + "Kind": "flag", + "Id": "CostRecovery.CalOesMars" + }, + { + "Kind": "module", + "Id": "BusinessOperations" + }, + { + "Kind": "addon", + "Id": "BusinessOperations" + } + ], + "SettingIds": [ + "module.BusinessOperationsDisabled" + ], + "RuleIds": [] + }, + { + "Id": "workforce", + "AreaId": "business", + "LabelKey": "Feature.workforce", + "PurposeKey": "FeaturePurpose.workforce", + "ValueKey": "AreaValue.business", + "ExampleKey": "AreaExample.business", + "AdoptionKey": "FeatureAdoption.workforce", + "ReleaseStatus": "available", + "Location": { + "Controller": "Workforce", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "flag", + "Id": "Workforce.InternalCosting" + }, + { + "Kind": "module", + "Id": "BusinessOperations" + }, + { + "Kind": "addon", + "Id": "BusinessOperations" + } + ], + "SettingIds": [ + "module.BusinessOperationsDisabled" + ], + "RuleIds": [] + }, + { + "Id": "workforce-compensation", + "AreaId": "business", + "LabelKey": "Feature.workforce-compensation", + "PurposeKey": "FeaturePurpose.workforce-compensation", + "ValueKey": "AreaValue.business", + "ExampleKey": "AreaExample.business", + "AdoptionKey": "FeatureAdoption.workforce-compensation", + "ReleaseStatus": "available", + "Location": { + "Controller": "Workforce", + "Action": "Compensation" + }, + "Requirements": [ + { + "Kind": "flag", + "Id": "Workforce.InternalCosting" + }, + { + "Kind": "module", + "Id": "BusinessOperations" + }, + { + "Kind": "addon", + "Id": "BusinessOperations" + } + ], + "SettingIds": [ + "module.BusinessOperationsDisabled" + ], + "RuleIds": [] + }, + { + "Id": "workforce-annual-facts", + "AreaId": "business", + "LabelKey": "Feature.workforce-annual-facts", + "PurposeKey": "FeaturePurpose.workforce-annual-facts", + "ValueKey": "AreaValue.business", + "ExampleKey": "AreaExample.business", + "AdoptionKey": "FeatureAdoption.workforce-annual-facts", + "ReleaseStatus": "available", + "Location": { + "Controller": "Workforce", + "Action": "AnnualFacts" + }, + "Requirements": [ + { + "Kind": "flag", + "Id": "Workforce.InternalCosting" + }, + { + "Kind": "module", + "Id": "BusinessOperations" + }, + { + "Kind": "addon", + "Id": "BusinessOperations" + } + ], + "SettingIds": [ + "module.BusinessOperationsDisabled" + ], + "RuleIds": [] + }, + { + "Id": "resource-costs", + "AreaId": "business", + "LabelKey": "Feature.resource-costs", + "PurposeKey": "FeaturePurpose.resource-costs", + "ValueKey": "AreaValue.business", + "ExampleKey": "AreaExample.business", + "AdoptionKey": "FeatureAdoption.resource-costs", + "ReleaseStatus": "available", + "Location": { + "Controller": "Workforce", + "Action": "ResourceCosts" + }, + "Requirements": [ + { + "Kind": "flag", + "Id": "Workforce.InternalCosting" + }, + { + "Kind": "module", + "Id": "BusinessOperations" + }, + { + "Kind": "addon", + "Id": "BusinessOperations" + } + ], + "SettingIds": [ + "module.BusinessOperationsDisabled" + ], + "RuleIds": [] + }, + { + "Id": "cost-runs", + "AreaId": "business", + "LabelKey": "Feature.cost-runs", + "PurposeKey": "FeaturePurpose.cost-runs", + "ValueKey": "AreaValue.business", + "ExampleKey": "AreaExample.business", + "AdoptionKey": "FeatureAdoption.cost-runs", + "ReleaseStatus": "available", + "Location": { + "Controller": "Workforce", + "Action": "CostRuns" + }, + "Requirements": [ + { + "Kind": "flag", + "Id": "Workforce.InternalCosting" + }, + { + "Kind": "module", + "Id": "BusinessOperations" + }, + { + "Kind": "addon", + "Id": "BusinessOperations" + } + ], + "SettingIds": [ + "module.BusinessOperationsDisabled" + ], + "RuleIds": [] + }, + { + "Id": "pay-data-reporting", + "AreaId": "business", + "LabelKey": "Feature.pay-data-reporting", + "PurposeKey": "FeaturePurpose.pay-data-reporting", + "ValueKey": "AreaValue.business", + "ExampleKey": "AreaExample.business", + "AdoptionKey": "FeatureAdoption.pay-data-reporting", + "ReleaseStatus": "available", + "Location": { + "Controller": "Workforce", + "Action": "PayData" + }, + "Requirements": [ + { + "Kind": "flag", + "Id": "Compliance.CaliforniaPayDataReporting" + }, + { + "Kind": "module", + "Id": "BusinessOperations" + }, + { + "Kind": "addon", + "Id": "BusinessOperations" + }, + { + "Kind": "addon", + "Id": "ADP" + }, + { + "Kind": "protection", + "Id": "Enabled" + } + ], + "SettingIds": [ + "module.BusinessOperationsDisabled" + ], + "RuleIds": [] + }, + { + "Id": "my-demographics", + "AreaId": "business", + "LabelKey": "Feature.my-demographics", + "PurposeKey": "FeaturePurpose.my-demographics", + "ValueKey": "AreaValue.business", + "ExampleKey": "AreaExample.business", + "AdoptionKey": "FeatureAdoption.my-demographics", + "ReleaseStatus": "available", + "Location": { + "Controller": "Workforce", + "Action": "MyDemographics" + }, + "Requirements": [ + { + "Kind": "flag", + "Id": "Compliance.CaliforniaPayDataReporting" + }, + { + "Kind": "addon", + "Id": "BusinessOperations" + } + ], + "SettingIds": [], + "RuleIds": [] + } + ], + "Rules": [], + "Packs": [], + "Articles": [ + { + "Id": "module.BusinessOperationsDisabled", + "Locale": "en", + "TitleKey": "Module.BusinessOperationsDisabled", + "Body": "BusinessOperations availability. Controls availability of business operations. Disabling this module can remove navigation and block module operations; it does not delete its records. Turning it on still requires the applicable subscription, rollout and permissions.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "module-businessoperationsdisabled", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "invoices", + "Locale": "en", + "TitleKey": "Feature.invoices", + "Body": "Invoices Customer invoices and payments Connect deployment records to invoicing, reimbursement or internal cost review when needed. Compare an event standby invoice with its approved deployment documentation. Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "invoices", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "new-invoice", + "Locale": "en", + "TitleKey": "Feature.new-invoice", + "Body": "New Invoice Draft an invoice for a customer Connect deployment records to invoicing, reimbursement or internal cost review when needed. Compare an event standby invoice with its approved deployment documentation. Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "new-invoice", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "rate-cards", + "Locale": "en", + "TitleKey": "Feature.rate-cards", + "Body": "Rate Cards Billing rates for units, personnel and fees Connect deployment records to invoicing, reimbursement or internal cost review when needed. Compare an event standby invoice with its approved deployment documentation. Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "rate-cards", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "invoice-aging", + "Locale": "en", + "TitleKey": "Feature.invoice-aging", + "Body": "Accounts Receivable Aging Outstanding invoice balances by age Connect deployment records to invoicing, reimbursement or internal cost review when needed. Compare an event standby invoice with its approved deployment documentation. Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "invoice-aging", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "billing-settings", + "Locale": "en", + "TitleKey": "Feature.billing-settings", + "Body": "Billing Settings Legal name, remit-to address and tax registrations printed on invoices Connect deployment records to invoicing, reimbursement or internal cost review when needed. Compare an event standby invoice with its approved deployment documentation. Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "billing-settings", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "online-payment-settings", + "Locale": "en", + "TitleKey": "Feature.online-payment-settings", + "Body": "Online Payment Settings Connect your Stripe account to collect invoice payments online Connect deployment records to invoicing, reimbursement or internal cost review when needed. Compare an event standby invoice with its approved deployment documentation. Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "online-payment-settings", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "deployments", + "Locale": "en", + "TitleKey": "Feature.deployments", + "Body": "Deployment Finance Deployments, rosters, daily time reports and expenses Connect deployment records to invoicing, reimbursement or internal cost review when needed. Compare an event standby invoice with its approved deployment documentation. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "deployments", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "new-deployment", + "Locale": "en", + "TitleKey": "Feature.new-deployment", + "Body": "New Deployment Create a deployment finance wrapper Connect deployment records to invoicing, reimbursement or internal cost review when needed. Compare an event standby invoice with its approved deployment documentation. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "new-deployment", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "deployment-from-external-order", + "Locale": "en", + "TitleKey": "Feature.deployment-from-external-order", + "Body": "Deployment From External Order Create a deployment from an open RMS mutual-aid order Connect deployment records to invoicing, reimbursement or internal cost review when needed. Compare an event standby invoice with its approved deployment documentation. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "deployment-from-external-order", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "bids", + "Locale": "en", + "TitleKey": "Feature.bids", + "Body": "Bids Priced estimates for customer contacts and contracts Connect deployment records to invoicing, reimbursement or internal cost review when needed. Compare an event standby invoice with its approved deployment documentation. Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "bids", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "new-bid", + "Locale": "en", + "TitleKey": "Feature.new-bid", + "Body": "New Bid Draft a bid for a customer Connect deployment records to invoicing, reimbursement or internal cost review when needed. Compare an event standby invoice with its approved deployment documentation. Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "new-bid", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "contracts", + "Locale": "en", + "TitleKey": "Feature.contracts", + "Body": "Contracts Service contracts, document requirements and compliance Connect deployment records to invoicing, reimbursement or internal cost review when needed. Compare an event standby invoice with its approved deployment documentation. Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "contracts", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "new-contract", + "Locale": "en", + "TitleKey": "Feature.new-contract", + "Body": "New Contract Create a service contract for a customer Connect deployment records to invoicing, reimbursement or internal cost review when needed. Compare an event standby invoice with its approved deployment documentation. Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "new-contract", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "compliance-documents", + "Locale": "en", + "TitleKey": "Feature.compliance-documents", + "Body": "Compliance Documents Insurance, workers' comp, SAM, licences and bonds with expiry alerts Connect deployment records to invoicing, reimbursement or internal cost review when needed. Compare an event standby invoice with its approved deployment documentation. Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "compliance-documents", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "rate-schedules", + "Locale": "en", + "TitleKey": "Feature.rate-schedules", + "Body": "Rate Schedules Contractor rate tables: certifications, crews, vehicles, equipment, premiums and policies Connect deployment records to invoicing, reimbursement or internal cost review when needed. Compare an event standby invoice with its approved deployment documentation. Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "rate-schedules", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "cal-oes-mars", + "Locale": "en", + "TitleKey": "Feature.cal-oes-mars", + "Body": "Cal OES MARS CFAA cost recovery readiness: agency, F-5 resources, annual rates and agreements Connect deployment records to invoicing, reimbursement or internal cost review when needed. Compare an event standby invoice with its approved deployment documentation. Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "cal-oes-mars", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "cal-oes-mars-queue", + "Locale": "en", + "TitleKey": "Feature.cal-oes-mars-queue", + "Body": "MARS Action Queue F-42 and expense claims to prepare, validate and hand off to the MARS portal Connect deployment records to invoicing, reimbursement or internal cost review when needed. Compare an event standby invoice with its approved deployment documentation. Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "cal-oes-mars-queue", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "cal-oes-mars-rates", + "Locale": "en", + "TitleKey": "Feature.cal-oes-mars-rates", + "Body": "MARS Annual Rates Salary Survey, Attachment A, Administrative Rate, Rate Letter and Special Equipment snapshots Connect deployment records to invoicing, reimbursement or internal cost review when needed. Compare an event standby invoice with its approved deployment documentation. Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "cal-oes-mars-rates", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "cal-oes-mars-reconciliation", + "Locale": "en", + "TitleKey": "Feature.cal-oes-mars-reconciliation", + "Body": "MARS Reconciliation Observed MARS invoices, local approval and payment reconciliation Connect deployment records to invoicing, reimbursement or internal cost review when needed. Compare an event standby invoice with its approved deployment documentation. Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "cal-oes-mars-reconciliation", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "workforce", + "Locale": "en", + "TitleKey": "Feature.workforce", + "Body": "Workforce Employer identity, establishments, workers, employments and job assignments Connect deployment records to invoicing, reimbursement or internal cost review when needed. Compare an event standby invoice with its approved deployment documentation. Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "workforce", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "workforce-compensation", + "Locale": "en", + "TitleKey": "Feature.workforce-compensation", + "Body": "Compensation Profiles Employee, role-default and department-default compensation with pay and employer-cost components Connect deployment records to invoicing, reimbursement or internal cost review when needed. Compare an event standby invoice with its approved deployment documentation. Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "workforce-compensation", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "workforce-annual-facts", + "Locale": "en", + "TitleKey": "Feature.workforce-annual-facts", + "Body": "Annual Pay Facts W-2 earnings and hours per employment for pay data reporting, with CSV import Connect deployment records to invoicing, reimbursement or internal cost review when needed. Compare an event standby invoice with its approved deployment documentation. Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "workforce-annual-facts", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "resource-costs", + "Locale": "en", + "TitleKey": "Feature.resource-costs", + "Body": "Resource Cost Profiles Depreciation, fuel, maintenance and fixed costs per unit or asset Connect deployment records to invoicing, reimbursement or internal cost review when needed. Compare an event standby invoice with its approved deployment documentation. Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "resource-costs", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "cost-runs", + "Locale": "en", + "TitleKey": "Feature.cost-runs", + "Body": "Field Cost Runs Internal loaded cost and margin for bids, calls and deployments Connect deployment records to invoicing, reimbursement or internal cost review when needed. Compare an event standby invoice with its approved deployment documentation. Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "cost-runs", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "pay-data-reporting", + "Locale": "en", + "TitleKey": "Feature.pay-data-reporting", + "Body": "California Pay Data Reporting CRD pay data report runs: snapshots, aggregation, validation, export and the portal worksheet Connect deployment records to invoicing, reimbursement or internal cost review when needed. Compare an event standby invoice with its approved deployment documentation. Requires Business Operations and Advanced Data Protection in the Enabled state. Review regional applicability and protected workforce access.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "pay-data-reporting", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "my-demographics", + "Locale": "en", + "TitleKey": "Feature.my-demographics", + "Body": "My Demographic Response Your voluntary self-identification for California pay data reporting Connect deployment records to invoicing, reimbursement or internal cost review when needed. Compare an event standby invoice with its approved deployment documentation. Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "my-demographics", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + } + ] +} diff --git a/Core/Resgrid.AdminAssist/Catalog/calls.yaml b/Core/Resgrid.AdminAssist/Catalog/calls.yaml new file mode 100644 index 000000000..1c4d10924 --- /dev/null +++ b/Core/Resgrid.AdminAssist/Catalog/calls.yaml @@ -0,0 +1,2833 @@ +{ + "Version": "2026.09.24.1", + "Areas": [ + { + "Id": "calls", + "LabelKey": "Area.calls", + "PurposeKey": "AreaPurpose.calls", + "Order": 1, + "Archetypes": [] + } + ], + "Settings": [ + { + "Id": "setting.RssFeedKeyForActiveCalls", + "AreaId": "calls", + "LabelKey": "Setting.RssFeedKeyForActiveCalls", + "HelpKey": "SettingHelp.RssFeedKeyForActiveCalls", + "Binding": "DepartmentSettingTypes.RssFeedKeyForActiveCalls", + "ValueType": "secret", + "Classification": "Restricted", + "Secret": true, + "Location": { + "Controller": "Department", + "Action": "CallSettings", + "Field": "RssFeedKeyForActiveCalls" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.RssFeedKeyForActiveCalls", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.TextToCallNumber", + "AreaId": "calls", + "LabelKey": "Setting.TextToCallNumber", + "HelpKey": "SettingHelp.TextToCallNumber", + "Binding": "DepartmentSettingTypes.TextToCallNumber", + "ValueType": "provider-reference", + "Classification": "Restricted", + "Secret": true, + "Location": { + "Controller": "Department", + "Action": "TextSettings", + "Field": "TextToCallNumber" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.TextToCallNumber", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.TextToCallImportFormat", + "AreaId": "calls", + "LabelKey": "Setting.TextToCallImportFormat", + "HelpKey": "SettingHelp.TextToCallImportFormat", + "Binding": "DepartmentSettingTypes.TextToCallImportFormat", + "ValueType": "enum", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "TextSettings", + "Field": "TextToCallImportFormat" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.TextToCallImportFormat", + "DefaultValue": "owning parser default", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.TextToCallSourceNumbers", + "AreaId": "calls", + "LabelKey": "Setting.TextToCallSourceNumbers", + "HelpKey": "SettingHelp.TextToCallSourceNumbers", + "Binding": "DepartmentSettingTypes.TextToCallSourceNumbers", + "ValueType": "sensitive-list", + "Classification": "Restricted", + "Secret": true, + "Location": { + "Controller": "Department", + "Action": "TextSettings", + "Field": "TextToCallSourceNumbers" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.TextToCallSourceNumbers", + "DefaultValue": "empty", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.EnableTextToCall", + "AreaId": "calls", + "LabelKey": "Setting.EnableTextToCall", + "HelpKey": "SettingHelp.EnableTextToCall", + "Binding": "DepartmentSettingTypes.EnableTextToCall", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "TextSettings", + "Field": "EnableTextToCall" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.EnableTextToCall", + "DefaultValue": "false", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.InternalDispatchEmail", + "AreaId": "calls", + "LabelKey": "Setting.InternalDispatchEmail", + "HelpKey": "SettingHelp.InternalDispatchEmail", + "Binding": "DepartmentSettingTypes.InternalDispatchEmail", + "ValueType": "secret", + "Classification": "Restricted", + "Secret": true, + "Location": { + "Controller": "Department", + "Action": "CallSettings", + "Field": "InternalDispatchEmail" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.InternalDispatchEmail", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.CallsSortOrder", + "AreaId": "calls", + "LabelKey": "Setting.CallsSortOrder", + "HelpKey": "SettingHelp.CallsSortOrder", + "Binding": "DepartmentSettingTypes.CallsSortOrder", + "ValueType": "enum", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "Settings", + "Field": "CallsSortOrder" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.CallsSortOrder", + "DefaultValue": "owning view default", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.DispatchShiftInsteadOfGroup", + "AreaId": "calls", + "LabelKey": "Setting.DispatchShiftInsteadOfGroup", + "HelpKey": "SettingHelp.DispatchShiftInsteadOfGroup", + "Binding": "DepartmentSettingTypes.DispatchShiftInsteadOfGroup", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "DispatchSettings", + "Field": "DispatchShiftInsteadOfGroup" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [ + "setting.AutoSetStatusForShiftDispatchPersonnel" + ], + "Conflicts": [], + "DocumentationId": "setting.DispatchShiftInsteadOfGroup", + "DefaultValue": "false", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.AutoSetStatusForShiftDispatchPersonnel", + "AreaId": "calls", + "LabelKey": "Setting.AutoSetStatusForShiftDispatchPersonnel", + "HelpKey": "SettingHelp.AutoSetStatusForShiftDispatchPersonnel", + "Binding": "DepartmentSettingTypes.AutoSetStatusForShiftDispatchPersonnel", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "DispatchSettings", + "Field": "AutoSetStatusForShiftPersonnel" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.DispatchShiftInsteadOfGroup" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.AutoSetStatusForShiftDispatchPersonnel", + "DefaultValue": "false", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.ShiftCallDispatchPersonnelStatusToSet", + "AreaId": "calls", + "LabelKey": "Setting.ShiftCallDispatchPersonnelStatusToSet", + "HelpKey": "SettingHelp.ShiftCallDispatchPersonnelStatusToSet", + "Binding": "DepartmentSettingTypes.ShiftCallDispatchPersonnelStatusToSet", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "DispatchSettings", + "Field": "ShiftCallDispatchPersonnelStatusToSet" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.ShiftCallDispatchPersonnelStatusToSet", + "DefaultValue": "-1", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.ShiftCallReleasePersonnelStatusToSet", + "AreaId": "calls", + "LabelKey": "Setting.ShiftCallReleasePersonnelStatusToSet", + "HelpKey": "SettingHelp.ShiftCallReleasePersonnelStatusToSet", + "Binding": "DepartmentSettingTypes.ShiftCallReleasePersonnelStatusToSet", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "DispatchSettings", + "Field": "ShiftCallReleasePersonnelStatusToSet" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.ShiftCallReleasePersonnelStatusToSet", + "DefaultValue": "-1", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.UnitDispatchAlsoDispatchToAssignedPersonnel", + "AreaId": "calls", + "LabelKey": "Setting.UnitDispatchAlsoDispatchToAssignedPersonnel", + "HelpKey": "SettingHelp.UnitDispatchAlsoDispatchToAssignedPersonnel", + "Binding": "DepartmentSettingTypes.UnitDispatchAlsoDispatchToAssignedPersonnel", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "DispatchSettings", + "Field": "UnitDispatchAlsoDispatchToAssignedPersonnel" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.UnitDispatchAlsoDispatchToAssignedPersonnel", + "DefaultValue": "false", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.UnitDispatchAlsoDispatchToGroup", + "AreaId": "calls", + "LabelKey": "Setting.UnitDispatchAlsoDispatchToGroup", + "HelpKey": "SettingHelp.UnitDispatchAlsoDispatchToGroup", + "Binding": "DepartmentSettingTypes.UnitDispatchAlsoDispatchToGroup", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "DispatchSettings", + "Field": "UnitDispatchAlsoDispatchToGroup" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.UnitDispatchAlsoDispatchToGroup", + "DefaultValue": "false", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.CheckInTimersAutoEnableForNewCalls", + "AreaId": "calls", + "LabelKey": "Setting.CheckInTimersAutoEnableForNewCalls", + "HelpKey": "SettingHelp.CheckInTimersAutoEnableForNewCalls", + "Binding": "DepartmentSettingTypes.CheckInTimersAutoEnableForNewCalls", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "DispatchSettings", + "Field": "AutoEnableCheckInTimers" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.CheckInTimersAutoEnableForNewCalls", + "DefaultValue": "false", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.UnitCallDispatchStatusToSet", + "AreaId": "calls", + "LabelKey": "Setting.UnitCallDispatchStatusToSet", + "HelpKey": "SettingHelp.UnitCallDispatchStatusToSet", + "Binding": "DepartmentSettingTypes.UnitCallDispatchStatusToSet", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "DispatchSettings", + "Field": "UnitCallDispatchStatusToSet" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.UnitCallDispatchStatusToSet", + "DefaultValue": "-1", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.UnitCallReleaseStatusToSet", + "AreaId": "calls", + "LabelKey": "Setting.UnitCallReleaseStatusToSet", + "HelpKey": "SettingHelp.UnitCallReleaseStatusToSet", + "Binding": "DepartmentSettingTypes.UnitCallReleaseStatusToSet", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "DispatchSettings", + "Field": "UnitCallReleaseStatusToSet" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.UnitCallReleaseStatusToSet", + "DefaultValue": "-1", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.UnitCallStatusOverridesByUnitType", + "AreaId": "calls", + "LabelKey": "Setting.UnitCallStatusOverridesByUnitType", + "HelpKey": "SettingHelp.UnitCallStatusOverridesByUnitType", + "Binding": "DepartmentSettingTypes.UnitCallStatusOverridesByUnitType", + "ValueType": "protobuf", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "DispatchSettings", + "Field": "UnitCallStatusOverridesByUnitType" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.UnitCallStatusOverridesByUnitType", + "DefaultValue": "empty overrides", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.DispatchRecommendationMode", + "AreaId": "calls", + "LabelKey": "Setting.DispatchRecommendationMode", + "HelpKey": "SettingHelp.DispatchRecommendationMode", + "Binding": "DepartmentSettingTypes.DispatchRecommendationMode", + "ValueType": "enum", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "DispatchSettings", + "Field": "DispatchRecommendationMode" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [ + "setting.DispatchRecommendationConfig" + ], + "Conflicts": [], + "DocumentationId": "setting.DispatchRecommendationMode", + "DefaultValue": "0", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.DispatchRecommendationAutoDispatch", + "AreaId": "calls", + "LabelKey": "Setting.DispatchRecommendationAutoDispatch", + "HelpKey": "SettingHelp.DispatchRecommendationAutoDispatch", + "Binding": "DepartmentSettingTypes.DispatchRecommendationAutoDispatch", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "DispatchSettings", + "Field": "DispatchRecommendationAutoDispatch" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.DispatchRecommendationAutoDispatch", + "DefaultValue": "false", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.DispatchRecommendationConfig", + "AreaId": "calls", + "LabelKey": "Setting.DispatchRecommendationConfig", + "HelpKey": "SettingHelp.DispatchRecommendationConfig", + "Binding": "DepartmentSettingTypes.DispatchRecommendationConfig", + "ValueType": "protobuf", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "DispatchSettings", + "Field": "DispatchRecommendationConfig" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.DispatchRecommendationMode" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.DispatchRecommendationConfig", + "DefaultValue": "DispatchRecommendationConfig constructor", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.NewCallFieldPolicy", + "AreaId": "calls", + "LabelKey": "Setting.NewCallFieldPolicy", + "HelpKey": "SettingHelp.NewCallFieldPolicy", + "Binding": "DepartmentSettingTypes.NewCallFieldPolicy", + "ValueType": "protobuf", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "DispatchSettings", + "Field": "NewCallFieldPolicy" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.NewCallFieldPolicy", + "DefaultValue": "visible, not required", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.GroupDispatchScopeConfig", + "AreaId": "calls", + "LabelKey": "Setting.GroupDispatchScopeConfig", + "HelpKey": "SettingHelp.GroupDispatchScopeConfig", + "Binding": "DepartmentSettingTypes.GroupDispatchScopeConfig", + "ValueType": "protobuf", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "DispatchSettings", + "Field": "GroupDispatchScopeEnabled" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.GroupDispatchScopeConfig", + "DefaultValue": "disabled, no department-wide role exceptions", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "field.UnitTypeCallStatusOverrideSetting.Overrides", + "AreaId": "calls", + "LabelKey": "Field.UnitTypeCallStatusOverrideSetting.Overrides", + "HelpKey": "FieldHelp.UnitTypeCallStatusOverrideSetting.Overrides", + "Binding": "UnitTypeCallStatusOverrideSetting.Overrides", + "ValueType": "list", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "DispatchSettings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.UnitCallStatusOverridesByUnitType" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.UnitTypeCallStatusOverrideSetting.Overrides", + "DefaultValue": "empty", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "UnitTypeCallStatusOverrideSetting", + "Availability": "reference-only" + }, + { + "Id": "field.UnitTypeCallStatusOverride.UnitTypeId", + "AreaId": "calls", + "LabelKey": "Field.UnitTypeCallStatusOverride.UnitTypeId", + "HelpKey": "FieldHelp.UnitTypeCallStatusOverride.UnitTypeId", + "Binding": "UnitTypeCallStatusOverride.UnitTypeId", + "ValueType": "reference", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "DispatchSettings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.UnitCallStatusOverridesByUnitType" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.UnitTypeCallStatusOverride.UnitTypeId", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "UnitTypeCallStatusOverride", + "Availability": "reference-only" + }, + { + "Id": "field.UnitTypeCallStatusOverride.DispatchStatus", + "AreaId": "calls", + "LabelKey": "Field.UnitTypeCallStatusOverride.DispatchStatus", + "HelpKey": "FieldHelp.UnitTypeCallStatusOverride.DispatchStatus", + "Binding": "UnitTypeCallStatusOverride.DispatchStatus", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "DispatchSettings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.UnitCallStatusOverridesByUnitType" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.UnitTypeCallStatusOverride.DispatchStatus", + "DefaultValue": "-1", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "UnitTypeCallStatusOverride", + "Availability": "reference-only" + }, + { + "Id": "field.UnitTypeCallStatusOverride.ReleaseStatus", + "AreaId": "calls", + "LabelKey": "Field.UnitTypeCallStatusOverride.ReleaseStatus", + "HelpKey": "FieldHelp.UnitTypeCallStatusOverride.ReleaseStatus", + "Binding": "UnitTypeCallStatusOverride.ReleaseStatus", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "DispatchSettings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.UnitCallStatusOverridesByUnitType" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.UnitTypeCallStatusOverride.ReleaseStatus", + "DefaultValue": "-1", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "UnitTypeCallStatusOverride", + "Availability": "reference-only" + }, + { + "Id": "field.NewCallFieldPolicy.Rules", + "AreaId": "calls", + "LabelKey": "Field.NewCallFieldPolicy.Rules", + "HelpKey": "FieldHelp.NewCallFieldPolicy.Rules", + "Binding": "NewCallFieldPolicy.Rules", + "ValueType": "list", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "DispatchSettings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.NewCallFieldPolicy" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.NewCallFieldPolicy.Rules", + "DefaultValue": "empty", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "NewCallFieldPolicy", + "Availability": "reference-only" + }, + { + "Id": "field.NewCallFieldRule.Key", + "AreaId": "calls", + "LabelKey": "Field.NewCallFieldRule.Key", + "HelpKey": "FieldHelp.NewCallFieldRule.Key", + "Binding": "NewCallFieldRule.Key", + "ValueType": "enum", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "DispatchSettings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.NewCallFieldPolicy" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.NewCallFieldRule.Key", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "NewCallFieldRule", + "Availability": "reference-only" + }, + { + "Id": "field.NewCallFieldRule.Visible", + "AreaId": "calls", + "LabelKey": "Field.NewCallFieldRule.Visible", + "HelpKey": "FieldHelp.NewCallFieldRule.Visible", + "Binding": "NewCallFieldRule.Visible", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "DispatchSettings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.NewCallFieldPolicy" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.NewCallFieldRule.Visible", + "DefaultValue": "true", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "NewCallFieldRule", + "Availability": "reference-only" + }, + { + "Id": "field.NewCallFieldRule.Required", + "AreaId": "calls", + "LabelKey": "Field.NewCallFieldRule.Required", + "HelpKey": "FieldHelp.NewCallFieldRule.Required", + "Binding": "NewCallFieldRule.Required", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "DispatchSettings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.NewCallFieldPolicy" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.NewCallFieldRule.Required", + "DefaultValue": "false", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "NewCallFieldRule", + "Availability": "reference-only" + }, + { + "Id": "field.GroupDispatchScopeConfig.Enabled", + "AreaId": "calls", + "LabelKey": "Field.GroupDispatchScopeConfig.Enabled", + "HelpKey": "FieldHelp.GroupDispatchScopeConfig.Enabled", + "Binding": "GroupDispatchScopeConfig.Enabled", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "DispatchSettings", + "Field": "GroupDispatchScopeEnabled" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.GroupDispatchScopeConfig" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.GroupDispatchScopeConfig.Enabled", + "DefaultValue": "false", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "GroupDispatchScopeConfig", + "Availability": "available" + }, + { + "Id": "field.GroupDispatchScopeConfig.DepartmentWideRoleIds", + "AreaId": "calls", + "LabelKey": "Field.GroupDispatchScopeConfig.DepartmentWideRoleIds", + "HelpKey": "FieldHelp.GroupDispatchScopeConfig.DepartmentWideRoleIds", + "Binding": "GroupDispatchScopeConfig.DepartmentWideRoleIds", + "ValueType": "reference-list", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "DispatchSettings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.GroupDispatchScopeConfig" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.GroupDispatchScopeConfig.DepartmentWideRoleIds", + "DefaultValue": "empty", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "GroupDispatchScopeConfig", + "Availability": "reference-only" + }, + { + "Id": "field.DispatchRecommendationConfig.MaxLocationAgeSeconds", + "AreaId": "calls", + "LabelKey": "Field.DispatchRecommendationConfig.MaxLocationAgeSeconds", + "HelpKey": "FieldHelp.DispatchRecommendationConfig.MaxLocationAgeSeconds", + "Binding": "DispatchRecommendationConfig.MaxLocationAgeSeconds", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "DispatchSettings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.DispatchRecommendationConfig" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.DispatchRecommendationConfig.MaxLocationAgeSeconds", + "DefaultValue": "1800", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "DispatchRecommendationConfig", + "Availability": "reference-only" + }, + { + "Id": "field.DispatchRecommendationConfig.MaxRadiusMeters", + "AreaId": "calls", + "LabelKey": "Field.DispatchRecommendationConfig.MaxRadiusMeters", + "HelpKey": "FieldHelp.DispatchRecommendationConfig.MaxRadiusMeters", + "Binding": "DispatchRecommendationConfig.MaxRadiusMeters", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "DispatchSettings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.DispatchRecommendationConfig" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.DispatchRecommendationConfig.MaxRadiusMeters", + "DefaultValue": "0", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "DispatchRecommendationConfig", + "Availability": "reference-only" + }, + { + "Id": "field.DispatchRecommendationConfig.IncludeStaleLocations", + "AreaId": "calls", + "LabelKey": "Field.DispatchRecommendationConfig.IncludeStaleLocations", + "HelpKey": "FieldHelp.DispatchRecommendationConfig.IncludeStaleLocations", + "Binding": "DispatchRecommendationConfig.IncludeStaleLocations", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "DispatchSettings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.DispatchRecommendationConfig" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.DispatchRecommendationConfig.IncludeStaleLocations", + "DefaultValue": "false", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "DispatchRecommendationConfig", + "Availability": "reference-only" + }, + { + "Id": "field.DispatchRecommendationConfig.PersonnelMaxLocationAgeSeconds", + "AreaId": "calls", + "LabelKey": "Field.DispatchRecommendationConfig.PersonnelMaxLocationAgeSeconds", + "HelpKey": "FieldHelp.DispatchRecommendationConfig.PersonnelMaxLocationAgeSeconds", + "Binding": "DispatchRecommendationConfig.PersonnelMaxLocationAgeSeconds", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "DispatchSettings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.DispatchRecommendationConfig" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.DispatchRecommendationConfig.PersonnelMaxLocationAgeSeconds", + "DefaultValue": "1800", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "DispatchRecommendationConfig", + "Availability": "reference-only" + }, + { + "Id": "field.DispatchRecommendationConfig.UseRoutedEta", + "AreaId": "calls", + "LabelKey": "Field.DispatchRecommendationConfig.UseRoutedEta", + "HelpKey": "FieldHelp.DispatchRecommendationConfig.UseRoutedEta", + "Binding": "DispatchRecommendationConfig.UseRoutedEta", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "DispatchSettings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.DispatchRecommendationConfig" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.DispatchRecommendationConfig.UseRoutedEta", + "DefaultValue": "false", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "DispatchRecommendationConfig", + "Availability": "reference-only" + }, + { + "Id": "field.DispatchRecommendationConfig.EtaShortlistSize", + "AreaId": "calls", + "LabelKey": "Field.DispatchRecommendationConfig.EtaShortlistSize", + "HelpKey": "FieldHelp.DispatchRecommendationConfig.EtaShortlistSize", + "Binding": "DispatchRecommendationConfig.EtaShortlistSize", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "DispatchSettings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.DispatchRecommendationConfig" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.DispatchRecommendationConfig.EtaShortlistSize", + "DefaultValue": "5", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "DispatchRecommendationConfig", + "Availability": "reference-only" + }, + { + "Id": "field.DispatchRecommendationConfig.RestPeriodMinutes", + "AreaId": "calls", + "LabelKey": "Field.DispatchRecommendationConfig.RestPeriodMinutes", + "HelpKey": "FieldHelp.DispatchRecommendationConfig.RestPeriodMinutes", + "Binding": "DispatchRecommendationConfig.RestPeriodMinutes", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "DispatchSettings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.DispatchRecommendationConfig" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.DispatchRecommendationConfig.RestPeriodMinutes", + "DefaultValue": "0", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "DispatchRecommendationConfig", + "Availability": "reference-only" + }, + { + "Id": "field.DispatchRecommendationConfig.UnitMinimumStaffingLevel", + "AreaId": "calls", + "LabelKey": "Field.DispatchRecommendationConfig.UnitMinimumStaffingLevel", + "HelpKey": "FieldHelp.DispatchRecommendationConfig.UnitMinimumStaffingLevel", + "Binding": "DispatchRecommendationConfig.UnitMinimumStaffingLevel", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "DispatchSettings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.DispatchRecommendationConfig" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.DispatchRecommendationConfig.UnitMinimumStaffingLevel", + "DefaultValue": "0", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "DispatchRecommendationConfig", + "Availability": "reference-only" + }, + { + "Id": "field.DispatchRecommendationConfig.MoveUpRecommendationsEnabled", + "AreaId": "calls", + "LabelKey": "Field.DispatchRecommendationConfig.MoveUpRecommendationsEnabled", + "HelpKey": "FieldHelp.DispatchRecommendationConfig.MoveUpRecommendationsEnabled", + "Binding": "DispatchRecommendationConfig.MoveUpRecommendationsEnabled", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "DispatchSettings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.DispatchRecommendationConfig" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.DispatchRecommendationConfig.MoveUpRecommendationsEnabled", + "DefaultValue": "false", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "DispatchRecommendationConfig", + "Availability": "reference-only" + }, + { + "Id": "table.DepartmentCallEmail.Hostname", + "AreaId": "calls", + "LabelKey": "TableField.DepartmentCallEmail.Hostname", + "HelpKey": "TableHelp.DepartmentCallEmail.Hostname", + "Binding": "DepartmentCallEmail.Hostname", + "ValueType": "restricted", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "CallSettings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentCallEmail.Hostname", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentCallEmail", + "Availability": "reference-only" + }, + { + "Id": "table.DepartmentCallEmail.Port", + "AreaId": "calls", + "LabelKey": "TableField.DepartmentCallEmail.Port", + "HelpKey": "TableHelp.DepartmentCallEmail.Port", + "Binding": "DepartmentCallEmail.Port", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "CallSettings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentCallEmail.Port", + "DefaultValue": "editor/provider default", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentCallEmail", + "Availability": "reference-only" + }, + { + "Id": "table.DepartmentCallEmail.UseSsl", + "AreaId": "calls", + "LabelKey": "TableField.DepartmentCallEmail.UseSsl", + "HelpKey": "TableHelp.DepartmentCallEmail.UseSsl", + "Binding": "DepartmentCallEmail.UseSsl", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "CallSettings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentCallEmail.UseSsl", + "DefaultValue": "editor/provider default", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentCallEmail", + "Availability": "reference-only" + }, + { + "Id": "table.DepartmentCallEmail.Username", + "AreaId": "calls", + "LabelKey": "TableField.DepartmentCallEmail.Username", + "HelpKey": "TableHelp.DepartmentCallEmail.Username", + "Binding": "DepartmentCallEmail.Username", + "ValueType": "restricted", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "CallSettings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentCallEmail.Username", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentCallEmail", + "Availability": "reference-only" + }, + { + "Id": "table.DepartmentCallEmail.Password", + "AreaId": "calls", + "LabelKey": "TableField.DepartmentCallEmail.Password", + "HelpKey": "TableHelp.DepartmentCallEmail.Password", + "Binding": "DepartmentCallEmail.Password", + "ValueType": "secret", + "Classification": "Restricted", + "Secret": true, + "Location": { + "Controller": "Department", + "Action": "CallSettings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentCallEmail.Password", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentCallEmail", + "Availability": "reference-only" + }, + { + "Id": "table.DepartmentCallEmail.FormatType", + "AreaId": "calls", + "LabelKey": "TableField.DepartmentCallEmail.FormatType", + "HelpKey": "TableHelp.DepartmentCallEmail.FormatType", + "Binding": "DepartmentCallEmail.FormatType", + "ValueType": "enum", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "CallSettings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentCallEmail.FormatType", + "DefaultValue": "owning parser default", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentCallEmail", + "Availability": "reference-only" + }, + { + "Id": "table.DepartmentCallEmail.LastCheck", + "AreaId": "calls", + "LabelKey": "TableField.DepartmentCallEmail.LastCheck", + "HelpKey": "TableHelp.DepartmentCallEmail.LastCheck", + "Binding": "DepartmentCallEmail.LastCheck", + "ValueType": "server-metadata", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "CallSettings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentCallEmail.LastCheck", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentCallEmail", + "Availability": "read-only" + }, + { + "Id": "table.DepartmentCallEmail.IsFailure", + "AreaId": "calls", + "LabelKey": "TableField.DepartmentCallEmail.IsFailure", + "HelpKey": "TableHelp.DepartmentCallEmail.IsFailure", + "Binding": "DepartmentCallEmail.IsFailure", + "ValueType": "server-metadata", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "CallSettings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentCallEmail.IsFailure", + "DefaultValue": "false", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentCallEmail", + "Availability": "read-only" + }, + { + "Id": "table.DepartmentCallEmail.ErrorMessage", + "AreaId": "calls", + "LabelKey": "TableField.DepartmentCallEmail.ErrorMessage", + "HelpKey": "TableHelp.DepartmentCallEmail.ErrorMessage", + "Binding": "DepartmentCallEmail.ErrorMessage", + "ValueType": "restricted", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "CallSettings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentCallEmail.ErrorMessage", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentCallEmail", + "Availability": "reference-only" + } + ], + "Capabilities": [ + { + "Id": "calls", + "AreaId": "calls", + "LabelKey": "Feature.calls", + "PurposeKey": "FeaturePurpose.calls", + "ValueKey": "AreaValue.calls", + "ExampleKey": "AreaExample.calls", + "AdoptionKey": "FeatureAdoption.calls", + "ReleaseStatus": "available", + "Location": { + "Controller": "Dispatch", + "Action": "Dashboard" + }, + "Requirements": [], + "SettingIds": [], + "RuleIds": [] + }, + { + "Id": "new-call", + "AreaId": "calls", + "LabelKey": "Feature.new-call", + "PurposeKey": "FeaturePurpose.new-call", + "ValueKey": "AreaValue.calls", + "ExampleKey": "AreaExample.calls", + "AdoptionKey": "FeatureAdoption.new-call", + "ReleaseStatus": "available", + "Location": { + "Controller": "Dispatch", + "Action": "NewCall" + }, + "Requirements": [], + "SettingIds": [], + "RuleIds": [] + }, + { + "Id": "archived-calls", + "AreaId": "calls", + "LabelKey": "Feature.archived-calls", + "PurposeKey": "FeaturePurpose.archived-calls", + "ValueKey": "AreaValue.calls", + "ExampleKey": "AreaExample.calls", + "AdoptionKey": "FeatureAdoption.archived-calls", + "ReleaseStatus": "available", + "Location": { + "Controller": "Dispatch", + "Action": "ArchivedCalls" + }, + "Requirements": [], + "SettingIds": [], + "RuleIds": [] + }, + { + "Id": "incident-command", + "AreaId": "calls", + "LabelKey": "Feature.incident-command", + "PurposeKey": "FeaturePurpose.incident-command", + "ValueKey": "FeatureValue.incident-command", + "ExampleKey": "AreaExample.calls", + "AdoptionKey": "FeatureAdoption.incident-command", + "ReleaseStatus": "available", + "Location": { + "Controller": "Command", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "permission", + "Id": "CommandAppLogin" + } + ], + "SettingIds": [ + "permission.CommandAppLogin" + ], + "RuleIds": [] + }, + { + "Id": "call-templates", + "AreaId": "calls", + "LabelKey": "Feature.call-templates", + "PurposeKey": "FeaturePurpose.call-templates", + "ValueKey": "FeatureValue.call-templates", + "ExampleKey": "AreaExample.calls", + "AdoptionKey": "FeatureAdoption.call-templates", + "ReleaseStatus": "available", + "Location": { + "Controller": "Templates", + "Action": "Index" + }, + "Requirements": [], + "SettingIds": [], + "RuleIds": [] + }, + { + "Id": "call-types-priorities", + "AreaId": "calls", + "LabelKey": "Feature.call-types-priorities", + "PurposeKey": "FeaturePurpose.call-types-priorities", + "ValueKey": "FeatureValue.call-types-priorities", + "ExampleKey": "AreaExample.calls", + "AdoptionKey": "FeatureAdoption.call-types-priorities", + "ReleaseStatus": "available", + "Location": { + "Controller": "Department", + "Action": "Types" + }, + "Requirements": [], + "SettingIds": [], + "RuleIds": [] + }, + { + "Id": "run-cards", + "AreaId": "calls", + "LabelKey": "Feature.run-cards", + "PurposeKey": "FeaturePurpose.run-cards", + "ValueKey": "FeatureValue.run-cards", + "ExampleKey": "AreaExample.calls", + "AdoptionKey": "FeatureAdoption.run-cards", + "ReleaseStatus": "available", + "Location": { + "Controller": "RunCards", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "flag", + "Id": "Dispatch.RunCards" + } + ], + "SettingIds": [], + "RuleIds": [ + "run-cards" + ], + "Setup": { + "EvidenceId": "runCardCount", + "Minimum": 1, + "RuleIds": [ + "run-cards" + ], + "GuidanceKey": "SetupEvidence.run-cards" + } + }, + { + "Id": "checkin-setup", + "AreaId": "calls", + "LabelKey": "Feature.checkin-setup", + "PurposeKey": "FeaturePurpose.checkin-setup", + "ValueKey": "FeatureValue.checkin-setup", + "ExampleKey": "AreaExample.calls", + "AdoptionKey": "FeatureAdoption.checkin-setup", + "ReleaseStatus": "available", + "Location": { + "Controller": "Department", + "Action": "DispatchSettings" + }, + "Requirements": [], + "SettingIds": [ + "field.DispatchRecommendationConfig.EtaShortlistSize", + "field.DispatchRecommendationConfig.IncludeStaleLocations", + "field.DispatchRecommendationConfig.MaxLocationAgeSeconds", + "field.DispatchRecommendationConfig.MaxRadiusMeters", + "field.DispatchRecommendationConfig.MoveUpRecommendationsEnabled", + "field.DispatchRecommendationConfig.PersonnelMaxLocationAgeSeconds", + "field.DispatchRecommendationConfig.RestPeriodMinutes", + "field.DispatchRecommendationConfig.UnitMinimumStaffingLevel", + "field.DispatchRecommendationConfig.UseRoutedEta", + "field.GroupDispatchScopeConfig.DepartmentWideRoleIds", + "field.GroupDispatchScopeConfig.Enabled", + "field.NewCallFieldPolicy.Rules", + "field.NewCallFieldRule.Key", + "field.NewCallFieldRule.Required", + "field.NewCallFieldRule.Visible", + "field.UnitTypeCallStatusOverride.DispatchStatus", + "field.UnitTypeCallStatusOverride.ReleaseStatus", + "field.UnitTypeCallStatusOverride.UnitTypeId", + "field.UnitTypeCallStatusOverrideSetting.Overrides", + "setting.AutoSetStatusForShiftDispatchPersonnel", + "setting.CheckInTimersAutoEnableForNewCalls", + "setting.DispatchRecommendationAutoDispatch", + "setting.DispatchRecommendationConfig", + "setting.DispatchRecommendationMode", + "setting.DispatchShiftInsteadOfGroup", + "setting.GroupDispatchScopeConfig", + "setting.NewCallFieldPolicy", + "setting.PersonnelOnUnitSetUnitStatus", + "setting.ShiftCallDispatchPersonnelStatusToSet", + "setting.ShiftCallReleasePersonnelStatusToSet", + "setting.UnitCallDispatchStatusToSet", + "setting.UnitCallReleaseStatusToSet", + "setting.UnitCallStatusOverridesByUnitType", + "setting.UnitDispatchAlsoDispatchToAssignedPersonnel", + "setting.UnitDispatchAlsoDispatchToGroup" + ], + "RuleIds": [ + "checkin-timers", + "shift-auto-without-dispatch", + "shift-coverage" + ], + "Setup": { + "EvidenceId": "checkInTimerCount", + "Minimum": 1, + "RuleIds": [ + "checkin-timers" + ], + "GuidanceKey": "SetupEvidence.checkin-setup" + } + }, + { + "Id": "text-intake", + "AreaId": "calls", + "LabelKey": "Feature.text-intake", + "PurposeKey": "FeaturePurpose.text-intake", + "ValueKey": "FeatureValue.text-intake", + "ExampleKey": "AreaExample.calls", + "AdoptionKey": "FeatureAdoption.text-intake", + "ReleaseStatus": "available", + "Location": { + "Controller": "Department", + "Action": "TextSettings" + }, + "Requirements": [], + "SettingIds": [ + "setting.EnableTextCommand", + "setting.EnableTextToCall", + "setting.TextToCallImportFormat", + "setting.TextToCallNumber", + "setting.TextToCallSourceNumbers", + "setting.TtsLanguage" + ], + "RuleIds": [ + "command-sources", + "text-sources" + ] + }, + { + "Id": "email-intake", + "AreaId": "calls", + "LabelKey": "Feature.email-intake", + "PurposeKey": "FeaturePurpose.email-intake", + "ValueKey": "FeatureValue.email-intake", + "ExampleKey": "AreaExample.calls", + "AdoptionKey": "FeatureAdoption.email-intake", + "ReleaseStatus": "available", + "Location": { + "Controller": "Department", + "Action": "CallSettings" + }, + "Requirements": [], + "SettingIds": [ + "setting.InternalDispatchEmail", + "setting.RssFeedKeyForActiveCalls", + "table.DepartmentCallEmail.ErrorMessage", + "table.DepartmentCallEmail.FormatType", + "table.DepartmentCallEmail.Hostname", + "table.DepartmentCallEmail.IsFailure", + "table.DepartmentCallEmail.LastCheck", + "table.DepartmentCallEmail.Password", + "table.DepartmentCallEmail.Port", + "table.DepartmentCallEmail.UseSsl", + "table.DepartmentCallEmail.Username" + ], + "RuleIds": [ + "email-import-failures", + "import-heartbeat" + ], + "Setup": { + "EvidenceId": "emailImportSourceCount", + "Minimum": 1, + "RuleIds": [ + "email-import-failures", + "import-heartbeat" + ], + "GuidanceKey": "SetupEvidence.email-intake" + } + } + ], + "Rules": [ + { + "Id": "shift-auto-without-dispatch", + "AreaId": "calls", + "Severity": "Warning", + "TitleKey": "Rule.shift-auto-without-dispatch", + "ExplanationKey": "RuleWhy.shift-auto-without-dispatch", + "NextActionKey": "RuleNext.shift-auto-without-dispatch", + "Location": { + "Controller": "Department", + "Action": "DispatchSettings" + }, + "AppliesWhen": [ + { + "EvidenceId": "AutoSetStatusForShiftDispatchPersonnel", + "Comparison": "IsTrue", + "Number": null + } + ], + "FailsWhen": [ + { + "EvidenceId": "DispatchShiftInsteadOfGroup", + "Comparison": "IsFalse", + "Number": null + } + ] + }, + { + "Id": "shift-coverage", + "AreaId": "calls", + "Severity": "Critical", + "TitleKey": "Rule.shift-coverage", + "ExplanationKey": "RuleWhy.shift-coverage", + "NextActionKey": "RuleNext.shift-coverage", + "Location": { + "Controller": "Department", + "Action": "DispatchSettings" + }, + "AppliesWhen": [ + { + "EvidenceId": "DispatchShiftInsteadOfGroup", + "Comparison": "IsTrue", + "Number": null + } + ], + "FailsWhen": [ + { + "EvidenceId": "groupsWithoutShiftCoverage", + "Comparison": "Greater", + "Number": 0 + } + ] + }, + { + "Id": "text-sources", + "AreaId": "calls", + "Severity": "Critical", + "TitleKey": "Rule.text-sources", + "ExplanationKey": "RuleWhy.text-sources", + "NextActionKey": "RuleNext.text-sources", + "Location": { + "Controller": "Department", + "Action": "TextSettings" + }, + "AppliesWhen": [ + { + "EvidenceId": "EnableTextToCall", + "Comparison": "IsTrue", + "Number": null + } + ], + "FailsWhen": [ + { + "EvidenceId": "textSourcePresent", + "Comparison": "IsFalse", + "Number": null + } + ] + }, + { + "Id": "run-cards", + "AreaId": "calls", + "Severity": "Warning", + "TitleKey": "Rule.run-cards", + "ExplanationKey": "RuleWhy.run-cards", + "NextActionKey": "RuleNext.run-cards", + "Location": { + "Controller": "RunCards", + "Action": "Index" + }, + "AppliesWhen": [ + { + "EvidenceId": "DispatchRecommendationMode", + "Comparison": "Greater", + "Number": 0 + } + ], + "FailsWhen": [ + { + "EvidenceId": "runCardCount", + "Comparison": "Equal", + "Number": 0 + } + ] + }, + { + "Id": "checkin-timers", + "AreaId": "calls", + "Severity": "Warning", + "TitleKey": "Rule.checkin-timers", + "ExplanationKey": "RuleWhy.checkin-timers", + "NextActionKey": "RuleNext.checkin-timers", + "Location": { + "Controller": "Department", + "Action": "DispatchSettings" + }, + "AppliesWhen": [ + { + "EvidenceId": "CheckInTimersAutoEnableForNewCalls", + "Comparison": "IsTrue", + "Number": null + } + ], + "FailsWhen": [ + { + "EvidenceId": "checkInTimerCount", + "Comparison": "Equal", + "Number": 0 + } + ] + }, + { + "Id": "import-heartbeat", + "AreaId": "calls", + "Severity": "Warning", + "TitleKey": "Rule.import-heartbeat", + "ExplanationKey": "RuleWhy.import-heartbeat", + "NextActionKey": "RuleNext.import-heartbeat", + "Location": { + "Controller": "Department", + "Action": "CallSettings" + }, + "AppliesWhen": [ + { + "EvidenceId": "importHeartbeatExpected", + "Comparison": "IsTrue", + "Number": null + } + ], + "FailsWhen": [ + { + "EvidenceId": "importHeartbeatMissing", + "Comparison": "IsTrue", + "Number": null + } + ] + }, + { + "Id": "email-import-failures", + "AreaId": "calls", + "Severity": "Warning", + "TitleKey": "Rule.email-import-failures", + "ExplanationKey": "RuleWhy.email-import-failures", + "NextActionKey": "RuleNext.email-import-failures", + "Location": { + "Controller": "Department", + "Action": "CallSettings" + }, + "AppliesWhen": [ + { + "EvidenceId": "emailImportSourceCount", + "Comparison": "Greater", + "Number": 0 + } + ], + "FailsWhen": [ + { + "EvidenceId": "emailImportFailureCount", + "Comparison": "Greater", + "Number": 0 + } + ] + } + ], + "Packs": [], + "Articles": [ + { + "Id": "setting.RssFeedKeyForActiveCalls", + "Locale": "en", + "TitleKey": "Setting.RssFeedKeyForActiveCalls", + "Body": "Rss Feed Key For Active Calls. Credential for the active-call feed. Treat the feed and its key as sensitive; rotate through its owning screen.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-rssfeedkeyforactivecalls", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.TextToCallNumber", + "Locale": "en", + "TitleKey": "Setting.TextToCallNumber", + "Body": "Text To Call Number. Provisioned inbound text number. Provisioning has external effects and must use the normal text settings flow.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-texttocallnumber", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.TextToCallImportFormat", + "Locale": "en", + "TitleKey": "Setting.TextToCallImportFormat", + "Body": "Text To Call Import Format. Parser used for inbound text call creation. Verify the sender format with a controlled example on the owning screen.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-texttocallimportformat", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.TextToCallSourceNumbers", + "Locale": "en", + "TitleKey": "Setting.TextToCallSourceNumbers", + "Body": "Text To Call Source Numbers. Allowed inbound source numbers for text calls and commands. Empty sources prevent accepted intake; never display raw numbers in Admin Assist.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-texttocallsourcenumbers", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.EnableTextToCall", + "Locale": "en", + "TitleKey": "Setting.EnableTextToCall", + "Body": "Enable Text To Call. Configured text-call preference. Enforcement differs by provider and chatbot path; the stored value alone does not prove intake is stopped or accepted. Review sender classification and perform an explicit controlled verification on the owning screen.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-enabletexttocall", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.InternalDispatchEmail", + "Locale": "en", + "TitleKey": "Setting.InternalDispatchEmail", + "Body": "Internal Dispatch Email. Assigned internal dispatch email address. Presence can be shown; the address stays on the authorized import screen.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-internaldispatchemail", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.CallsSortOrder", + "Locale": "en", + "TitleKey": "Setting.CallsSortOrder", + "Body": "Calls Sort Order. Sort order for displayed calls; does not reorder provider delivery or change call priority.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-callssortorder", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.DispatchShiftInsteadOfGroup", + "Locale": "en", + "TitleKey": "Setting.DispatchShiftInsteadOfGroup", + "Body": "Dispatch Shift Instead Of Group. Expand dispatched groups using the resolved on-duty roster. An empty shift result falls back to group members; preview both cases.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-dispatchshiftinsteadofgroup", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.AutoSetStatusForShiftDispatchPersonnel", + "Locale": "en", + "TitleKey": "Setting.AutoSetStatusForShiftDispatchPersonnel", + "Body": "Auto Set Status For Shift Dispatch Personnel. Apply the configured status to shift-dispatched personnel. This only takes effect with shift dispatch enabled.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-autosetstatusforshiftdispatchpersonnel", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.ShiftCallDispatchPersonnelStatusToSet", + "Locale": "en", + "TitleKey": "Setting.ShiftCallDispatchPersonnelStatusToSet", + "Body": "Shift Call Dispatch Personnel Status To Set. Personnel status applied when shift dispatch occurs. Minus one leaves status unchanged; use an existing approved status.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-shiftcalldispatchpersonnelstatustoset", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.ShiftCallReleasePersonnelStatusToSet", + "Locale": "en", + "TitleKey": "Setting.ShiftCallReleasePersonnelStatusToSet", + "Body": "Shift Call Release Personnel Status To Set. Personnel status applied on call release for the shift-dispatch path. Minus one leaves status unchanged.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-shiftcallreleasepersonnelstatustoset", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.UnitDispatchAlsoDispatchToAssignedPersonnel", + "Locale": "en", + "TitleKey": "Setting.UnitDispatchAlsoDispatchToAssignedPersonnel", + "Body": "Unit Dispatch Also Dispatch To Assigned Personnel. Expand unit dispatch to its assigned crew. Review duplicate routes, current assignments and recipient permissions.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-unitdispatchalsodispatchtoassignedpersonnel", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.UnitDispatchAlsoDispatchToGroup", + "Locale": "en", + "TitleKey": "Setting.UnitDispatchAlsoDispatchToGroup", + "Body": "Unit Dispatch Also Dispatch To Group. Expand unit dispatch to the station group. This can substantially increase the recipient audience.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-unitdispatchalsodispatchtogroup", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.CheckInTimersAutoEnableForNewCalls", + "Locale": "en", + "TitleKey": "Setting.CheckInTimersAutoEnableForNewCalls", + "Body": "Check In Timers Auto Enable For New Calls. Automatically start configured check-in timers for new calls. Review timer targets and escalation before enabling it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-checkintimersautoenablefornewcalls", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.UnitCallDispatchStatusToSet", + "Locale": "en", + "TitleKey": "Setting.UnitCallDispatchStatusToSet", + "Body": "Unit Call Dispatch Status To Set. Unit status applied during call dispatch. Minus one or an invalid built-in unit status leaves the status unchanged.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-unitcalldispatchstatustoset", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.UnitCallReleaseStatusToSet", + "Locale": "en", + "TitleKey": "Setting.UnitCallReleaseStatusToSet", + "Body": "Unit Call Release Status To Set. Unit status applied at call release. Review unit-type overrides before assuming one department-wide effect.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-unitcallreleasestatustoset", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.UnitCallStatusOverridesByUnitType", + "Locale": "en", + "TitleKey": "Setting.UnitCallStatusOverridesByUnitType", + "Body": "Unit Call Status Overrides By Unit Type. Per-unit-type call status overrides. Existing type-specific values take precedence over department defaults.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-unitcallstatusoverridesbyunittype", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.DispatchRecommendationMode", + "Locale": "en", + "TitleKey": "Setting.DispatchRecommendationMode", + "Body": "Dispatch Recommendation Mode. Dispatch recommendation selection mode. Review run cards, eligible resources and human dispatch responsibility.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-dispatchrecommendationmode", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.DispatchRecommendationAutoDispatch", + "Locale": "en", + "TitleKey": "Setting.DispatchRecommendationAutoDispatch", + "Body": "Dispatch Recommendation Auto Dispatch. Allow the recommendation path to dispatch automatically where enabled. Preview recipients and verify local procedures first.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-dispatchrecommendationautodispatch", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.DispatchRecommendationConfig", + "Locale": "en", + "TitleKey": "Setting.DispatchRecommendationConfig", + "Body": "Dispatch Recommendation Config. Location, ETA, rest, crew and move-up settings used by recommendation consumers. External ETA calls occur only in the owning operational flow.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-dispatchrecommendationconfig", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.NewCallFieldPolicy", + "Locale": "en", + "TitleKey": "Setting.NewCallFieldPolicy", + "Body": "New Call Field Policy. Controls optional new-call fields across supported clients. Hidden fields cannot be required; core dispatch fields remain mandatory.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-newcallfieldpolicy", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.GroupDispatchScopeConfig", + "Locale": "en", + "TitleKey": "Setting.GroupDispatchScopeConfig", + "Body": "Group Dispatch Scope Config. Group-subtree dispatch visibility with explicit role exceptions. Preview each actor and target scope before changing it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-groupdispatchscopeconfig", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "field.UnitTypeCallStatusOverrideSetting.Overrides", + "Locale": "en", + "TitleKey": "Field.UnitTypeCallStatusOverrideSetting.Overrides", + "Body": "Unit Type Call Status Override Setting / Overrides. Per-unit-type dispatch and release status overrides. The owning consumer resolves these before department defaults.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-unittypecallstatusoverridesetting-overrides", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "field.UnitTypeCallStatusOverride.UnitTypeId", + "Locale": "en", + "TitleKey": "Field.UnitTypeCallStatusOverride.UnitTypeId", + "Body": "Unit Type Call Status Override / Unit Type Id. Existing unit type receiving this override. A unit type name is not a qualification or staffing clearance.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-unittypecallstatusoverride-unittypeid", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "field.UnitTypeCallStatusOverride.DispatchStatus", + "Locale": "en", + "TitleKey": "Field.UnitTypeCallStatusOverride.DispatchStatus", + "Body": "Unit Type Call Status Override / Dispatch Status. Status applied to this unit type on dispatch; minus one leaves it unchanged.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-unittypecallstatusoverride-dispatchstatus", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "field.UnitTypeCallStatusOverride.ReleaseStatus", + "Locale": "en", + "TitleKey": "Field.UnitTypeCallStatusOverride.ReleaseStatus", + "Body": "Unit Type Call Status Override / Release Status. Status applied to this unit type on release; minus one leaves it unchanged.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-unittypecallstatusoverride-releasestatus", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "field.NewCallFieldPolicy.Rules", + "Locale": "en", + "TitleKey": "Field.NewCallFieldPolicy.Rules", + "Body": "New Call Field Policy / Rules. Per-field visibility and requiredness for optional call-creation fields. Unconfigured fields remain visible and optional.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-newcallfieldpolicy-rules", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "field.NewCallFieldRule.Key", + "Locale": "en", + "TitleKey": "Field.NewCallFieldRule.Key", + "Body": "New Call Field Rule / Key. One supported built-in call-field key. Name, nature, priority and type cannot be removed through this policy.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-newcallfieldrule-key", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "field.NewCallFieldRule.Visible", + "Locale": "en", + "TitleKey": "Field.NewCallFieldRule.Visible", + "Body": "New Call Field Rule / Visible. Whether the optional call field is shown on supported creation surfaces. Hidden fields cannot be required.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-newcallfieldrule-visible", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "field.NewCallFieldRule.Required", + "Locale": "en", + "TitleKey": "Field.NewCallFieldRule.Required", + "Body": "New Call Field Rule / Required. Whether a visible optional field must be supplied. Review imports and each supported client before making a field mandatory.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-newcallfieldrule-required", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "field.GroupDispatchScopeConfig.Enabled", + "Locale": "en", + "TitleKey": "Field.GroupDispatchScopeConfig.Enabled", + "Body": "Group Dispatch Scope Config / Enabled. Limit supported dispatch views to the member's group subtree. Department administrators and configured department-wide roles keep department-wide access.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-groupdispatchscopeconfig-enabled", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "field.GroupDispatchScopeConfig.DepartmentWideRoleIds", + "Locale": "en", + "TitleKey": "Field.GroupDispatchScopeConfig.DepartmentWideRoleIds", + "Body": "Group Dispatch Scope Config / Department Wide Role Ids. Existing roles allowed department-wide dispatch views while group scoping is enabled. This changes view scope, not licensing or operational qualification.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-groupdispatchscopeconfig-departmentwideroleids", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "field.DispatchRecommendationConfig.MaxLocationAgeSeconds", + "Locale": "en", + "TitleKey": "Field.DispatchRecommendationConfig.MaxLocationAgeSeconds", + "Body": "Dispatch Recommendation Config / Max Location Age Seconds. Exclude older unit positions from closest-unit candidates; zero removes the age limit. IncludeStaleLocations changes this behavior.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-dispatchrecommendationconfig-maxlocationageseconds", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "field.DispatchRecommendationConfig.MaxRadiusMeters", + "Locale": "en", + "TitleKey": "Field.DispatchRecommendationConfig.MaxRadiusMeters", + "Body": "Dispatch Recommendation Config / Max Radius Meters. Maximum candidate distance in meters; zero removes the radius cap. This is not a response-time or route-safety guarantee.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-dispatchrecommendationconfig-maxradiusmeters", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "field.DispatchRecommendationConfig.IncludeStaleLocations", + "Locale": "en", + "TitleKey": "Field.DispatchRecommendationConfig.IncludeStaleLocations", + "Body": "Dispatch Recommendation Config / Include Stale Locations. Permit positions beyond the configured age limit in closest-unit selection, marked stale. Review the risk of outdated positions.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-dispatchrecommendationconfig-includestalelocations", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "field.DispatchRecommendationConfig.PersonnelMaxLocationAgeSeconds", + "Locale": "en", + "TitleKey": "Field.DispatchRecommendationConfig.PersonnelMaxLocationAgeSeconds", + "Body": "Dispatch Recommendation Config / Personnel Max Location Age Seconds. Maximum age of personnel positions for closest-unit candidate selection; zero removes the age limit.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-dispatchrecommendationconfig-personnelmaxlocationageseconds", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "field.DispatchRecommendationConfig.UseRoutedEta", + "Locale": "en", + "TitleKey": "Field.DispatchRecommendationConfig.UseRoutedEta", + "Body": "Dispatch Recommendation Config / Use Routed Eta. Re-rank shortlisted candidates using provider travel estimates. The operational path can make external provider calls; Admin Assist does not.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-dispatchrecommendationconfig-useroutedeta", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "field.DispatchRecommendationConfig.EtaShortlistSize", + "Locale": "en", + "TitleKey": "Field.DispatchRecommendationConfig.EtaShortlistSize", + "Body": "Dispatch Recommendation Config / Eta Shortlist Size. Number of straight-line candidates per requirement sent for routed ETA when enabled. The owning validator caps provider work.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-dispatchrecommendationconfig-etashortlistsize", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "field.DispatchRecommendationConfig.RestPeriodMinutes", + "Locale": "en", + "TitleKey": "Field.DispatchRecommendationConfig.RestPeriodMinutes", + "Body": "Dispatch Recommendation Config / Rest Period Minutes. Deprioritize recently dispatched resources for this duration; zero disables rotation. This does not infer fatigue or medical fitness.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-dispatchrecommendationconfig-restperiodminutes", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "field.DispatchRecommendationConfig.UnitMinimumStaffingLevel", + "Locale": "en", + "TitleKey": "Field.DispatchRecommendationConfig.UnitMinimumStaffingLevel", + "Body": "Dispatch Recommendation Config / Unit Minimum Staffing Level. Minimum configured unit staffing level for recommendation eligibility; zero disables the gate. Units without defined seats pass, and run cards may override it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-dispatchrecommendationconfig-unitminimumstaffinglevel", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "field.DispatchRecommendationConfig.MoveUpRecommendationsEnabled", + "Locale": "en", + "TitleKey": "Field.DispatchRecommendationConfig.MoveUpRecommendationsEnabled", + "Body": "Dispatch Recommendation Config / Move Up Recommendations Enabled. Run the station coverage move-up pass after selection. Recommendations remain subject to approved local dispatch procedures.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-dispatchrecommendationconfig-moveuprecommendationsenabled", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentCallEmail.Hostname", + "Locale": "en", + "TitleKey": "TableField.DepartmentCallEmail.Hostname", + "Body": "Department Call Email / Hostname. Mailbox host for email intake. Review the approved provider and network reachability without exposing credentials in setup guidance.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentcallemail-hostname", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentCallEmail.Port", + "Locale": "en", + "TitleKey": "TableField.DepartmentCallEmail.Port", + "Body": "Department Call Email / Port. Mailbox connection port. It must match the selected provider and TLS mode.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentcallemail-port", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentCallEmail.UseSsl", + "Locale": "en", + "TitleKey": "TableField.DepartmentCallEmail.UseSsl", + "Body": "Department Call Email / Use Ssl. Mailbox transport encryption option. Verify actual provider compatibility and successful polling; a stored flag alone does not prove transport protection.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentcallemail-usessl", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentCallEmail.Username", + "Locale": "en", + "TitleKey": "TableField.DepartmentCallEmail.Username", + "Body": "Department Call Email / Username. Mailbox account identifier used for intake. It remains on the authorized configuration screen.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentcallemail-username", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentCallEmail.Password", + "Locale": "en", + "TitleKey": "TableField.DepartmentCallEmail.Password", + "Body": "Department Call Email / Password. Mailbox credential. Do not copy it into Admin Assist; rotate it through the mailbox and owning editor together.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentcallemail-password", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentCallEmail.FormatType", + "Locale": "en", + "TitleKey": "TableField.DepartmentCallEmail.FormatType", + "Body": "Department Call Email / Format Type. Selects the inbound email parser. Verify representative permitted source messages without sending an operational page unintentionally.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentcallemail-formattype", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentCallEmail.LastCheck", + "Locale": "en", + "TitleKey": "TableField.DepartmentCallEmail.LastCheck", + "Body": "Department Call Email / Last Check. Last recorded polling timestamp. Missing or future timestamps are unknown; quiet call volume is not proof of a poll failure.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentcallemail-lastcheck", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentCallEmail.IsFailure", + "Locale": "en", + "TitleKey": "TableField.DepartmentCallEmail.IsFailure", + "Body": "Department Call Email / Is Failure. Latest recorded mailbox failure flag. Verify a new poll before treating it as a continuing outage or a resolved problem.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentcallemail-isfailure", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentCallEmail.ErrorMessage", + "Locale": "en", + "TitleKey": "TableField.DepartmentCallEmail.ErrorMessage", + "Body": "Department Call Email / Error Message. Provider/import error details on the authorized screen. Admin Assist uses a normalized failure indication and does not copy provider bodies.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentcallemail-errormessage", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "calls", + "Locale": "en", + "TitleKey": "Feature.calls", + "Body": "Calls View calls and dispatches Make call intake and recipient selection consistent across shifts. Review a test scenario before enabling CAD intake or changing dispatch routing. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "calls", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "new-call", + "Locale": "en", + "TitleKey": "Feature.new-call", + "Body": "New Call Create and dispatch a new call Make call intake and recipient selection consistent across shifts. Review a test scenario before enabling CAD intake or changing dispatch routing. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "new-call", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "archived-calls", + "Locale": "en", + "TitleKey": "Feature.archived-calls", + "Body": "Archived Calls Closed and historical calls Make call intake and recipient selection consistent across shifts. Review a test scenario before enabling CAD intake or changing dispatch routing. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "archived-calls", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "incident-command", + "Locale": "en", + "TitleKey": "Feature.incident-command", + "Body": "Incident command and accountability Command tools organize incident roles and accountability for authorized commanders. Prepare command structures and access before an exercise or response. Review a test scenario before enabling CAD intake or changing dispatch routing. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "incident-command", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "call-templates", + "Locale": "en", + "TitleKey": "Feature.call-templates", + "Body": "Call templates Maintain reusable call information and note templates. Reduce repeated entry while keeping dispatch decisions explicit. Review a test scenario before enabling CAD intake or changing dispatch routing. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "call-templates", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "call-types-priorities", + "Locale": "en", + "TitleKey": "Feature.call-types-priorities", + "Body": "Call types and priorities Maintain the department's call classifications and priorities. Use consistent reporting and run-card matching terms. Review a test scenario before enabling CAD intake or changing dispatch routing. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "call-types-priorities", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "run-cards", + "Locale": "en", + "TitleKey": "Feature.run-cards", + "Body": "Run cards and recommendations Configure matching rules, resource requirements and supported dispatch recommendations. Review candidate coverage before enabling operational use. Review a test scenario before enabling CAD intake or changing dispatch routing. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "run-cards", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "checkin-setup", + "Locale": "en", + "TitleKey": "Feature.checkin-setup", + "Body": "Check-in timers Configure supported check-in targets and escalation behavior. Review lone-worker and response follow-up with approved local procedures. Review a test scenario before enabling CAD intake or changing dispatch routing. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "checkin-setup", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "text-intake", + "Locale": "en", + "TitleKey": "Feature.text-intake", + "Body": "Text call intake and commands Configure source numbers, parsing and supported inbound text actions. Make approved inbound sources and their operational effects explicit. Review a test scenario before enabling CAD intake or changing dispatch routing. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "text-intake", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "email-intake", + "Locale": "en", + "TitleKey": "Feature.email-intake", + "Body": "Email and CAD call intake Configure supported email and CAD import formats and source credentials. Review parsing, routing and observed integration failures before activation. Review a test scenario before enabling CAD intake or changing dispatch routing. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "email-intake", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + } + ] +} diff --git a/Core/Resgrid.AdminAssist/Catalog/communication.yaml b/Core/Resgrid.AdminAssist/Catalog/communication.yaml new file mode 100644 index 000000000..a2df094b1 --- /dev/null +++ b/Core/Resgrid.AdminAssist/Catalog/communication.yaml @@ -0,0 +1,1226 @@ +{ + "Version": "2026.09.24.1", + "Areas": [ + { + "Id": "communication", + "LabelKey": "Area.communication", + "PurposeKey": "AreaPurpose.communication", + "Order": 4, + "Archetypes": [] + } + ], + "Settings": [ + { + "Id": "setting.EnableTextCommand", + "AreaId": "communication", + "LabelKey": "Setting.EnableTextCommand", + "HelpKey": "SettingHelp.EnableTextCommand", + "Binding": "DepartmentSettingTypes.EnableTextCommand", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "TextSettings", + "Field": "EnableTextCommand" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.communication", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.EnableTextCommand", + "DefaultValue": "false", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.TtsLanguage", + "AreaId": "communication", + "LabelKey": "Setting.TtsLanguage", + "HelpKey": "SettingHelp.TtsLanguage", + "Binding": "DepartmentSettingTypes.TtsLanguage", + "ValueType": "enum", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "TextSettings", + "Field": "TtsLanguage" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.communication", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.TtsLanguage", + "DefaultValue": "owning voice default", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.EnableModernNotifications", + "AreaId": "communication", + "LabelKey": "Setting.EnableModernNotifications", + "HelpKey": "SettingHelp.EnableModernNotifications", + "Binding": "DepartmentSettingTypes.EnableModernNotifications", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "Settings", + "Field": "EnableModernNotifications" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.communication", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.EnableModernNotifications", + "DefaultValue": "false", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "module.MessagingDisabled", + "AreaId": "communication", + "LabelKey": "Module.MessagingDisabled", + "HelpKey": "ModuleHelp.MessagingDisabled", + "Binding": "DepartmentModuleSettings.MessagingDisabled", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "ModuleSettings", + "Field": "MessagingEnabled" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.communication", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.ModuleSettings" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "module.MessagingDisabled", + "DefaultValue": "false", + "AllowedValues": "true or false", + "EvidenceSource": "DepartmentSettingsService.GetDepartmentModuleSettingsAsync", + "Availability": "available" + }, + { + "Id": "module.MessagingNameOverride", + "AreaId": "communication", + "LabelKey": "Module.MessagingNameOverride", + "HelpKey": "ModuleHelp.MessagingNameOverride", + "Binding": "DepartmentModuleSettings.MessagingNameOverride", + "ValueType": "string", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "ModuleSettings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.communication", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.ModuleSettings" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "module.MessagingNameOverride", + "DefaultValue": "unset", + "AllowedValues": "owning serializer and consuming UI", + "EvidenceSource": "DepartmentSettingsService.GetDepartmentModuleSettingsAsync", + "Availability": "reference-only" + }, + { + "Id": "table.DepartmentNotification.EventType", + "AreaId": "communication", + "LabelKey": "TableField.DepartmentNotification.EventType", + "HelpKey": "TableHelp.DepartmentNotification.EventType", + "Binding": "DepartmentNotification.EventType", + "ValueType": "enum", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Notifications", + "Action": "Index", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.communication", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentNotification.EventType", + "DefaultValue": "required", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentNotification", + "Availability": "reference-only" + }, + { + "Id": "table.DepartmentNotification.UsersToNotify", + "AreaId": "communication", + "LabelKey": "TableField.DepartmentNotification.UsersToNotify", + "HelpKey": "TableHelp.DepartmentNotification.UsersToNotify", + "Binding": "DepartmentNotification.UsersToNotify", + "ValueType": "reference-list", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Notifications", + "Action": "Index", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.communication", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentNotification.UsersToNotify", + "DefaultValue": "empty", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentNotification", + "Availability": "reference-only" + }, + { + "Id": "table.DepartmentNotification.RolesToNotify", + "AreaId": "communication", + "LabelKey": "TableField.DepartmentNotification.RolesToNotify", + "HelpKey": "TableHelp.DepartmentNotification.RolesToNotify", + "Binding": "DepartmentNotification.RolesToNotify", + "ValueType": "reference-list", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Notifications", + "Action": "Index", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.communication", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentNotification.RolesToNotify", + "DefaultValue": "empty", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentNotification", + "Availability": "reference-only" + }, + { + "Id": "table.DepartmentNotification.GroupsToNotify", + "AreaId": "communication", + "LabelKey": "TableField.DepartmentNotification.GroupsToNotify", + "HelpKey": "TableHelp.DepartmentNotification.GroupsToNotify", + "Binding": "DepartmentNotification.GroupsToNotify", + "ValueType": "reference-list", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Notifications", + "Action": "Index", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.communication", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentNotification.GroupsToNotify", + "DefaultValue": "empty", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentNotification", + "Availability": "reference-only" + }, + { + "Id": "table.DepartmentNotification.LockToGroup", + "AreaId": "communication", + "LabelKey": "TableField.DepartmentNotification.LockToGroup", + "HelpKey": "TableHelp.DepartmentNotification.LockToGroup", + "Binding": "DepartmentNotification.LockToGroup", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Notifications", + "Action": "Index", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.communication", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentNotification.LockToGroup", + "DefaultValue": "false", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentNotification", + "Availability": "reference-only" + }, + { + "Id": "table.DepartmentNotification.SelectedGroupsAdminsOnly", + "AreaId": "communication", + "LabelKey": "TableField.DepartmentNotification.SelectedGroupsAdminsOnly", + "HelpKey": "TableHelp.DepartmentNotification.SelectedGroupsAdminsOnly", + "Binding": "DepartmentNotification.SelectedGroupsAdminsOnly", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Notifications", + "Action": "Index", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.communication", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentNotification.SelectedGroupsAdminsOnly", + "DefaultValue": "false", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentNotification", + "Availability": "reference-only" + }, + { + "Id": "table.DepartmentNotification.DepartmentAdmins", + "AreaId": "communication", + "LabelKey": "TableField.DepartmentNotification.DepartmentAdmins", + "HelpKey": "TableHelp.DepartmentNotification.DepartmentAdmins", + "Binding": "DepartmentNotification.DepartmentAdmins", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Notifications", + "Action": "Index", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.communication", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentNotification.DepartmentAdmins", + "DefaultValue": "false", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentNotification", + "Availability": "reference-only" + }, + { + "Id": "table.DepartmentNotification.Everyone", + "AreaId": "communication", + "LabelKey": "TableField.DepartmentNotification.Everyone", + "HelpKey": "TableHelp.DepartmentNotification.Everyone", + "Binding": "DepartmentNotification.Everyone", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Notifications", + "Action": "Index", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.communication", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentNotification.Everyone", + "DefaultValue": "false", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentNotification", + "Availability": "reference-only" + }, + { + "Id": "table.DepartmentNotification.Disabled", + "AreaId": "communication", + "LabelKey": "TableField.DepartmentNotification.Disabled", + "HelpKey": "TableHelp.DepartmentNotification.Disabled", + "Binding": "DepartmentNotification.Disabled", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Notifications", + "Action": "Index", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.communication", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentNotification.Disabled", + "DefaultValue": "false", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentNotification", + "Availability": "reference-only" + }, + { + "Id": "table.DepartmentNotification.BeforeData", + "AreaId": "communication", + "LabelKey": "TableField.DepartmentNotification.BeforeData", + "HelpKey": "TableHelp.DepartmentNotification.BeforeData", + "Binding": "DepartmentNotification.BeforeData", + "ValueType": "structured", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Notifications", + "Action": "Index", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.communication", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentNotification.BeforeData", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentNotification", + "Availability": "reference-only" + }, + { + "Id": "table.DepartmentNotification.CurrentData", + "AreaId": "communication", + "LabelKey": "TableField.DepartmentNotification.CurrentData", + "HelpKey": "TableHelp.DepartmentNotification.CurrentData", + "Binding": "DepartmentNotification.CurrentData", + "ValueType": "structured", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Notifications", + "Action": "Index", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.communication", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentNotification.CurrentData", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentNotification", + "Availability": "reference-only" + }, + { + "Id": "table.DepartmentNotification.UpperLimit", + "AreaId": "communication", + "LabelKey": "TableField.DepartmentNotification.UpperLimit", + "HelpKey": "TableHelp.DepartmentNotification.UpperLimit", + "Binding": "DepartmentNotification.UpperLimit", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Notifications", + "Action": "Index", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.communication", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentNotification.UpperLimit", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentNotification", + "Availability": "reference-only" + }, + { + "Id": "table.DepartmentNotification.LowerLimit", + "AreaId": "communication", + "LabelKey": "TableField.DepartmentNotification.LowerLimit", + "HelpKey": "TableHelp.DepartmentNotification.LowerLimit", + "Binding": "DepartmentNotification.LowerLimit", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Notifications", + "Action": "Index", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.communication", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentNotification.LowerLimit", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentNotification", + "Availability": "reference-only" + }, + { + "Id": "table.DepartmentNotification.Data", + "AreaId": "communication", + "LabelKey": "TableField.DepartmentNotification.Data", + "HelpKey": "TableHelp.DepartmentNotification.Data", + "Binding": "DepartmentNotification.Data", + "ValueType": "structured", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Notifications", + "Action": "Index", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.communication", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentNotification.Data", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentNotification", + "Availability": "reference-only" + } + ], + "Capabilities": [ + { + "Id": "inbox", + "AreaId": "communication", + "LabelKey": "Feature.inbox", + "PurposeKey": "FeaturePurpose.inbox", + "ValueKey": "AreaValue.communication", + "ExampleKey": "AreaExample.communication", + "AdoptionKey": "FeatureAdoption.inbox", + "ReleaseStatus": "available", + "Location": { + "Controller": "Messages", + "Action": "Inbox" + }, + "Requirements": [ + { + "Kind": "module", + "Id": "Messaging" + } + ], + "SettingIds": [ + "module.MessagingDisabled" + ], + "RuleIds": [] + }, + { + "Id": "outbox", + "AreaId": "communication", + "LabelKey": "Feature.outbox", + "PurposeKey": "FeaturePurpose.outbox", + "ValueKey": "AreaValue.communication", + "ExampleKey": "AreaExample.communication", + "AdoptionKey": "FeatureAdoption.outbox", + "ReleaseStatus": "available", + "Location": { + "Controller": "Messages", + "Action": "Outbox" + }, + "Requirements": [ + { + "Kind": "module", + "Id": "Messaging" + } + ], + "SettingIds": [ + "module.MessagingDisabled" + ], + "RuleIds": [] + }, + { + "Id": "compose-message", + "AreaId": "communication", + "LabelKey": "Feature.compose-message", + "PurposeKey": "FeaturePurpose.compose-message", + "ValueKey": "AreaValue.communication", + "ExampleKey": "AreaExample.communication", + "AdoptionKey": "FeatureAdoption.compose-message", + "ReleaseStatus": "available", + "Location": { + "Controller": "Messages", + "Action": "Compose" + }, + "Requirements": [ + { + "Kind": "module", + "Id": "Messaging" + } + ], + "SettingIds": [ + "module.MessagingDisabled" + ], + "RuleIds": [] + }, + { + "Id": "chat", + "AreaId": "communication", + "LabelKey": "Feature.chat", + "PurposeKey": "FeaturePurpose.chat", + "ValueKey": "AreaValue.communication", + "ExampleKey": "AreaExample.communication", + "AdoptionKey": "FeatureAdoption.chat", + "ReleaseStatus": "available", + "Location": { + "Controller": "Chat", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "flag", + "Id": "Chat.System" + } + ], + "SettingIds": [], + "RuleIds": [] + }, + { + "Id": "voice", + "AreaId": "communication", + "LabelKey": "Feature.voice", + "PurposeKey": "FeaturePurpose.voice", + "ValueKey": "AreaValue.communication", + "ExampleKey": "AreaExample.communication", + "AdoptionKey": "FeatureAdoption.voice", + "ReleaseStatus": "available", + "Location": { + "Controller": "Voice", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "addon", + "Id": "PTT" + } + ], + "SettingIds": [], + "RuleIds": [] + }, + { + "Id": "notifications", + "AreaId": "communication", + "LabelKey": "Feature.notifications", + "PurposeKey": "FeaturePurpose.notifications", + "ValueKey": "FeatureValue.notifications", + "ExampleKey": "AreaExample.communication", + "AdoptionKey": "FeatureAdoption.notifications", + "ReleaseStatus": "available", + "Location": { + "Controller": "Notifications", + "Action": "Index" + }, + "Requirements": [], + "SettingIds": [ + "table.DepartmentNotification.BeforeData", + "table.DepartmentNotification.CurrentData", + "table.DepartmentNotification.Data", + "table.DepartmentNotification.DepartmentAdmins", + "table.DepartmentNotification.Disabled", + "table.DepartmentNotification.EventType", + "table.DepartmentNotification.Everyone", + "table.DepartmentNotification.GroupsToNotify", + "table.DepartmentNotification.LockToGroup", + "table.DepartmentNotification.LowerLimit", + "table.DepartmentNotification.RolesToNotify", + "table.DepartmentNotification.SelectedGroupsAdminsOnly", + "table.DepartmentNotification.UpperLimit", + "table.DepartmentNotification.UsersToNotify" + ], + "RuleIds": [] + }, + { + "Id": "distribution-lists", + "AreaId": "communication", + "LabelKey": "Feature.distribution-lists", + "PurposeKey": "FeaturePurpose.distribution-lists", + "ValueKey": "FeatureValue.distribution-lists", + "ExampleKey": "AreaExample.communication", + "AdoptionKey": "FeatureAdoption.distribution-lists", + "ReleaseStatus": "available", + "Location": { + "Controller": "DistributionLists", + "Action": "Index" + }, + "Requirements": [], + "SettingIds": [], + "RuleIds": [] + }, + { + "Id": "communication-tests", + "AreaId": "communication", + "LabelKey": "Feature.communication-tests", + "PurposeKey": "FeaturePurpose.communication-tests", + "ValueKey": "FeatureValue.communication-tests", + "ExampleKey": "AreaExample.communication", + "AdoptionKey": "FeatureAdoption.communication-tests", + "ReleaseStatus": "available", + "Location": { + "Controller": "CommunicationTest", + "Action": "Index" + }, + "Requirements": [], + "SettingIds": [], + "RuleIds": [ + "communication-tests" + ] + }, + { + "Id": "chatbot-settings", + "AreaId": "communication", + "LabelKey": "Feature.chatbot-settings", + "PurposeKey": "FeaturePurpose.chatbot-settings", + "ValueKey": "FeatureValue.chatbot-settings", + "ExampleKey": "AreaExample.communication", + "AdoptionKey": "FeatureAdoption.chatbot-settings", + "ReleaseStatus": "available", + "Location": { + "Controller": "ChatbotSettings", + "Action": "Index" + }, + "Requirements": [], + "SettingIds": [ + "table.ChatbotDepartmentConfig.AllowDispatchViaChatbot", + "table.ChatbotDepartmentConfig.AllowedPlatforms", + "table.ChatbotDepartmentConfig.IsEnabled", + "table.ChatbotDepartmentConfig.LlmApiEndpoint", + "table.ChatbotDepartmentConfig.LlmApiKey", + "table.ChatbotDepartmentConfig.LlmModelName", + "table.ChatbotDepartmentConfig.MaxSessionsPerUser", + "table.ChatbotDepartmentConfig.MessagesPerDepartmentPerMinute", + "table.ChatbotDepartmentConfig.MessagesPerUserPerMinute", + "table.ChatbotDepartmentConfig.ProactiveNotificationsEnabled", + "table.ChatbotDepartmentConfig.RequireConfirmationForStatusChange", + "table.ChatbotDepartmentConfig.RequireLinkingConfirmation", + "table.ChatbotDepartmentConfig.SessionTtlMinutes" + ], + "RuleIds": [] + } + ], + "Rules": [ + { + "Id": "command-sources", + "AreaId": "communication", + "Severity": "Warning", + "TitleKey": "Rule.command-sources", + "ExplanationKey": "RuleWhy.command-sources", + "NextActionKey": "RuleNext.command-sources", + "Location": { + "Controller": "Department", + "Action": "TextSettings" + }, + "AppliesWhen": [ + { + "EvidenceId": "EnableTextCommand", + "Comparison": "IsTrue", + "Number": null + }, + { + "EvidenceId": "EnableTextToCall", + "Comparison": "IsTrue", + "Number": null + } + ], + "FailsWhen": [ + { + "EvidenceId": "textSourcePresent", + "Comparison": "IsFalse", + "Number": null + } + ] + }, + { + "Id": "communication-tests", + "AreaId": "communication", + "Severity": "Warning", + "TitleKey": "Rule.communication-tests", + "ExplanationKey": "RuleWhy.communication-tests", + "NextActionKey": "RuleNext.communication-tests", + "Location": { + "Controller": "CommunicationTest", + "Action": "Index" + }, + "AppliesWhen": [], + "FailsWhen": [ + { + "EvidenceId": "communicationTestAgeDays", + "Comparison": "Greater", + "Number": 30 + } + ] + } + ], + "Packs": [], + "Articles": [ + { + "Id": "setting.EnableTextCommand", + "Locale": "en", + "TitleKey": "Setting.EnableTextCommand", + "Body": "Enable Text Command. Configured text-command preference. Dispatch-source patterns and verified member command identity are different checks. Provider and chatbot paths do not enforce this switch uniformly; review the active consumer before changing it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-enabletextcommand", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.TtsLanguage", + "Locale": "en", + "TitleKey": "Setting.TtsLanguage", + "Body": "Tts Language. Text-to-speech language for supported voice notifications. Confirm supported voices and pronunciation with a human-run test.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-ttslanguage", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.EnableModernNotifications", + "Locale": "en", + "TitleKey": "Setting.EnableModernNotifications", + "Body": "Enable Modern Notifications. Use supported modern notification behavior. Verify each consumer and channel; provider acceptance is not delivery.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-enablemodernnotifications", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "module.MessagingDisabled", + "Locale": "en", + "TitleKey": "Module.MessagingDisabled", + "Body": "Messaging availability. Controls availability of messages and announcements. Disabling this module can remove navigation and block module operations; it does not delete its records. Turning it on still requires the applicable subscription, rollout and permissions.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "module-messagingdisabled", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "module.MessagingNameOverride", + "Locale": "en", + "TitleKey": "Module.MessagingNameOverride", + "Body": "Messaging menu name. Optional display name for messages and announcements in consumers that support the override. It changes the label, not permissions or functionality. This release has no separate name-override control in Module Settings.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "module-messagingnameoverride", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentNotification.EventType", + "Locale": "en", + "TitleKey": "TableField.DepartmentNotification.EventType", + "Body": "Department Notification / Event Type. Event that can trigger this notification rule. Match the event to a real administrative responsibility and expected frequency.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentnotification-eventtype", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentNotification.UsersToNotify", + "Locale": "en", + "TitleKey": "TableField.DepartmentNotification.UsersToNotify", + "Body": "Department Notification / Users To Notify. Explicit member recipients. Membership and channel eligibility are evaluated separately at send time.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentnotification-userstonotify", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentNotification.RolesToNotify", + "Locale": "en", + "TitleKey": "TableField.DepartmentNotification.RolesToNotify", + "Body": "Department Notification / Roles To Notify. Personnel-role recipients. Role membership can change; a role label is not a qualification or delivery guarantee.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentnotification-rolestonotify", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentNotification.GroupsToNotify", + "Locale": "en", + "TitleKey": "TableField.DepartmentNotification.GroupsToNotify", + "Body": "Department Notification / Groups To Notify. Department-group recipients. Review group membership and lock-to-group behavior together.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentnotification-groupstonotify", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentNotification.LockToGroup", + "Locale": "en", + "TitleKey": "TableField.DepartmentNotification.LockToGroup", + "Body": "Department Notification / Lock To Group. Restricts applicable event processing to group scope. Review the selected event's actual source group and recipient resolver.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentnotification-locktogroup", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentNotification.SelectedGroupsAdminsOnly", + "Locale": "en", + "TitleKey": "TableField.DepartmentNotification.SelectedGroupsAdminsOnly", + "Body": "Department Notification / Selected Groups Admins Only. Limits selected-group recipients to their administrators in supported event paths. It does not designate department administrators automatically.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentnotification-selectedgroupsadminsonly", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentNotification.DepartmentAdmins", + "Locale": "en", + "TitleKey": "TableField.DepartmentNotification.DepartmentAdmins", + "Body": "Department Notification / Department Admins. Includes department administrators in supported notification processing. Review overlap and channel eligibility before estimating volume.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentnotification-departmentadmins", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentNotification.Everyone", + "Locale": "en", + "TitleKey": "TableField.DepartmentNotification.Everyone", + "Body": "Department Notification / Everyone. Selects the department-wide recipient option. Review actual active membership, suppression and deduplication before use.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentnotification-everyone", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentNotification.Disabled", + "Locale": "en", + "TitleKey": "TableField.DepartmentNotification.Disabled", + "Body": "Department Notification / Disabled. Stored disabled marker. The inspected legacy notification processor does not consult this flag, so it must not be treated as a verified stop switch. Review the active consumer; queued and delivered notifications cannot be recalled.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentnotification-disabled", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentNotification.BeforeData", + "Locale": "en", + "TitleKey": "TableField.DepartmentNotification.BeforeData", + "Body": "Department Notification / Before Data. Previous-state filter for applicable events. Review valid status IDs and the event-specific interpretation of an empty or wildcard value.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentnotification-beforedata", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentNotification.CurrentData", + "Locale": "en", + "TitleKey": "TableField.DepartmentNotification.CurrentData", + "Body": "Department Notification / Current Data. Current-state filter for applicable events. Compare it with the previous-state filter to avoid unintended volume.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentnotification-currentdata", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentNotification.UpperLimit", + "Locale": "en", + "TitleKey": "TableField.DepartmentNotification.UpperLimit", + "Body": "Department Notification / Upper Limit. Upper threshold for event-specific alerts. Units and comparisons depend on the selected event.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentnotification-upperlimit", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentNotification.LowerLimit", + "Locale": "en", + "TitleKey": "TableField.DepartmentNotification.LowerLimit", + "Body": "Department Notification / Lower Limit. Lower threshold for event-specific alerts. Use approved local thresholds and verify the actual event interpretation.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentnotification-lowerlimit", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentNotification.Data", + "Locale": "en", + "TitleKey": "TableField.DepartmentNotification.Data", + "Body": "Department Notification / Data. Event-specific configuration payload. The owning editor validates its meaning; Admin Assist does not execute arbitrary stored expressions.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentnotification-data", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "inbox", + "Locale": "en", + "TitleKey": "Feature.inbox", + "Body": "Inbox Your messages inbox Choose a communication channel appropriate to the audience and task. Preview recipients and explicitly run a communication test through its own screen. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "inbox", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "outbox", + "Locale": "en", + "TitleKey": "Feature.outbox", + "Body": "Sent Messages Messages you sent Choose a communication channel appropriate to the audience and task. Preview recipients and explicitly run a communication test through its own screen. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "outbox", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "compose-message", + "Locale": "en", + "TitleKey": "Feature.compose-message", + "Body": "New Message Send a message, poll or callback request Choose a communication channel appropriate to the audience and task. Preview recipients and explicitly run a communication test through its own screen. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "compose-message", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "chat", + "Locale": "en", + "TitleKey": "Feature.chat", + "Body": "Chat Real-time department chat Choose a communication channel appropriate to the audience and task. Preview recipients and explicitly run a communication test through its own screen. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "chat", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "voice", + "Locale": "en", + "TitleKey": "Feature.voice", + "Body": "Voice Voice channels and push-to-talk Choose a communication channel appropriate to the audience and task. Preview recipients and explicitly run a communication test through its own screen. Review supported clients, channel membership and current subscription quantities. Push-to-Talk is distinct from phone voice alerts and department radio requirements.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "voice", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "notifications", + "Locale": "en", + "TitleKey": "Feature.notifications", + "Body": "Notification rules Select events, recipients and conditions for administrative notifications. Reduce missed follow-up and review unnecessary message volume. Preview recipients and explicitly run a communication test through its own screen. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "notifications", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "distribution-lists", + "Locale": "en", + "TitleKey": "Feature.distribution-lists", + "Body": "Distribution lists Maintain reusable recipient lists for supported communication flows. Keep audience ownership explicit and review recipients before sending. Preview recipients and explicitly run a communication test through its own screen. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "distribution-lists", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "communication-tests", + "Locale": "en", + "TitleKey": "Feature.communication-tests", + "Body": "Communication Tests Prepare and explicitly run tests of configured channels. Collect observed test evidence; registration alone does not prove delivery. Preview recipients and explicitly run a communication test through its own screen. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "communication-tests", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "chatbot-settings", + "Locale": "en", + "TitleKey": "Feature.chatbot-settings", + "Body": "Chatbot and Assistant integration settings Review supported chatbot platforms, account linking, limits and operational actions. Keep automated access and confirmations under administrator control. Preview recipients and explicitly run a communication test through its own screen. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "chatbot-settings", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + } + ] +} diff --git a/Core/Resgrid.AdminAssist/Catalog/home.yaml b/Core/Resgrid.AdminAssist/Catalog/home.yaml new file mode 100644 index 000000000..87969053c --- /dev/null +++ b/Core/Resgrid.AdminAssist/Catalog/home.yaml @@ -0,0 +1,852 @@ +{ + "Version": "2026.09.24.1", + "Areas": [ + { + "Id": "home", + "LabelKey": "Area.home", + "PurposeKey": "AreaPurpose.home", + "Order": 0, + "Archetypes": [] + } + ], + "Settings": [ + { + "Id": "setting.BigBoardMapZoomLevel", + "AreaId": "home", + "LabelKey": "Setting.BigBoardMapZoomLevel", + "HelpKey": "SettingHelp.BigBoardMapZoomLevel", + "Binding": "DepartmentSettingTypes.BigBoardMapZoomLevel", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "Settings", + "Field": "BigBoardMapZoomLevel" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.home", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.BigBoardMapZoomLevel", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.BigBoardPageRefresh", + "AreaId": "home", + "LabelKey": "Setting.BigBoardPageRefresh", + "HelpKey": "SettingHelp.BigBoardPageRefresh", + "Binding": "DepartmentSettingTypes.BigBoardPageRefresh", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "Settings", + "Field": "BigBoardPageRefresh" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.home", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.BigBoardPageRefresh", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.BigBoardMapCenterAddress", + "AreaId": "home", + "LabelKey": "Setting.BigBoardMapCenterAddress", + "HelpKey": "SettingHelp.BigBoardMapCenterAddress", + "Binding": "DepartmentSettingTypes.BigBoardMapCenterAddress", + "ValueType": "reference", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "Settings", + "Field": "BigBoardMapCenterAddress" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.home", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.BigBoardMapCenterAddress", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.BigBoardHideUnavailable", + "AreaId": "home", + "LabelKey": "Setting.BigBoardHideUnavailable", + "HelpKey": "SettingHelp.BigBoardHideUnavailable", + "Binding": "DepartmentSettingTypes.BigBoardHideUnavailable", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "Settings", + "Field": "BigBoardHideUnavailable" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.home", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.BigBoardHideUnavailable", + "DefaultValue": "false", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.BigBoardMapCenterGpsCoordinates", + "AreaId": "home", + "LabelKey": "Setting.BigBoardMapCenterGpsCoordinates", + "HelpKey": "SettingHelp.BigBoardMapCenterGpsCoordinates", + "Binding": "DepartmentSettingTypes.BigBoardMapCenterGpsCoordinates", + "ValueType": "location", + "Classification": "Restricted", + "Secret": true, + "Location": { + "Controller": "Department", + "Action": "Settings", + "Field": "BigBoardMapCenterGpsCoordinates" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.home", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.BigBoardMapCenterGpsCoordinates", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + } + ], + "Capabilities": [ + { + "Id": "dashboard", + "AreaId": "home", + "LabelKey": "Feature.dashboard", + "PurposeKey": "FeaturePurpose.dashboard", + "ValueKey": "AreaValue.home", + "ExampleKey": "AreaExample.home", + "AdoptionKey": "FeatureAdoption.dashboard", + "ReleaseStatus": "available", + "Location": { + "Controller": "Home", + "Action": "Dashboard" + }, + "Requirements": [], + "SettingIds": [], + "RuleIds": [] + }, + { + "Id": "profile", + "AreaId": "home", + "LabelKey": "Feature.profile", + "PurposeKey": "FeaturePurpose.profile", + "ValueKey": "AreaValue.home", + "ExampleKey": "AreaExample.home", + "AdoptionKey": "FeatureAdoption.profile", + "ReleaseStatus": "available", + "Location": { + "Controller": "Home", + "Action": "EditUserProfile" + }, + "Requirements": [], + "SettingIds": [], + "RuleIds": [] + }, + { + "Id": "departments", + "AreaId": "home", + "LabelKey": "Feature.departments", + "PurposeKey": "FeaturePurpose.departments", + "ValueKey": "AreaValue.home", + "ExampleKey": "AreaExample.home", + "AdoptionKey": "FeatureAdoption.departments", + "ReleaseStatus": "available", + "Location": { + "Controller": "Profile", + "Action": "YourDepartments" + }, + "Requirements": [], + "SettingIds": [], + "RuleIds": [] + }, + { + "Id": "dispatch-app", + "AreaId": "home", + "LabelKey": "Feature.dispatch-app", + "PurposeKey": "FeaturePurpose.dispatch-app", + "ValueKey": "FeatureValue.dispatch-app", + "ExampleKey": "AreaExample.home", + "AdoptionKey": "FeatureAdoption.dispatch-app", + "ReleaseStatus": "available", + "Location": { + "Controller": "Dispatch", + "Action": "Dashboard" + }, + "Requirements": [ + { + "Kind": "permission", + "Id": "DispatchAppLogin" + } + ], + "SettingIds": [ + "permission.DispatchAppLogin" + ], + "RuleIds": [] + }, + { + "Id": "responder-app", + "AreaId": "home", + "LabelKey": "Feature.responder-app", + "PurposeKey": "FeaturePurpose.responder-app", + "ValueKey": "FeatureValue.responder-app", + "ExampleKey": "AreaExample.home", + "AdoptionKey": "FeatureAdoption.responder-app", + "ReleaseStatus": "available", + "Location": { + "Controller": "Home", + "Action": "EditUserProfile" + }, + "Requirements": [], + "SettingIds": [], + "RuleIds": [] + }, + { + "Id": "unit-app", + "AreaId": "home", + "LabelKey": "Feature.unit-app", + "PurposeKey": "FeaturePurpose.unit-app", + "ValueKey": "FeatureValue.unit-app", + "ExampleKey": "AreaExample.home", + "AdoptionKey": "FeatureAdoption.unit-app", + "ReleaseStatus": "available", + "Location": { + "Controller": "Units", + "Action": "Index" + }, + "Requirements": [], + "SettingIds": [], + "RuleIds": [ + "unit-group", + "unit-type" + ] + }, + { + "Id": "big-board", + "AreaId": "home", + "LabelKey": "Feature.big-board", + "PurposeKey": "FeaturePurpose.big-board", + "ValueKey": "FeatureValue.big-board", + "ExampleKey": "AreaExample.home", + "AdoptionKey": "FeatureAdoption.big-board", + "ReleaseStatus": "available", + "Location": { + "Controller": "Department", + "Action": "Settings" + }, + "Requirements": [], + "SettingIds": [ + "field.DepartmentSuppressStaffingInfo.EnableSupressStaffing", + "field.DepartmentSuppressStaffingInfo.StaffingLevelsToSupress", + "field.PersonnelListStatusOrder.StatusId", + "field.PersonnelListStatusOrder.Weight", + "field.PersonnelListStatusOrderSetting.Orders", + "setting.BigBoardHideUnavailable", + "setting.BigBoardMapCenterAddress", + "setting.BigBoardMapCenterGpsCoordinates", + "setting.BigBoardMapZoomLevel", + "setting.BigBoardPageRefresh", + "setting.CallsSortOrder", + "setting.DisabledAutoAvailable", + "setting.EnableModernNotifications", + "setting.ForceChatbotSecurityPin", + "setting.PersonnelListStatusSortOrder", + "setting.PersonnelSortOrder", + "setting.RequirePasswordResetViaEmail", + "setting.StaffingSuppressStaffingLevels", + "setting.TestEnabled", + "setting.UnitsSortOrder", + "setting.UpdateTimestamp", + "table.Department.ApiKey", + "table.Department.Code", + "table.Department.LinkCode", + "table.Department.ManagingUserId", + "table.Department.PublicApiKey", + "table.Department.SharedSecret", + "table.Department.ShowWelcome", + "table.Department.TimeZone", + "table.Department.Use24HourTime" + ], + "RuleIds": [] + } + ], + "Rules": [], + "Packs": [ + { + "Id": "fire", + "LabelKey": "Pack.fire", + "PurposeKey": "PackPurpose.fire", + "AreaIds": [ + "calls", + "people", + "location", + "maintenance" + ], + "RuleIds": [ + "shift-auto-without-dispatch", + "shift-coverage", + "text-sources", + "empty-groups", + "unit-type", + "unit-group", + "station-address", + "person-location-age", + "unit-location-age", + "map-token", + "map-style", + "run-cards", + "checkin-timers", + "weather-zones", + "qualified-coverage", + "credential-expiry", + "checklist-overdue", + "equipment-holds", + "import-heartbeat", + "email-import-failures", + "shift-open-slots", + "shift-overlaps", + "shift-trades", + "site-references" + ], + "PrerequisiteKeys": [ + "PackLocalRequirements", + "PackGap.fire", + "PackGapOwner" + ] + }, + { + "Id": "ems", + "LabelKey": "Pack.ems", + "PurposeKey": "PackPurpose.ems", + "AreaIds": [ + "people", + "maintenance", + "inventory", + "knowledge" + ], + "RuleIds": [ + "empty-groups", + "qualified-coverage", + "credential-expiry", + "checklist-overdue", + "equipment-holds", + "stock-expiry", + "shift-open-slots", + "shift-overlaps", + "shift-trades", + "policy-references", + "policy-expiry", + "continuity-reference" + ], + "PrerequisiteKeys": [ + "PackLocalRequirements", + "PackGap.ems", + "PackGapOwner" + ] + }, + { + "Id": "mental-health", + "LabelKey": "Pack.mental-health", + "PurposeKey": "PackPurpose.mental-health", + "AreaIds": [ + "people", + "communication", + "knowledge", + "records" + ], + "RuleIds": [ + "command-sources", + "empty-groups", + "communication-tests", + "qualified-coverage", + "credential-expiry", + "record-review", + "shift-open-slots", + "shift-overlaps", + "shift-trades", + "policy-references", + "policy-expiry", + "continuity-reference" + ], + "PrerequisiteKeys": [ + "PackLocalRequirements", + "PackGap.mental-health", + "PackGapOwner" + ] + }, + { + "Id": "sar", + "LabelKey": "Pack.sar", + "PurposeKey": "PackPurpose.sar", + "AreaIds": [ + "people", + "maintenance", + "location", + "calls" + ], + "RuleIds": [ + "shift-auto-without-dispatch", + "shift-coverage", + "text-sources", + "empty-groups", + "unit-type", + "unit-group", + "station-address", + "person-location-age", + "unit-location-age", + "map-token", + "map-style", + "run-cards", + "checkin-timers", + "weather-zones", + "qualified-coverage", + "credential-expiry", + "checklist-overdue", + "equipment-holds", + "import-heartbeat", + "email-import-failures", + "shift-open-slots", + "shift-overlaps", + "shift-trades", + "site-references" + ], + "PrerequisiteKeys": [ + "PackLocalRequirements", + "PackGap.sar", + "PackGapOwner" + ] + }, + { + "Id": "emergency-response", + "LabelKey": "Pack.emergency-response", + "PurposeKey": "PackPurpose.emergency-response", + "AreaIds": [ + "people", + "knowledge", + "inventory", + "calls" + ], + "RuleIds": [ + "shift-auto-without-dispatch", + "shift-coverage", + "text-sources", + "empty-groups", + "run-cards", + "checkin-timers", + "qualified-coverage", + "credential-expiry", + "stock-expiry", + "import-heartbeat", + "email-import-failures", + "shift-open-slots", + "shift-overlaps", + "shift-trades", + "policy-references", + "policy-expiry", + "continuity-reference" + ], + "PrerequisiteKeys": [ + "PackLocalRequirements", + "PackGap.emergency-response", + "PackGapOwner" + ] + }, + { + "Id": "hazmat", + "LabelKey": "Pack.hazmat", + "PurposeKey": "PackPurpose.hazmat", + "AreaIds": [ + "people", + "maintenance", + "inventory", + "knowledge" + ], + "RuleIds": [ + "empty-groups", + "qualified-coverage", + "credential-expiry", + "checklist-overdue", + "equipment-holds", + "stock-expiry", + "shift-open-slots", + "shift-overlaps", + "shift-trades", + "policy-references", + "policy-expiry", + "continuity-reference" + ], + "PrerequisiteKeys": [ + "PackLocalRequirements", + "PackGap.hazmat", + "PackGapOwner" + ] + }, + { + "Id": "industrial", + "LabelKey": "Pack.industrial", + "PurposeKey": "PackPurpose.industrial", + "AreaIds": [ + "people", + "maintenance", + "knowledge", + "communication" + ], + "RuleIds": [ + "command-sources", + "empty-groups", + "communication-tests", + "qualified-coverage", + "credential-expiry", + "checklist-overdue", + "equipment-holds", + "shift-open-slots", + "shift-overlaps", + "shift-trades", + "policy-references", + "policy-expiry", + "continuity-reference" + ], + "PrerequisiteKeys": [ + "PackLocalRequirements", + "PackGap.industrial", + "PackGapOwner" + ] + }, + { + "Id": "security", + "LabelKey": "Pack.security", + "PurposeKey": "PackPurpose.security", + "AreaIds": [ + "people", + "calls", + "maintenance", + "records" + ], + "RuleIds": [ + "shift-auto-without-dispatch", + "shift-coverage", + "text-sources", + "empty-groups", + "run-cards", + "checkin-timers", + "qualified-coverage", + "credential-expiry", + "checklist-overdue", + "equipment-holds", + "import-heartbeat", + "email-import-failures", + "record-review", + "shift-open-slots", + "shift-overlaps", + "shift-trades" + ], + "PrerequisiteKeys": [ + "PackLocalRequirements", + "PackGap.security", + "PackGapOwner" + ] + }, + { + "Id": "mutual-aid", + "LabelKey": "Pack.mutual-aid", + "PurposeKey": "PackPurpose.mutual-aid", + "AreaIds": [ + "calls", + "people", + "knowledge", + "business" + ], + "RuleIds": [ + "shift-auto-without-dispatch", + "shift-coverage", + "text-sources", + "empty-groups", + "run-cards", + "checkin-timers", + "qualified-coverage", + "credential-expiry", + "import-heartbeat", + "email-import-failures", + "shift-open-slots", + "shift-overlaps", + "shift-trades", + "policy-references", + "policy-expiry", + "continuity-reference" + ], + "PrerequisiteKeys": [ + "PackLocalRequirements", + "PackGap.mutual-aid", + "PackGapOwner" + ] + } + ], + "Articles": [ + { + "Id": "setting.BigBoardMapZoomLevel", + "Locale": "en", + "TitleKey": "Setting.BigBoardMapZoomLevel", + "Body": "Big Board Map Zoom Level. Initial Big Board map zoom. An unset override lets the consuming board select its default.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-bigboardmapzoomlevel", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.BigBoardPageRefresh", + "Locale": "en", + "TitleKey": "Setting.BigBoardPageRefresh", + "Body": "Big Board Page Refresh. Big Board refresh interval. Review load and the age of displayed information before changing it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-bigboardpagerefresh", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.BigBoardMapCenterAddress", + "Locale": "en", + "TitleKey": "Setting.BigBoardMapCenterAddress", + "Body": "Big Board Map Center Address. Address used to center the Big Board. Address details remain on their owning screen.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-bigboardmapcenteraddress", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.BigBoardHideUnavailable", + "Locale": "en", + "TitleKey": "Setting.BigBoardHideUnavailable", + "Body": "Big Board Hide Unavailable. Hide unavailable resources on the Big Board; this affects visibility, not their dispatch eligibility.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-bigboardhideunavailable", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.BigBoardMapCenterGpsCoordinates", + "Locale": "en", + "TitleKey": "Setting.BigBoardMapCenterGpsCoordinates", + "Body": "Big Board Map Center Gps Coordinates. Explicit board-center coordinates take precedence over address geocoding. Partial coordinates do not overwrite the saved center.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-bigboardmapcentergpscoordinates", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "dashboard", + "Locale": "en", + "TitleKey": "Feature.dashboard", + "Body": "Dashboard Department home: status, staffing and activity Give each role the right app and a clear starting point. A dispatcher creates a call while responders and unit crews receive their assigned work. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "dashboard", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "profile", + "Locale": "en", + "TitleKey": "Feature.profile", + "Body": "My Profile View and edit your own profile, contact methods and notifications Give each role the right app and a clear starting point. A dispatcher creates a call while responders and unit crews receive their assigned work. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "profile", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "departments", + "Locale": "en", + "TitleKey": "Feature.departments", + "Body": "Your Departments Switch between the departments you belong to Give each role the right app and a clear starting point. A dispatcher creates a call while responders and unit crews receive their assigned work. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "departments", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "dispatch-app", + "Locale": "en", + "TitleKey": "Feature.dispatch-app", + "Body": "Dispatch application Dispatchers use the dispatch interface to review calls and resources and explicitly select recipients. Give dispatch personnel a focused operating surface alongside web administration. A dispatcher creates a call while responders and unit crews receive their assigned work. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "dispatch-app", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "responder-app", + "Locale": "en", + "TitleKey": "Feature.responder-app", + "Body": "Responder application Individual members use the supported Responder client for their account, availability, calls and notifications. Separate member device setup from department configuration and verify each member's channels. A dispatcher creates a call while responders and unit crews receive their assigned work. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "responder-app", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "unit-app", + "Locale": "en", + "TitleKey": "Feature.unit-app", + "Body": "Unit application A shared unit client represents an apparatus or team rather than an individual member. Review device identity, assigned crew and unit communications together. A dispatcher creates a call while responders and unit crews receive their assigned work. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "unit-app", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "big-board", + "Locale": "en", + "TitleKey": "Feature.big-board", + "Body": "Big Board Shared display clients show the configured resource and call picture. Choose display refresh, location age and visibility appropriate to a station screen. A dispatcher creates a call while responders and unit crews receive their assigned work. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "big-board", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "guide.start-setup", + "Locale": "en", + "TitleKey": "Guide.start-setup", + "Body": "Department administrators can open **Setup Wizard** from the Department or Help menu when setup is enabled for the deployment. An unfinished setup also appears on the dashboard. Dismissing that prompt affects only your account; the menu remains available. Setup Wizard and Setup Report are available independently of Admin Assist and do not require an AI add-on.\n\nChoose **Fresh setup**, **Review existing setup**, or **Import or migration**. The choice records your intent; it does not import or overwrite data. Use the department operating profile to describe your organization and link approved local policies. Fire, EMS, mental health, SAR, emergency response, Hazmat, industrial, security and mutual-aid packs suggest areas to review. They do not establish qualifications or authorize clinical, tactical or hazardous work.\n\nThe nine-step journey covers goals and applications, the department profile, all-area orientation, people and access, operational essentials, selected workflows, add-ons, verification and practice, and review and handover. Pause and resume without creating sample incidents or overwriting existing configuration.\n\nSelected operating packs suggest areas without changing your choices. Site references use existing department group IDs; policy references use existing, unexpired department document IDs. Saving checks those references and rejects concurrent overwrites. A valid reference does not prove that its contents are approved or sufficient. Verification checks whether linked policies or site groups later become unavailable, and flags documents scheduled for removal within 30 days. A reviewed profile without a continuity link receives an administrative review prompt; an external procedure may still exist. The checks do not read or certify policy contents.\n\nAn optional **Expected email polling interval** compares recorded mailbox polls with your declared maximum interval. Leave it blank when no interval has been established. Missing or future poll timestamps remain unknown, and low call volume alone does not imply failure. SMS, CAD push and API intake require separate telemetry; the expectation does not change polling or send a test.", + "SourcePath": "docs/admin-assist/setup-guide.md", + "Anchor": "start-setup", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "guide.choose-areas", + "Locale": "en", + "TitleKey": "Guide.choose-areas", + "Body": "Mark each area **Use now**, **Learn later**, or **Not applicable**. A not-applicable choice requires a reason: outside the mission, managed in another approved system, managed by a responsible partner, or no current need. Baseline security remains in scope. Explore Resgrid covers applications, calls, people, units and location, communication, contacts and site knowledge, records, maintenance, inventory, deployments and business, automation, security and plans. Each feature explains its purpose, value, example and adoption requirements.\n\nArea choices are shared by department administrators. Learning and interest choices are personal. Another administrator's save can require you to reload and review before saving again. Learning a feature does not configure it, purchase an add-on, enable a module or send anything.", + "SourcePath": "docs/admin-assist/setup-guide.md", + "Anchor": "choose-areas", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "guide.configure-and-verify", + "Locale": "en", + "TitleKey": "Guide.configure-and-verify", + "Body": "Use **Open owning screen** to configure a feature with its existing permissions and validation. Some settings include contextual help and a highlighted field when opened from Admin Assist. Save on that screen, then use **Return to setup and verify**. The return link does not submit the form. Its navigation context lasts 30 minutes and is tied to the current administrator and department, so it can survive the owning screen’s save and redirect. Returning to setup clears it. Choose **Verify again** after saving.\n\nThe report separates verified checks, failures and unknown evidence. Unpurchased optional add-ons do not reduce core completion. Deferring an area does not hide a verified critical failure or uncertainty in an active critical check. Selected areas with no automated checks are listed explicitly; they are not verified. Missing, restricted or unavailable source data remains unknown; it is never treated as zero. Critical unknowns prevent a fully verified result. Review the evidence time and refresh again if configuration changed during the read.\n\nRecord an administrative review to retain the report version, evidence revision, selected-scope revision, timestamp and unresolved-check counts. A changed configuration, scope or catalog makes that review visibly out of date. Each newly added administrator still has a separate orientation checklist. An optional revisit date is stored in the report; it does not schedule a one-off notification. Weekly follow-up is a separate preference.\n\nSetup Report replaces the old numerical setup score. It is administrative configuration guidance, not certification of operational readiness or proof that a page was delivered. Start a Communication Test explicitly through its own screen when communication verification is needed.\n\n**Open a fresh printable report** rechecks access and reloads the summary. It includes current findings, selected areas, personal feature interests and public add-on guidance. It omits protected notes, names, source records and credentials. Printing or saving a local copy does not create a managed server export; handle the copy under department policy.", + "SourcePath": "docs/admin-assist/setup-guide.md", + "Anchor": "configure-and-verify", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "guide.understand-addons", + "Locale": "en", + "TitleKey": "Guide.understand-addons", + "Body": "| Add-on | What it adds | Adoption considerations |\n|---|---|---|\n| Push-to-Talk | Voice channels in supported Resgrid clients | Review seats, devices and channel membership. Phone voice alerts and radio requirements are separate. |\n| Advanced Data Protection | Additional protection and scoped disclosure for supported sensitive content | Purchase and enrollment are separate. Review MFA, recovery, supported fields and effects on search, exports and integrations. |\n| Readiness Pro | Maintenance, work orders, preventive and corrective work, approvals and safety holds | Checklists remain available without the add-on. Maintenance also needs its module and permissions. |\n| Business Operations | Invoicing, rates, contracts, bids, reimbursement and workforce costing | Certifications and Deployment Finance remain available without the add-on. Pay-data reporting also requires enabled ADP; payment providers need separate setup. |\n| Enhanced AI | Planned summaries, drafts, knowledge assistance and optional conversation | Availability depends on release and rollout. Deterministic setup and Admin Assist do not require it. |\n\nAvailability can depend on subscription, rollout, module settings, permissions, protection enrollment and source availability. An unknown subscription is not confirmation of a free or paid plan. Only the managing member can perform subscription changes through the billing screen. Feature interest does not start a trial or purchase.\n\nThe add-on step opens a comparison of all five add-ons. Each card lists its related features. Mark a feature **Interested in this feature** to see it in Setup Report with its current prerequisites and next available action. **I understand this feature**, availability, purchased entitlement, configuration and verified checks are separate states. Buying an add-on does not configure or verify the feature. The feature-setup section distinguishes recorded configuration, supported check results, current entitlement and optional opportunities. Initial evidence mappings cover personnel, groups, units, run cards, check-in timers, weather zones and email intake. Other feature configuration remains unassessed; absence of setup evidence does not prove a feature is unused.", + "SourcePath": "docs/admin-assist/setup-guide.md", + "Anchor": "understand-addons", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "guide.follow-up", + "Locale": "en", + "TitleKey": "Guide.follow-up", + "Body": "Admin Assist adds an administrative worklist. Claim an unresolved finding, set a review date, start a review, or record an accepted exception with a reason and expiry. Exceptions do not make the underlying check pass. Fresh verification resolves a finding; a later verified failure reopens it. An evidence outage cannot resolve it. Fix records, qualifications, inventory and other source tasks through their owning screens.\n\nWeekly follow-up is opt-in and only runs when the deployment enables scheduled digests. Quiet hours use the department time zone. The message contains a generic link to Admin Assist; open the authenticated page to see current evidence. A notification handoff is not confirmation of delivery. Turning the preference off suppresses future handoffs.", + "SourcePath": "docs/admin-assist/setup-guide.md", + "Anchor": "follow-up", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "guide.reference-and-history", + "Locale": "en", + "TitleKey": "Guide.reference-and-history", + "Body": "Settings Reference searches a release-pinned public documentation catalog. It does not search department records or attachments. If your language has no reference article, the result labels its source language.\n\nSupported scalar settings offer **Preview a proposed value**. A preview compares values in memory, shows related health-check changes and lists its limits. It does not save configuration. Current operational previews cover v4 map-marker selection, the automatic-availability status projection and administrator MFA enrollment. These do not establish every client's behavior, actual physical availability or session/recovery readiness.\n\n**Preview plan capacity** compares proposed total personnel and unit counts with observed base-plan headroom. It does not purchase capacity or add resources; add-on seats, provider quotas and pricing are separate.\n\n**Preview dispatch routing** uses a saved call ID as a route scenario. Choose an explicit UTC roster time within seven days of now and all three proposed shift/crew/group options. It compares direct, group, role and unit crew/group recipients with the broadcaster's resolver, preserving direct duplicates and empty-shift fallback. Current membership and crews are not reconstructed historical assignments. Unit devices, printers, channel eligibility, provider handoff and delivery are outside these personnel counts. The preview sends nothing and rereads inputs to detect changes during evaluation.\n\n**Preview a permission change** compares current members through the supported action or resource visibility gate. It includes roles, department and group administration, target-group ancestors, and ungrouped targets. It counts allowed actor/target pairs separately from members, so a narrowed group scope is visible even when the same members retain some access. Existing sessions, protected fields and other permission gates still require verification.\n\n**Preview a module switch** compares web navigation and bounded primary-table counts. Hiding an entry keeps the underlying data; it does not prove API access is revoked or a worker stops. Mapping, Reports, Logs/Records and Inventory data totals remain unknown where a complete adapter is unavailable. Licensed maintenance, checklists and business switches require separate entitlement-aware previews.\n\n**Preview a text sender scenario** compares a test number against current source patterns and the complete proposed call/command switches. Choose the provider path actually in use. The result returns a masked reference and distinguishes routing branches from actual successful acceptance. SignalWire and Twilio legacy paths share their production routing decisions with the preview, but do not consult the switches uniformly. Chatbot and master-number paths, number ownership, active SMS department, plan access, webhook authentication, verified identity, opt-out exceptions and parser success require separate verification. A missing dispatch-source pattern is not proof that verified members cannot use commands. The preview never receives or sends a text.\n\n**Preview notification volume** compares the current staffing-suppression setting with a proposed toggle for a declared department-wide scenario. Set the future window and assumed events per member across that entire window. The result separates current membership, preference/contact gates, confirmed suppression, unknown profiles/staffing and possible channel-handoff ranges. It uses no historical event sample and does not resolve every notification-rule audience. Address/device validity, provider behavior, chat/voice, SMS segments and delivery remain separate. Changing this preview never saves settings, sends a notification or changes staffing.\n\n**Preview sign-in and session policy** compares department-wide MFA, SSO-only policy, password age/minimum length, idle timeout or concurrent-session limits. Enrollment is distinct from verified factors and recovery. The SSO-only gate keeps its existing safety valve when no provider is enabled; an enabled provider is not a successful login test. Session estimates respect the host policy date and distinguish next-session limits from revocation. Password age uses the owning boundary and preserves its handling of untracked dates. No password, recovery secret, session ticket, IP address or provider credential is read by these projections, and the preview does not change credentials or sessions.\n\n**Preview retention policy** compares a prospective Records default using a bounded sample of metadata. A blank default uses the system class default; zero means permanent. Historical policy and definition overrides remain in force, so older revisions do not simply inherit a shortened default. Known parent/preservation holds and permanent-content obligations are excluded; uncertain historical holds are identified. Remaining candidates need the owning lifecycle checks for children, disclosure copies, attachments, external storage, evidence, submissions, workflows and search erasure. These counts are not permission or proof of eligibility to purge. The preview does not read record bodies, purge content, change protection or cancel an add-on.\n\nCounts and checks are only as current as their source evidence. Unquantified effects still need review on the owning screen. Refresh after a configuration conflict and verify again after an actual save. Reference results can expand the full release-pinned source text and identify its source language.\n\nChange History starts when collection is enabled. Earlier changes are unavailable. History shows safe configuration differences; secrets and sensitive strings use presence/change markers. It does not expose another administrator's personal learning choices.", + "SourcePath": "docs/admin-assist/setup-guide.md", + "Anchor": "reference-and-history", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + } + ] +} diff --git a/Core/Resgrid.AdminAssist/Catalog/inventory.yaml b/Core/Resgrid.AdminAssist/Catalog/inventory.yaml new file mode 100644 index 000000000..f4a046f3a --- /dev/null +++ b/Core/Resgrid.AdminAssist/Catalog/inventory.yaml @@ -0,0 +1,351 @@ +{ + "Version": "2026.09.24.1", + "Areas": [ + { + "Id": "inventory", + "LabelKey": "Area.inventory", + "PurposeKey": "AreaPurpose.inventory", + "Order": 8, + "Archetypes": [] + } + ], + "Settings": [ + { + "Id": "module.InventoryDisabled", + "AreaId": "inventory", + "LabelKey": "Module.InventoryDisabled", + "HelpKey": "ModuleHelp.InventoryDisabled", + "Binding": "DepartmentModuleSettings.InventoryDisabled", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "ModuleSettings", + "Field": "InventoryEnabled" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.inventory", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.ModuleSettings" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "module.InventoryDisabled", + "DefaultValue": "false", + "AllowedValues": "true or false", + "EvidenceSource": "DepartmentSettingsService.GetDepartmentModuleSettingsAsync", + "Availability": "available" + }, + { + "Id": "module.InventoryNameOverride", + "AreaId": "inventory", + "LabelKey": "Module.InventoryNameOverride", + "HelpKey": "ModuleHelp.InventoryNameOverride", + "Binding": "DepartmentModuleSettings.InventoryNameOverride", + "ValueType": "string", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "ModuleSettings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.inventory", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.ModuleSettings" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "module.InventoryNameOverride", + "DefaultValue": "unset", + "AllowedValues": "owning serializer and consuming UI", + "EvidenceSource": "DepartmentSettingsService.GetDepartmentModuleSettingsAsync", + "Availability": "reference-only" + } + ], + "Capabilities": [ + { + "Id": "inventory", + "AreaId": "inventory", + "LabelKey": "Feature.inventory", + "PurposeKey": "FeaturePurpose.inventory", + "ValueKey": "AreaValue.inventory", + "ExampleKey": "AreaExample.inventory", + "AdoptionKey": "FeatureAdoption.inventory", + "ReleaseStatus": "available", + "Location": { + "Controller": "Inventory", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "module", + "Id": "Inventory" + } + ], + "SettingIds": [ + "module.InventoryDisabled" + ], + "RuleIds": [ + "stock-expiry" + ] + }, + { + "Id": "inventory-status", + "AreaId": "inventory", + "LabelKey": "Feature.inventory-status", + "PurposeKey": "FeaturePurpose.inventory-status", + "ValueKey": "AreaValue.inventory", + "ExampleKey": "AreaExample.inventory", + "AdoptionKey": "FeatureAdoption.inventory-status", + "ReleaseStatus": "available", + "Location": { + "Controller": "Inventory", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "module", + "Id": "Inventory" + } + ], + "SettingIds": [ + "module.InventoryDisabled" + ], + "RuleIds": [ + "stock-expiry" + ] + }, + { + "Id": "inventory-transfer", + "AreaId": "inventory", + "LabelKey": "Feature.inventory-transfer", + "PurposeKey": "FeaturePurpose.inventory-transfer", + "ValueKey": "AreaValue.inventory", + "ExampleKey": "AreaExample.inventory", + "AdoptionKey": "FeatureAdoption.inventory-transfer", + "ReleaseStatus": "available", + "Location": { + "Controller": "Inventory", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "module", + "Id": "Inventory" + } + ], + "SettingIds": [ + "module.InventoryDisabled" + ], + "RuleIds": [ + "stock-expiry" + ] + }, + { + "Id": "inventory-issue", + "AreaId": "inventory", + "LabelKey": "Feature.inventory-issue", + "PurposeKey": "FeaturePurpose.inventory-issue", + "ValueKey": "AreaValue.inventory", + "ExampleKey": "AreaExample.inventory", + "AdoptionKey": "FeatureAdoption.inventory-issue", + "ReleaseStatus": "available", + "Location": { + "Controller": "Inventory", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "module", + "Id": "Inventory" + } + ], + "SettingIds": [ + "module.InventoryDisabled" + ], + "RuleIds": [ + "stock-expiry" + ] + }, + { + "Id": "inventory-purchasing", + "AreaId": "inventory", + "LabelKey": "Feature.inventory-purchasing", + "PurposeKey": "FeaturePurpose.inventory-purchasing", + "ValueKey": "FeatureValue.inventory-purchasing", + "ExampleKey": "AreaExample.inventory", + "AdoptionKey": "FeatureAdoption.inventory-purchasing", + "ReleaseStatus": "available", + "Location": { + "Controller": "Inventory", + "Action": "Purchasing" + }, + "Requirements": [ + { + "Kind": "module", + "Id": "Inventory" + } + ], + "SettingIds": [ + "module.InventoryDisabled" + ], + "RuleIds": [] + }, + { + "Id": "inventory-operations", + "AreaId": "inventory", + "LabelKey": "Feature.inventory-operations", + "PurposeKey": "FeaturePurpose.inventory-operations", + "ValueKey": "FeatureValue.inventory-operations", + "ExampleKey": "AreaExample.inventory", + "AdoptionKey": "FeatureAdoption.inventory-operations", + "ReleaseStatus": "available", + "Location": { + "Controller": "Inventory", + "Action": "Operations" + }, + "Requirements": [ + { + "Kind": "module", + "Id": "Inventory" + } + ], + "SettingIds": [ + "module.InventoryDisabled" + ], + "RuleIds": [] + } + ], + "Rules": [ + { + "Id": "stock-expiry", + "AreaId": "inventory", + "Severity": "Warning", + "TitleKey": "Rule.stock-expiry", + "ExplanationKey": "RuleWhy.stock-expiry", + "NextActionKey": "RuleNext.stock-expiry", + "Location": { + "Controller": "Inventory", + "Action": "Index" + }, + "AppliesWhen": [], + "FailsWhen": [ + { + "EvidenceId": "stockExpiring30Days", + "Comparison": "Greater", + "Number": 0 + } + ] + } + ], + "Packs": [], + "Articles": [ + { + "Id": "module.InventoryDisabled", + "Locale": "en", + "TitleKey": "Module.InventoryDisabled", + "Body": "Inventory availability. Controls availability of inventory. Disabling this module can remove navigation and block module operations; it does not delete its records. Turning it on still requires the applicable subscription, rollout and permissions.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "module-inventorydisabled", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "module.InventoryNameOverride", + "Locale": "en", + "TitleKey": "Module.InventoryNameOverride", + "Body": "Inventory menu name. Optional display name for inventory in consumers that support the override. It changes the label, not permissions or functionality. This release has no separate name-override control in Module Settings.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "module-inventorynameoverride", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "inventory", + "Locale": "en", + "TitleKey": "Feature.inventory", + "Body": "Inventory Inventory for stations and units See recorded shortages and due dates before they become an administrative surprise. Review an expiring supply lot and arrange restocking through the inventory screen. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "inventory", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "inventory-status", + "Locale": "en", + "TitleKey": "Feature.inventory-status", + "Body": "Inventory Status On-hand, low-stock and expiring inventory See recorded shortages and due dates before they become an administrative surprise. Review an expiring supply lot and arrange restocking through the inventory screen. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "inventory-status", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "inventory-transfer", + "Locale": "en", + "TitleKey": "Feature.inventory-transfer", + "Body": "Transfer Inventory Move inventory between locations See recorded shortages and due dates before they become an administrative surprise. Review an expiring supply lot and arrange restocking through the inventory screen. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "inventory-transfer", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "inventory-issue", + "Locale": "en", + "TitleKey": "Feature.inventory-issue", + "Body": "Issue Equipment Issue or return equipment to personnel See recorded shortages and due dates before they become an administrative surprise. Review an expiring supply lot and arrange restocking through the inventory screen. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "inventory-issue", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "inventory-purchasing", + "Locale": "en", + "TitleKey": "Feature.inventory-purchasing", + "Body": "Inventory purchasing Manage suppliers, purchase orders and receipts. Connect replenishment to recorded stock needs and authorized receipt. Review an expiring supply lot and arrange restocking through the inventory screen. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "inventory-purchasing", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "inventory-operations", + "Locale": "en", + "TitleKey": "Feature.inventory-operations", + "Body": "Inventory counts, expiry and alerts Review stock counts, expiry and configured inventory alerts. Identify inventory data and supplies that need owner follow-up. Review an expiring supply lot and arrange restocking through the inventory screen. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "inventory-operations", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + } + ] +} diff --git a/Core/Resgrid.AdminAssist/Catalog/knowledge.yaml b/Core/Resgrid.AdminAssist/Catalog/knowledge.yaml new file mode 100644 index 000000000..bc5c2fc30 --- /dev/null +++ b/Core/Resgrid.AdminAssist/Catalog/knowledge.yaml @@ -0,0 +1,658 @@ +{ + "Version": "2026.09.24.1", + "Areas": [ + { + "Id": "knowledge", + "LabelKey": "Area.knowledge", + "PurposeKey": "AreaPurpose.knowledge", + "Order": 5, + "Archetypes": [] + } + ], + "Settings": [ + { + "Id": "module.DocumentsDisabled", + "AreaId": "knowledge", + "LabelKey": "Module.DocumentsDisabled", + "HelpKey": "ModuleHelp.DocumentsDisabled", + "Binding": "DepartmentModuleSettings.DocumentsDisabled", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "ModuleSettings", + "Field": "DocumentsEnabled" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.knowledge", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.ModuleSettings" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "module.DocumentsDisabled", + "DefaultValue": "false", + "AllowedValues": "true or false", + "EvidenceSource": "DepartmentSettingsService.GetDepartmentModuleSettingsAsync", + "Availability": "available" + }, + { + "Id": "module.DocumentsNameOverride", + "AreaId": "knowledge", + "LabelKey": "Module.DocumentsNameOverride", + "HelpKey": "ModuleHelp.DocumentsNameOverride", + "Binding": "DepartmentModuleSettings.DocumentsNameOverride", + "ValueType": "string", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "ModuleSettings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.knowledge", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.ModuleSettings" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "module.DocumentsNameOverride", + "DefaultValue": "unset", + "AllowedValues": "owning serializer and consuming UI", + "EvidenceSource": "DepartmentSettingsService.GetDepartmentModuleSettingsAsync", + "Availability": "reference-only" + }, + { + "Id": "module.NotesDisabled", + "AreaId": "knowledge", + "LabelKey": "Module.NotesDisabled", + "HelpKey": "ModuleHelp.NotesDisabled", + "Binding": "DepartmentModuleSettings.NotesDisabled", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "ModuleSettings", + "Field": "NotesEnabled" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.knowledge", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.ModuleSettings" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "module.NotesDisabled", + "DefaultValue": "false", + "AllowedValues": "true or false", + "EvidenceSource": "DepartmentSettingsService.GetDepartmentModuleSettingsAsync", + "Availability": "available" + }, + { + "Id": "module.NotesNameOverride", + "AreaId": "knowledge", + "LabelKey": "Module.NotesNameOverride", + "HelpKey": "ModuleHelp.NotesNameOverride", + "Binding": "DepartmentModuleSettings.NotesNameOverride", + "ValueType": "string", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "ModuleSettings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.knowledge", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.ModuleSettings" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "module.NotesNameOverride", + "DefaultValue": "unset", + "AllowedValues": "owning serializer and consuming UI", + "EvidenceSource": "DepartmentSettingsService.GetDepartmentModuleSettingsAsync", + "Availability": "reference-only" + } + ], + "Capabilities": [ + { + "Id": "contacts", + "AreaId": "knowledge", + "LabelKey": "Feature.contacts", + "PurposeKey": "FeaturePurpose.contacts", + "ValueKey": "AreaValue.knowledge", + "ExampleKey": "AreaExample.knowledge", + "AdoptionKey": "FeatureAdoption.contacts", + "ReleaseStatus": "available", + "Location": { + "Controller": "Contacts", + "Action": "Index" + }, + "Requirements": [], + "SettingIds": [], + "RuleIds": [] + }, + { + "Id": "new-contact", + "AreaId": "knowledge", + "LabelKey": "Feature.new-contact", + "PurposeKey": "FeaturePurpose.new-contact", + "ValueKey": "AreaValue.knowledge", + "ExampleKey": "AreaExample.knowledge", + "AdoptionKey": "FeatureAdoption.new-contact", + "ReleaseStatus": "available", + "Location": { + "Controller": "Contacts", + "Action": "Add" + }, + "Requirements": [], + "SettingIds": [], + "RuleIds": [] + }, + { + "Id": "contact-categories", + "AreaId": "knowledge", + "LabelKey": "Feature.contact-categories", + "PurposeKey": "FeaturePurpose.contact-categories", + "ValueKey": "AreaValue.knowledge", + "ExampleKey": "AreaExample.knowledge", + "AdoptionKey": "FeatureAdoption.contact-categories", + "ReleaseStatus": "available", + "Location": { + "Controller": "Contacts", + "Action": "Categories" + }, + "Requirements": [], + "SettingIds": [], + "RuleIds": [] + }, + { + "Id": "documents", + "AreaId": "knowledge", + "LabelKey": "Feature.documents", + "PurposeKey": "FeaturePurpose.documents", + "ValueKey": "AreaValue.knowledge", + "ExampleKey": "AreaExample.knowledge", + "AdoptionKey": "FeatureAdoption.documents", + "ReleaseStatus": "available", + "Location": { + "Controller": "Documents", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "module", + "Id": "Documents" + } + ], + "SettingIds": [ + "module.DocumentsDisabled" + ], + "RuleIds": [ + "policy-expiry" + ] + }, + { + "Id": "new-document", + "AreaId": "knowledge", + "LabelKey": "Feature.new-document", + "PurposeKey": "FeaturePurpose.new-document", + "ValueKey": "AreaValue.knowledge", + "ExampleKey": "AreaExample.knowledge", + "AdoptionKey": "FeatureAdoption.new-document", + "ReleaseStatus": "available", + "Location": { + "Controller": "Documents", + "Action": "NewDocument" + }, + "Requirements": [ + { + "Kind": "module", + "Id": "Documents" + } + ], + "SettingIds": [ + "module.DocumentsDisabled" + ], + "RuleIds": [] + }, + { + "Id": "notes", + "AreaId": "knowledge", + "LabelKey": "Feature.notes", + "PurposeKey": "FeaturePurpose.notes", + "ValueKey": "AreaValue.knowledge", + "ExampleKey": "AreaExample.knowledge", + "AdoptionKey": "FeatureAdoption.notes", + "ReleaseStatus": "available", + "Location": { + "Controller": "Notes", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "module", + "Id": "Notes" + } + ], + "SettingIds": [ + "module.NotesDisabled" + ], + "RuleIds": [] + }, + { + "Id": "new-note", + "AreaId": "knowledge", + "LabelKey": "Feature.new-note", + "PurposeKey": "FeaturePurpose.new-note", + "ValueKey": "AreaValue.knowledge", + "ExampleKey": "AreaExample.knowledge", + "AdoptionKey": "FeatureAdoption.new-note", + "ReleaseStatus": "available", + "Location": { + "Controller": "Notes", + "Action": "NewNote" + }, + "Requirements": [ + { + "Kind": "module", + "Id": "Notes" + } + ], + "SettingIds": [ + "module.NotesDisabled" + ], + "RuleIds": [] + }, + { + "Id": "protocols", + "AreaId": "knowledge", + "LabelKey": "Feature.protocols", + "PurposeKey": "FeaturePurpose.protocols", + "ValueKey": "AreaValue.knowledge", + "ExampleKey": "AreaExample.knowledge", + "AdoptionKey": "FeatureAdoption.protocols", + "ReleaseStatus": "available", + "Location": { + "Controller": "Protocols", + "Action": "Index" + }, + "Requirements": [], + "SettingIds": [], + "RuleIds": [] + }, + { + "Id": "new-protocol", + "AreaId": "knowledge", + "LabelKey": "Feature.new-protocol", + "PurposeKey": "FeaturePurpose.new-protocol", + "ValueKey": "AreaValue.knowledge", + "ExampleKey": "AreaExample.knowledge", + "AdoptionKey": "FeatureAdoption.new-protocol", + "ReleaseStatus": "available", + "Location": { + "Controller": "Protocols", + "Action": "New" + }, + "Requirements": [], + "SettingIds": [], + "RuleIds": [] + }, + { + "Id": "forms", + "AreaId": "knowledge", + "LabelKey": "Feature.forms", + "PurposeKey": "FeaturePurpose.forms", + "ValueKey": "AreaValue.knowledge", + "ExampleKey": "AreaExample.knowledge", + "AdoptionKey": "FeatureAdoption.forms", + "ReleaseStatus": "available", + "Location": { + "Controller": "Forms", + "Action": "Index" + }, + "Requirements": [], + "SettingIds": [], + "RuleIds": [] + }, + { + "Id": "shared-links", + "AreaId": "knowledge", + "LabelKey": "Feature.shared-links", + "PurposeKey": "FeaturePurpose.shared-links", + "ValueKey": "FeatureValue.shared-links", + "ExampleKey": "AreaExample.knowledge", + "AdoptionKey": "FeatureAdoption.shared-links", + "ReleaseStatus": "available", + "Location": { + "Controller": "Links", + "Action": "Index" + }, + "Requirements": [], + "SettingIds": [], + "RuleIds": [] + }, + { + "Id": "file-library", + "AreaId": "knowledge", + "LabelKey": "Feature.file-library", + "PurposeKey": "FeaturePurpose.file-library", + "ValueKey": "FeatureValue.file-library", + "ExampleKey": "AreaExample.knowledge", + "AdoptionKey": "FeatureAdoption.file-library", + "ReleaseStatus": "available", + "Location": { + "Controller": "Documents", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "module", + "Id": "Documents" + } + ], + "SettingIds": [ + "module.DocumentsDisabled" + ], + "RuleIds": [ + "policy-expiry" + ] + } + ], + "Rules": [ + { + "Id": "policy-references", + "AreaId": "knowledge", + "Severity": "Warning", + "TitleKey": "Rule.policy-references", + "ExplanationKey": "RuleWhy.policy-references", + "NextActionKey": "RuleNext.policy-references", + "Location": { + "Controller": "Department", + "Action": "OperatingProfile" + }, + "AppliesWhen": [ + { + "EvidenceId": "declaredPolicyReferences", + "Comparison": "Greater", + "Number": 0 + } + ], + "FailsWhen": [ + { + "EvidenceId": "unavailablePolicyReferences", + "Comparison": "Greater", + "Number": 0 + } + ] + }, + { + "Id": "policy-expiry", + "AreaId": "knowledge", + "Severity": "Warning", + "TitleKey": "Rule.policy-expiry", + "ExplanationKey": "RuleWhy.policy-expiry", + "NextActionKey": "RuleNext.policy-expiry", + "Location": { + "Controller": "Documents", + "Action": "Index" + }, + "AppliesWhen": [ + { + "EvidenceId": "declaredPolicyReferences", + "Comparison": "Greater", + "Number": 0 + } + ], + "FailsWhen": [ + { + "EvidenceId": "policyReferencesExpiring30Days", + "Comparison": "Greater", + "Number": 0 + } + ] + }, + { + "Id": "continuity-reference", + "AreaId": "knowledge", + "Severity": "Information", + "TitleKey": "Rule.continuity-reference", + "ExplanationKey": "RuleWhy.continuity-reference", + "NextActionKey": "RuleNext.continuity-reference", + "Location": { + "Controller": "Department", + "Action": "OperatingProfile" + }, + "AppliesWhen": [ + { + "EvidenceId": "operatingProfileReviewed", + "Comparison": "IsTrue", + "Number": null + } + ], + "FailsWhen": [ + { + "EvidenceId": "declaredContinuityReferences", + "Comparison": "Equal", + "Number": 0 + } + ] + } + ], + "Packs": [], + "Articles": [ + { + "Id": "module.DocumentsDisabled", + "Locale": "en", + "TitleKey": "Module.DocumentsDisabled", + "Body": "Documents availability. Controls availability of shared documents. Disabling this module can remove navigation and block module operations; it does not delete its records. Turning it on still requires the applicable subscription, rollout and permissions.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "module-documentsdisabled", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "module.DocumentsNameOverride", + "Locale": "en", + "TitleKey": "Module.DocumentsNameOverride", + "Body": "Documents menu name. Optional display name for shared documents in consumers that support the override. It changes the label, not permissions or functionality. This release has no separate name-override control in Module Settings.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "module-documentsnameoverride", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "module.NotesDisabled", + "Locale": "en", + "TitleKey": "Module.NotesDisabled", + "Body": "Notes availability. Controls availability of notes. Disabling this module can remove navigation and block module operations; it does not delete its records. Turning it on still requires the applicable subscription, rollout and permissions.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "module-notesdisabled", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "module.NotesNameOverride", + "Locale": "en", + "TitleKey": "Module.NotesNameOverride", + "Body": "Notes menu name. Optional display name for notes in consumers that support the override. It changes the label, not permissions or functionality. This release has no separate name-override control in Module Settings.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "module-notesnameoverride", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "contacts", + "Locale": "en", + "TitleKey": "Feature.contacts", + "Body": "Contacts People and organizations outside the department Help authorized members find the current reference and responsible contact. Review a receiving-agency contact and an approved site plan before a planned event. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "contacts", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "new-contact", + "Locale": "en", + "TitleKey": "Feature.new-contact", + "Body": "New Contact Add a person or organization contact Help authorized members find the current reference and responsible contact. Review a receiving-agency contact and an approved site plan before a planned event. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "new-contact", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "contact-categories", + "Locale": "en", + "TitleKey": "Feature.contact-categories", + "Body": "Contact Categories Manage contact categories Help authorized members find the current reference and responsible contact. Review a receiving-agency contact and an approved site plan before a planned event. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "contact-categories", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "documents", + "Locale": "en", + "TitleKey": "Feature.documents", + "Body": "Documents Upload and share documents Help authorized members find the current reference and responsible contact. Review a receiving-agency contact and an approved site plan before a planned event. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "documents", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "new-document", + "Locale": "en", + "TitleKey": "Feature.new-document", + "Body": "Upload Document Upload a new document Help authorized members find the current reference and responsible contact. Review a receiving-agency contact and an approved site plan before a planned event. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "new-document", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "notes", + "Locale": "en", + "TitleKey": "Feature.notes", + "Body": "Notes Department notes: small bits of shared information Help authorized members find the current reference and responsible contact. Review a receiving-agency contact and an approved site plan before a planned event. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "notes", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "new-note", + "Locale": "en", + "TitleKey": "Feature.new-note", + "Body": "New Note Post a department note Help authorized members find the current reference and responsible contact. Review a receiving-agency contact and an approved site plan before a planned event. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "new-note", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "protocols", + "Locale": "en", + "TitleKey": "Feature.protocols", + "Body": "Protocols Dispatch protocols and procedures Help authorized members find the current reference and responsible contact. Review a receiving-agency contact and an approved site plan before a planned event. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "protocols", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "new-protocol", + "Locale": "en", + "TitleKey": "Feature.new-protocol", + "Body": "New Protocol Create a dispatch protocol Help authorized members find the current reference and responsible contact. Review a receiving-agency contact and an approved site plan before a planned event. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "new-protocol", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "forms", + "Locale": "en", + "TitleKey": "Feature.forms", + "Body": "Forms Custom call and dispatch forms Help authorized members find the current reference and responsible contact. Review a receiving-agency contact and an approved site plan before a planned event. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "forms", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "shared-links", + "Locale": "en", + "TitleKey": "Feature.shared-links", + "Body": "Shared links Configure supported shared views and links. Review exposed data and link access before distribution. Review a receiving-agency contact and an approved site plan before a planned event. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "shared-links", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "file-library", + "Locale": "en", + "TitleKey": "Feature.file-library", + "Body": "Files and attachments Attach and maintain files through their owning documents and records. Keep reference material with its owner, permissions and retention policy. Review a receiving-agency contact and an approved site plan before a planned event. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "file-library", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + } + ] +} diff --git a/Core/Resgrid.AdminAssist/Catalog/location.yaml b/Core/Resgrid.AdminAssist/Catalog/location.yaml new file mode 100644 index 000000000..540491c95 --- /dev/null +++ b/Core/Resgrid.AdminAssist/Catalog/location.yaml @@ -0,0 +1,2136 @@ +{ + "Version": "2026.09.24.1", + "Areas": [ + { + "Id": "location", + "LabelKey": "Area.location", + "PurposeKey": "AreaPurpose.location", + "Order": 3, + "Archetypes": [] + } + ], + "Settings": [ + { + "Id": "setting.UnitsSortOrder", + "AreaId": "location", + "LabelKey": "Setting.UnitsSortOrder", + "HelpKey": "SettingHelp.UnitsSortOrder", + "Binding": "DepartmentSettingTypes.UnitsSortOrder", + "ValueType": "enum", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "Settings", + "Field": "UnitsSortOrder" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.location", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.UnitsSortOrder", + "DefaultValue": "owning view default", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.MappingPersonnelLocationTTL", + "AreaId": "location", + "LabelKey": "Setting.MappingPersonnelLocationTTL", + "HelpKey": "SettingHelp.MappingPersonnelLocationTTL", + "Binding": "DepartmentSettingTypes.MappingPersonnelLocationTTL", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "MappingSettings", + "Field": "PersonnelLocationTTL" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.location", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.MappingPersonnelLocationTTL", + "DefaultValue": "0", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.MappingUnitLocationTTL", + "AreaId": "location", + "LabelKey": "Setting.MappingUnitLocationTTL", + "HelpKey": "SettingHelp.MappingUnitLocationTTL", + "Binding": "DepartmentSettingTypes.MappingUnitLocationTTL", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "MappingSettings", + "Field": "UnitLocationTTL" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.location", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.MappingUnitLocationTTL", + "DefaultValue": "0", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.MappingPersonnelAllowStatusWithNoLocationToOverwrite", + "AreaId": "location", + "LabelKey": "Setting.MappingPersonnelAllowStatusWithNoLocationToOverwrite", + "HelpKey": "SettingHelp.MappingPersonnelAllowStatusWithNoLocationToOverwrite", + "Binding": "DepartmentSettingTypes.MappingPersonnelAllowStatusWithNoLocationToOverwrite", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "MappingSettings", + "Field": "PersonnelAllowStatusWithNoLocationToOverwrite" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.location", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.MappingPersonnelAllowStatusWithNoLocationToOverwrite", + "DefaultValue": "false", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.MappingUnitAllowStatusWithNoLocationToOverwrite", + "AreaId": "location", + "LabelKey": "Setting.MappingUnitAllowStatusWithNoLocationToOverwrite", + "HelpKey": "SettingHelp.MappingUnitAllowStatusWithNoLocationToOverwrite", + "Binding": "DepartmentSettingTypes.MappingUnitAllowStatusWithNoLocationToOverwrite", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "MappingSettings", + "Field": "UnitAllowStatusWithNoLocationToOverwrite" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.location", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.MappingUnitAllowStatusWithNoLocationToOverwrite", + "DefaultValue": "false", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.PersonnelOnUnitSetUnitStatus", + "AreaId": "location", + "LabelKey": "Setting.PersonnelOnUnitSetUnitStatus", + "HelpKey": "SettingHelp.PersonnelOnUnitSetUnitStatus", + "Binding": "DepartmentSettingTypes.PersonnelOnUnitSetUnitStatus", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "DispatchSettings", + "Field": "PersonnelOnUnitSetUnitStatus" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.location", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.PersonnelOnUnitSetUnitStatus", + "DefaultValue": "false", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.WeatherAlertsEnabled", + "AreaId": "location", + "LabelKey": "Setting.WeatherAlertsEnabled", + "HelpKey": "SettingHelp.WeatherAlertsEnabled", + "Binding": "DepartmentSettingTypes.WeatherAlertsEnabled", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "WeatherAlerts", + "Action": "Settings", + "Field": "WeatherAlertsEnabled" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.location", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.WeatherAlertsEnabled", + "DefaultValue": "false", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.WeatherAlertMinimumSeverity", + "AreaId": "location", + "LabelKey": "Setting.WeatherAlertMinimumSeverity", + "HelpKey": "SettingHelp.WeatherAlertMinimumSeverity", + "Binding": "DepartmentSettingTypes.WeatherAlertMinimumSeverity", + "ValueType": "enum", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "WeatherAlerts", + "Action": "Settings", + "Field": "WeatherAlertMinimumSeverity" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.location", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.WeatherAlertMinimumSeverity", + "DefaultValue": "owning weather default", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.WeatherAlertAutoMessageSeverity", + "AreaId": "location", + "LabelKey": "Setting.WeatherAlertAutoMessageSeverity", + "HelpKey": "SettingHelp.WeatherAlertAutoMessageSeverity", + "Binding": "DepartmentSettingTypes.WeatherAlertAutoMessageSeverity", + "ValueType": "enum", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "WeatherAlerts", + "Action": "Settings", + "Field": "WeatherAlertAutoMessageSeverity" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.location", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.WeatherAlertAutoMessageSeverity", + "DefaultValue": "owning weather default", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.WeatherAlertCallIntegration", + "AreaId": "location", + "LabelKey": "Setting.WeatherAlertCallIntegration", + "HelpKey": "SettingHelp.WeatherAlertCallIntegration", + "Binding": "DepartmentSettingTypes.WeatherAlertCallIntegration", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "WeatherAlerts", + "Action": "Settings", + "Field": "WeatherAlertCallIntegration" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.location", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.WeatherAlertCallIntegration", + "DefaultValue": "false", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.WeatherAlertCacheMinutes", + "AreaId": "location", + "LabelKey": "Setting.WeatherAlertCacheMinutes", + "HelpKey": "SettingHelp.WeatherAlertCacheMinutes", + "Binding": "DepartmentSettingTypes.WeatherAlertCacheMinutes", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "WeatherAlerts", + "Action": "Settings", + "Field": "WeatherAlertCacheMinutes" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.location", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.WeatherAlertCacheMinutes", + "DefaultValue": "owning weather default", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.WeatherAlertAutoMessageSchedule", + "AreaId": "location", + "LabelKey": "Setting.WeatherAlertAutoMessageSchedule", + "HelpKey": "SettingHelp.WeatherAlertAutoMessageSchedule", + "Binding": "DepartmentSettingTypes.WeatherAlertAutoMessageSchedule", + "ValueType": "structured", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "WeatherAlerts", + "Action": "Settings", + "Field": "WeatherAlertAutoMessageSchedule" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.location", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.WeatherAlertAutoMessageSchedule", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.WeatherAlertExcludedEvents", + "AreaId": "location", + "LabelKey": "Setting.WeatherAlertExcludedEvents", + "HelpKey": "SettingHelp.WeatherAlertExcludedEvents", + "Binding": "DepartmentSettingTypes.WeatherAlertExcludedEvents", + "ValueType": "list", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "WeatherAlerts", + "Action": "Settings", + "Field": "WeatherAlertExcludedEvents" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.location", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.WeatherAlertExcludedEvents", + "DefaultValue": "empty", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.MappingUseMapboxOverride", + "AreaId": "location", + "LabelKey": "Setting.MappingUseMapboxOverride", + "HelpKey": "SettingHelp.MappingUseMapboxOverride", + "Binding": "DepartmentSettingTypes.MappingUseMapboxOverride", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "MappingSettings", + "Field": "UseMapboxOverride" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.location", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [ + "setting.MappingMapboxAccessToken", + "setting.MappingMapboxStyleUrl" + ], + "Conflicts": [], + "DocumentationId": "setting.MappingUseMapboxOverride", + "DefaultValue": "false", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.MappingMapboxStyleUrl", + "AreaId": "location", + "LabelKey": "Setting.MappingMapboxStyleUrl", + "HelpKey": "SettingHelp.MappingMapboxStyleUrl", + "Binding": "DepartmentSettingTypes.MappingMapboxStyleUrl", + "ValueType": "sensitive-text", + "Classification": "Restricted", + "Secret": true, + "Location": { + "Controller": "Department", + "Action": "MappingSettings", + "Field": "MapboxStyleUrl" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.location", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.MappingUseMapboxOverride" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.MappingMapboxStyleUrl", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.MappingMapboxAccessToken", + "AreaId": "location", + "LabelKey": "Setting.MappingMapboxAccessToken", + "HelpKey": "SettingHelp.MappingMapboxAccessToken", + "Binding": "DepartmentSettingTypes.MappingMapboxAccessToken", + "ValueType": "secret", + "Classification": "Restricted", + "Secret": true, + "Location": { + "Controller": "Department", + "Action": "MappingSettings", + "Field": "MapboxAccessToken" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.location", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.MappingUseMapboxOverride" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.MappingMapboxAccessToken", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.HardwareTrackingStaleAfterSeconds", + "AreaId": "location", + "LabelKey": "Setting.HardwareTrackingStaleAfterSeconds", + "HelpKey": "SettingHelp.HardwareTrackingStaleAfterSeconds", + "Binding": "DepartmentSettingTypes.HardwareTrackingStaleAfterSeconds", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "MappingSettings", + "Field": "HardwareTrackingStaleAfterSeconds" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.location", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.HardwareTrackingStaleAfterSeconds", + "DefaultValue": "180", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.HardwareTrackingMobileFallbackEnabled", + "AreaId": "location", + "LabelKey": "Setting.HardwareTrackingMobileFallbackEnabled", + "HelpKey": "SettingHelp.HardwareTrackingMobileFallbackEnabled", + "Binding": "DepartmentSettingTypes.HardwareTrackingMobileFallbackEnabled", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "MappingSettings", + "Field": "HardwareTrackingMobileFallbackEnabled" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.location", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.HardwareTrackingMobileFallbackEnabled", + "DefaultValue": "true", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.HardwareTrackingLocationRetentionDays", + "AreaId": "location", + "LabelKey": "Setting.HardwareTrackingLocationRetentionDays", + "HelpKey": "SettingHelp.HardwareTrackingLocationRetentionDays", + "Binding": "DepartmentSettingTypes.HardwareTrackingLocationRetentionDays", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "MappingSettings", + "Field": "HardwareTrackingLocationRetentionDays" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.location", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.HardwareTrackingLocationRetentionDays", + "DefaultValue": "UnitTrackingConfig.DefaultLocationRetentionDays", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.UnitStatusThresholds", + "AreaId": "location", + "LabelKey": "Setting.UnitStatusThresholds", + "HelpKey": "SettingHelp.UnitStatusThresholds", + "Binding": "DepartmentSettingTypes.UnitStatusThresholds", + "ValueType": "protobuf", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "MappingSettings", + "Field": "UnitStatusThresholds" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.location", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.UnitStatusThresholds", + "DefaultValue": "no thresholds", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "module.MappingDisabled", + "AreaId": "location", + "LabelKey": "Module.MappingDisabled", + "HelpKey": "ModuleHelp.MappingDisabled", + "Binding": "DepartmentModuleSettings.MappingDisabled", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "ModuleSettings", + "Field": "MappingEnabled" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.location", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.ModuleSettings" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "module.MappingDisabled", + "DefaultValue": "false", + "AllowedValues": "true or false", + "EvidenceSource": "DepartmentSettingsService.GetDepartmentModuleSettingsAsync", + "Availability": "available" + }, + { + "Id": "module.MappingNameOverride", + "AreaId": "location", + "LabelKey": "Module.MappingNameOverride", + "HelpKey": "ModuleHelp.MappingNameOverride", + "Binding": "DepartmentModuleSettings.MappingNameOverride", + "ValueType": "string", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "ModuleSettings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.location", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.ModuleSettings" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "module.MappingNameOverride", + "DefaultValue": "unset", + "AllowedValues": "owning serializer and consuming UI", + "EvidenceSource": "DepartmentSettingsService.GetDepartmentModuleSettingsAsync", + "Availability": "reference-only" + }, + { + "Id": "field.UnitStatusThresholds.Thresholds", + "AreaId": "location", + "LabelKey": "Field.UnitStatusThresholds.Thresholds", + "HelpKey": "FieldHelp.UnitStatusThresholds.Thresholds", + "Binding": "UnitStatusThresholds.Thresholds", + "ValueType": "list", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "MappingSettings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.location", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.UnitStatusThresholds" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.UnitStatusThresholds.Thresholds", + "DefaultValue": "empty", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "UnitStatusThresholds", + "Availability": "reference-only" + }, + { + "Id": "field.UnitStatusThreshold.BaseType", + "AreaId": "location", + "LabelKey": "Field.UnitStatusThreshold.BaseType", + "HelpKey": "FieldHelp.UnitStatusThreshold.BaseType", + "Binding": "UnitStatusThreshold.BaseType", + "ValueType": "enum", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "MappingSettings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.location", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.UnitStatusThresholds" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.UnitStatusThreshold.BaseType", + "DefaultValue": "0", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "UnitStatusThreshold", + "Availability": "reference-only" + }, + { + "Id": "field.UnitStatusThreshold.WarnSeconds", + "AreaId": "location", + "LabelKey": "Field.UnitStatusThreshold.WarnSeconds", + "HelpKey": "FieldHelp.UnitStatusThreshold.WarnSeconds", + "Binding": "UnitStatusThreshold.WarnSeconds", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "MappingSettings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.location", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.UnitStatusThresholds" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.UnitStatusThreshold.WarnSeconds", + "DefaultValue": "0", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "UnitStatusThreshold", + "Availability": "reference-only" + }, + { + "Id": "field.UnitStatusThreshold.AlertSeconds", + "AreaId": "location", + "LabelKey": "Field.UnitStatusThreshold.AlertSeconds", + "HelpKey": "FieldHelp.UnitStatusThreshold.AlertSeconds", + "Binding": "UnitStatusThreshold.AlertSeconds", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "MappingSettings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.location", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.UnitStatusThresholds" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.UnitStatusThreshold.AlertSeconds", + "DefaultValue": "0", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "UnitStatusThreshold", + "Availability": "reference-only" + }, + { + "Id": "table.Department.AddressId", + "AreaId": "location", + "LabelKey": "TableField.Department.AddressId", + "HelpKey": "TableHelp.Department.AddressId", + "Binding": "Department.AddressId", + "ValueType": "reference", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "Profile", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.location", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.Department.AddressId", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "Department", + "Availability": "reference-only" + }, + { + "Id": "table.WeatherAlertZone.Name", + "AreaId": "location", + "LabelKey": "TableField.WeatherAlertZone.Name", + "HelpKey": "TableHelp.WeatherAlertZone.Name", + "Binding": "WeatherAlertZone.Name", + "ValueType": "text", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "WeatherAlerts", + "Action": "Zones", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.location", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.WeatherAlertZone.Name", + "DefaultValue": "required", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "WeatherAlertZone", + "Availability": "reference-only" + }, + { + "Id": "table.WeatherAlertZone.ZoneCode", + "AreaId": "location", + "LabelKey": "TableField.WeatherAlertZone.ZoneCode", + "HelpKey": "TableHelp.WeatherAlertZone.ZoneCode", + "Binding": "WeatherAlertZone.ZoneCode", + "ValueType": "restricted", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "WeatherAlerts", + "Action": "Zones", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.location", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.WeatherAlertZone.ZoneCode", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "WeatherAlertZone", + "Availability": "reference-only" + }, + { + "Id": "table.WeatherAlertZone.CenterGeoLocation", + "AreaId": "location", + "LabelKey": "TableField.WeatherAlertZone.CenterGeoLocation", + "HelpKey": "TableHelp.WeatherAlertZone.CenterGeoLocation", + "Binding": "WeatherAlertZone.CenterGeoLocation", + "ValueType": "location", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "WeatherAlerts", + "Action": "Zones", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.location", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.WeatherAlertZone.CenterGeoLocation", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "WeatherAlertZone", + "Availability": "reference-only" + }, + { + "Id": "table.WeatherAlertZone.RadiusMiles", + "AreaId": "location", + "LabelKey": "TableField.WeatherAlertZone.RadiusMiles", + "HelpKey": "TableHelp.WeatherAlertZone.RadiusMiles", + "Binding": "WeatherAlertZone.RadiusMiles", + "ValueType": "number", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "WeatherAlerts", + "Action": "Zones", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.location", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.WeatherAlertZone.RadiusMiles", + "DefaultValue": "editor default", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "WeatherAlertZone", + "Availability": "reference-only" + }, + { + "Id": "table.WeatherAlertZone.IsActive", + "AreaId": "location", + "LabelKey": "TableField.WeatherAlertZone.IsActive", + "HelpKey": "TableHelp.WeatherAlertZone.IsActive", + "Binding": "WeatherAlertZone.IsActive", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "WeatherAlerts", + "Action": "Zones", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.location", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.WeatherAlertZone.IsActive", + "DefaultValue": "false", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "WeatherAlertZone", + "Availability": "reference-only" + }, + { + "Id": "table.WeatherAlertZone.IsPrimary", + "AreaId": "location", + "LabelKey": "TableField.WeatherAlertZone.IsPrimary", + "HelpKey": "TableHelp.WeatherAlertZone.IsPrimary", + "Binding": "WeatherAlertZone.IsPrimary", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "WeatherAlerts", + "Action": "Zones", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.location", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.WeatherAlertZone.IsPrimary", + "DefaultValue": "false", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "WeatherAlertZone", + "Availability": "reference-only" + } + ], + "Capabilities": [ + { + "Id": "units", + "AreaId": "location", + "LabelKey": "Feature.units", + "PurposeKey": "FeaturePurpose.units", + "ValueKey": "AreaValue.location", + "ExampleKey": "AreaExample.location", + "AdoptionKey": "FeatureAdoption.units", + "ReleaseStatus": "available", + "Location": { + "Controller": "Units", + "Action": "Index" + }, + "Requirements": [], + "SettingIds": [], + "RuleIds": [ + "unit-group", + "unit-type" + ], + "Setup": { + "EvidenceId": "unitCount", + "Minimum": 1, + "RuleIds": [ + "unit-type", + "unit-group" + ], + "GuidanceKey": "SetupEvidence.units" + } + }, + { + "Id": "new-unit", + "AreaId": "location", + "LabelKey": "Feature.new-unit", + "PurposeKey": "FeaturePurpose.new-unit", + "ValueKey": "AreaValue.location", + "ExampleKey": "AreaExample.location", + "AdoptionKey": "FeatureAdoption.new-unit", + "ReleaseStatus": "available", + "Location": { + "Controller": "Units", + "Action": "NewUnit" + }, + "Requirements": [], + "SettingIds": [], + "RuleIds": [] + }, + { + "Id": "unit-staffing", + "AreaId": "location", + "LabelKey": "Feature.unit-staffing", + "PurposeKey": "FeaturePurpose.unit-staffing", + "ValueKey": "AreaValue.location", + "ExampleKey": "AreaExample.location", + "AdoptionKey": "FeatureAdoption.unit-staffing", + "ReleaseStatus": "available", + "Location": { + "Controller": "Units", + "Action": "UnitStaffing" + }, + "Requirements": [], + "SettingIds": [], + "RuleIds": [] + }, + { + "Id": "mapping", + "AreaId": "location", + "LabelKey": "Feature.mapping", + "PurposeKey": "FeaturePurpose.mapping", + "ValueKey": "AreaValue.location", + "ExampleKey": "AreaExample.location", + "AdoptionKey": "FeatureAdoption.mapping", + "ReleaseStatus": "available", + "Location": { + "Controller": "Mapping", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "module", + "Id": "Mapping" + } + ], + "SettingIds": [ + "module.MappingDisabled" + ], + "RuleIds": [] + }, + { + "Id": "pois", + "AreaId": "location", + "LabelKey": "Feature.pois", + "PurposeKey": "FeaturePurpose.pois", + "ValueKey": "AreaValue.location", + "ExampleKey": "AreaExample.location", + "AdoptionKey": "FeatureAdoption.pois", + "ReleaseStatus": "available", + "Location": { + "Controller": "Mapping", + "Action": "POIs" + }, + "Requirements": [ + { + "Kind": "module", + "Id": "Mapping" + } + ], + "SettingIds": [ + "module.MappingDisabled" + ], + "RuleIds": [] + }, + { + "Id": "map-layers", + "AreaId": "location", + "LabelKey": "Feature.map-layers", + "PurposeKey": "FeaturePurpose.map-layers", + "ValueKey": "AreaValue.location", + "ExampleKey": "AreaExample.location", + "AdoptionKey": "FeatureAdoption.map-layers", + "ReleaseStatus": "available", + "Location": { + "Controller": "Mapping", + "Action": "Layers" + }, + "Requirements": [ + { + "Kind": "module", + "Id": "Mapping" + } + ], + "SettingIds": [ + "module.MappingDisabled" + ], + "RuleIds": [] + }, + { + "Id": "live-routing", + "AreaId": "location", + "LabelKey": "Feature.live-routing", + "PurposeKey": "FeaturePurpose.live-routing", + "ValueKey": "AreaValue.location", + "ExampleKey": "AreaExample.location", + "AdoptionKey": "FeatureAdoption.live-routing", + "ReleaseStatus": "available", + "Location": { + "Controller": "Mapping", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "module", + "Id": "Mapping" + } + ], + "SettingIds": [ + "module.MappingDisabled" + ], + "RuleIds": [] + }, + { + "Id": "unit-types", + "AreaId": "location", + "LabelKey": "Feature.unit-types", + "PurposeKey": "FeaturePurpose.unit-types", + "ValueKey": "FeatureValue.unit-types", + "ExampleKey": "AreaExample.location", + "AdoptionKey": "FeatureAdoption.unit-types", + "ReleaseStatus": "available", + "Location": { + "Controller": "Department", + "Action": "Types" + }, + "Requirements": [], + "SettingIds": [], + "RuleIds": [] + }, + { + "Id": "hardware-tracking", + "AreaId": "location", + "LabelKey": "Feature.hardware-tracking", + "PurposeKey": "FeaturePurpose.hardware-tracking", + "ValueKey": "FeatureValue.hardware-tracking", + "ExampleKey": "AreaExample.location", + "AdoptionKey": "FeatureAdoption.hardware-tracking", + "ReleaseStatus": "available", + "Location": { + "Controller": "UnitTracking", + "Action": "Index" + }, + "Requirements": [], + "SettingIds": [], + "RuleIds": [] + }, + { + "Id": "custom-maps", + "AreaId": "location", + "LabelKey": "Feature.custom-maps", + "PurposeKey": "FeaturePurpose.custom-maps", + "ValueKey": "FeatureValue.custom-maps", + "ExampleKey": "AreaExample.location", + "AdoptionKey": "FeatureAdoption.custom-maps", + "ReleaseStatus": "available", + "Location": { + "Controller": "CustomMaps", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "module", + "Id": "Mapping" + } + ], + "SettingIds": [ + "module.MappingDisabled" + ], + "RuleIds": [] + }, + { + "Id": "indoor-maps", + "AreaId": "location", + "LabelKey": "Feature.indoor-maps", + "PurposeKey": "FeaturePurpose.indoor-maps", + "ValueKey": "FeatureValue.indoor-maps", + "ExampleKey": "AreaExample.location", + "AdoptionKey": "FeatureAdoption.indoor-maps", + "ReleaseStatus": "available", + "Location": { + "Controller": "IndoorMaps", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "module", + "Id": "Mapping" + } + ], + "SettingIds": [ + "module.MappingDisabled" + ], + "RuleIds": [] + }, + { + "Id": "routes", + "AreaId": "location", + "LabelKey": "Feature.routes", + "PurposeKey": "FeaturePurpose.routes", + "ValueKey": "FeatureValue.routes", + "ExampleKey": "AreaExample.location", + "AdoptionKey": "FeatureAdoption.routes", + "ReleaseStatus": "available", + "Location": { + "Controller": "Routes", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "module", + "Id": "Mapping" + }, + { + "Kind": "permission", + "Id": "ManageRoutes" + } + ], + "SettingIds": [ + "module.MappingDisabled", + "permission.ManageRoutes" + ], + "RuleIds": [] + }, + { + "Id": "weather", + "AreaId": "location", + "LabelKey": "Feature.weather", + "PurposeKey": "FeaturePurpose.weather", + "ValueKey": "FeatureValue.weather", + "ExampleKey": "AreaExample.location", + "AdoptionKey": "FeatureAdoption.weather", + "ReleaseStatus": "available", + "Location": { + "Controller": "WeatherAlerts", + "Action": "Index" + }, + "Requirements": [], + "SettingIds": [], + "RuleIds": [ + "weather-zones" + ], + "Setup": { + "EvidenceId": "weatherZoneCount", + "Minimum": 1, + "RuleIds": [ + "weather-zones" + ], + "GuidanceKey": "SetupEvidence.weather" + } + } + ], + "Rules": [ + { + "Id": "unit-type", + "AreaId": "location", + "Severity": "Warning", + "TitleKey": "Rule.unit-type", + "ExplanationKey": "RuleWhy.unit-type", + "NextActionKey": "RuleNext.unit-type", + "Location": { + "Controller": "Units", + "Action": "Index" + }, + "AppliesWhen": [], + "FailsWhen": [ + { + "EvidenceId": "unitsWithoutType", + "Comparison": "Greater", + "Number": 0 + } + ] + }, + { + "Id": "unit-group", + "AreaId": "location", + "Severity": "Warning", + "TitleKey": "Rule.unit-group", + "ExplanationKey": "RuleWhy.unit-group", + "NextActionKey": "RuleNext.unit-group", + "Location": { + "Controller": "Units", + "Action": "Index" + }, + "AppliesWhen": [], + "FailsWhen": [ + { + "EvidenceId": "unitsWithoutGroup", + "Comparison": "Greater", + "Number": 0 + } + ] + }, + { + "Id": "station-address", + "AreaId": "location", + "Severity": "Warning", + "TitleKey": "Rule.station-address", + "ExplanationKey": "RuleWhy.station-address", + "NextActionKey": "RuleNext.station-address", + "Location": { + "Controller": "Groups", + "Action": "Index" + }, + "AppliesWhen": [], + "FailsWhen": [ + { + "EvidenceId": "stationsWithoutLocation", + "Comparison": "Greater", + "Number": 0 + } + ] + }, + { + "Id": "person-location-age", + "AreaId": "location", + "Severity": "Warning", + "TitleKey": "Rule.person-location-age", + "ExplanationKey": "RuleWhy.person-location-age", + "NextActionKey": "RuleNext.person-location-age", + "Location": { + "Controller": "Department", + "Action": "MappingSettings" + }, + "AppliesWhen": [], + "FailsWhen": [ + { + "EvidenceId": "MappingPersonnelLocationTTL", + "Comparison": "Equal", + "Number": 0 + } + ] + }, + { + "Id": "unit-location-age", + "AreaId": "location", + "Severity": "Warning", + "TitleKey": "Rule.unit-location-age", + "ExplanationKey": "RuleWhy.unit-location-age", + "NextActionKey": "RuleNext.unit-location-age", + "Location": { + "Controller": "Department", + "Action": "MappingSettings" + }, + "AppliesWhen": [], + "FailsWhen": [ + { + "EvidenceId": "MappingUnitLocationTTL", + "Comparison": "Equal", + "Number": 0 + } + ] + }, + { + "Id": "map-token", + "AreaId": "location", + "Severity": "Warning", + "TitleKey": "Rule.map-token", + "ExplanationKey": "RuleWhy.map-token", + "NextActionKey": "RuleNext.map-token", + "Location": { + "Controller": "Department", + "Action": "MappingSettings" + }, + "AppliesWhen": [ + { + "EvidenceId": "MappingUseMapboxOverride", + "Comparison": "IsTrue", + "Number": null + } + ], + "FailsWhen": [ + { + "EvidenceId": "mapTokenPresent", + "Comparison": "IsFalse", + "Number": null + } + ] + }, + { + "Id": "map-style", + "AreaId": "location", + "Severity": "Warning", + "TitleKey": "Rule.map-style", + "ExplanationKey": "RuleWhy.map-style", + "NextActionKey": "RuleNext.map-style", + "Location": { + "Controller": "Department", + "Action": "MappingSettings" + }, + "AppliesWhen": [ + { + "EvidenceId": "MappingUseMapboxOverride", + "Comparison": "IsTrue", + "Number": null + } + ], + "FailsWhen": [ + { + "EvidenceId": "mapStylePresent", + "Comparison": "IsFalse", + "Number": null + } + ] + }, + { + "Id": "weather-zones", + "AreaId": "location", + "Severity": "Warning", + "TitleKey": "Rule.weather-zones", + "ExplanationKey": "RuleWhy.weather-zones", + "NextActionKey": "RuleNext.weather-zones", + "Location": { + "Controller": "WeatherAlerts", + "Action": "Index" + }, + "AppliesWhen": [ + { + "EvidenceId": "WeatherAlertsEnabled", + "Comparison": "IsTrue", + "Number": null + } + ], + "FailsWhen": [ + { + "EvidenceId": "weatherZoneCount", + "Comparison": "Equal", + "Number": 0 + } + ] + }, + { + "Id": "site-references", + "AreaId": "location", + "Severity": "Warning", + "TitleKey": "Rule.site-references", + "ExplanationKey": "RuleWhy.site-references", + "NextActionKey": "RuleNext.site-references", + "Location": { + "Controller": "Department", + "Action": "OperatingProfile" + }, + "AppliesWhen": [ + { + "EvidenceId": "declaredSiteReferences", + "Comparison": "Greater", + "Number": 0 + } + ], + "FailsWhen": [ + { + "EvidenceId": "unavailableSiteReferences", + "Comparison": "Greater", + "Number": 0 + } + ] + } + ], + "Packs": [], + "Articles": [ + { + "Id": "setting.UnitsSortOrder", + "Locale": "en", + "TitleKey": "Setting.UnitsSortOrder", + "Body": "Units Sort Order. Sort order for unit lists; does not change unit eligibility or dispatch recommendation ranking.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-unitssortorder", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.MappingPersonnelLocationTTL", + "Locale": "en", + "TitleKey": "Setting.MappingPersonnelLocationTTL", + "Body": "Mapping Personnel Location TTL. Maximum personnel location age in minutes. Zero keeps stale positions visible without an age limit; assess actual ping ages.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-mappingpersonnellocationttl", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.MappingUnitLocationTTL", + "Locale": "en", + "TitleKey": "Setting.MappingUnitLocationTTL", + "Body": "Mapping Unit Location TTL. Maximum unit location age in minutes. Zero retains stale unit locations indefinitely in supported map consumers.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-mappingunitlocationttl", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.MappingPersonnelAllowStatusWithNoLocationToOverwrite", + "Locale": "en", + "TitleKey": "Setting.MappingPersonnelAllowStatusWithNoLocationToOverwrite", + "Body": "Mapping Personnel Allow Status With No Location To Overwrite. Allow a personnel status without a location to replace the previous location-bearing state. This can remove a marker.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-mappingpersonnelallowstatuswithnolocationtooverwrite", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.MappingUnitAllowStatusWithNoLocationToOverwrite", + "Locale": "en", + "TitleKey": "Setting.MappingUnitAllowStatusWithNoLocationToOverwrite", + "Body": "Mapping Unit Allow Status With No Location To Overwrite. Allow a unit status without a location to overwrite earlier location data. Check hardware and app updates together.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-mappingunitallowstatuswithnolocationtooverwrite", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.PersonnelOnUnitSetUnitStatus", + "Locale": "en", + "TitleKey": "Setting.PersonnelOnUnitSetUnitStatus", + "Body": "Personnel On Unit Set Unit Status. Allow personnel status updates to influence their assigned unit status. Review automation and crew assignment together.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-personnelonunitsetunitstatus", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.WeatherAlertsEnabled", + "Locale": "en", + "TitleKey": "Setting.WeatherAlertsEnabled", + "Body": "Weather Alerts Enabled. Enable supported weather-alert processing. Configure zones and communication behavior before relying on it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-weatheralertsenabled", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.WeatherAlertMinimumSeverity", + "Locale": "en", + "TitleKey": "Setting.WeatherAlertMinimumSeverity", + "Body": "Weather Alert Minimum Severity. Minimum severity eligible for the weather-alert view. Severity values come from the weather service, not local incident triage.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-weatheralertminimumseverity", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.WeatherAlertAutoMessageSeverity", + "Locale": "en", + "TitleKey": "Setting.WeatherAlertAutoMessageSeverity", + "Body": "Weather Alert Auto Message Severity. Minimum weather severity eligible for automatic messages. Review recipients, schedule and duplicate suppression.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-weatheralertautomessageseverity", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.WeatherAlertCallIntegration", + "Locale": "en", + "TitleKey": "Setting.WeatherAlertCallIntegration", + "Body": "Weather Alert Call Integration. Include supported weather context in call integration. It does not certify weather or route safety.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-weatheralertcallintegration", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.WeatherAlertCacheMinutes", + "Locale": "en", + "TitleKey": "Setting.WeatherAlertCacheMinutes", + "Body": "Weather Alert Cache Minutes. Weather cache duration; longer caching changes freshness and provider load.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-weatheralertcacheminutes", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.WeatherAlertAutoMessageSchedule", + "Locale": "en", + "TitleKey": "Setting.WeatherAlertAutoMessageSchedule", + "Body": "Weather Alert Auto Message Schedule. Schedule for automatic weather messaging. Review the department time zone, overnight periods and daylight-saving changes.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-weatheralertautomessageschedule", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.WeatherAlertExcludedEvents", + "Locale": "en", + "TitleKey": "Setting.WeatherAlertExcludedEvents", + "Body": "Weather Alert Excluded Events. Weather event types excluded from configured processing. Review exclusions against approved local procedures.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-weatheralertexcludedevents", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.MappingUseMapboxOverride", + "Locale": "en", + "TitleKey": "Setting.MappingUseMapboxOverride", + "Body": "Mapping Use Mapbox Override. Use the department map provider override. Turning it off on the owning screen removes the stored style and token.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-mappingusemapboxoverride", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.MappingMapboxStyleUrl", + "Locale": "en", + "TitleKey": "Setting.MappingMapboxStyleUrl", + "Body": "Mapping Mapbox Style Url. Custom map style reference. Verify the provider style and credential together; disabling the override deletes this value.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-mappingmapboxstyleurl", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.MappingMapboxAccessToken", + "Locale": "en", + "TitleKey": "Setting.MappingMapboxAccessToken", + "Body": "Mapping Mapbox Access Token. Map-provider access token. Only presence is reported. Disabling the override deletes it and requires re-entry to restore.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-mappingmapboxaccesstoken", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.HardwareTrackingStaleAfterSeconds", + "Locale": "en", + "TitleKey": "Setting.HardwareTrackingStaleAfterSeconds", + "Body": "Hardware Tracking Stale After Seconds. Age at which a hardware location becomes stale, clamped to at least one second. Review device reporting intervals.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-hardwaretrackingstaleafterseconds", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.HardwareTrackingMobileFallbackEnabled", + "Locale": "en", + "TitleKey": "Setting.HardwareTrackingMobileFallbackEnabled", + "Body": "Hardware Tracking Mobile Fallback Enabled. Use supported mobile location fallback when hardware data is stale. Verify each device/source rather than assuming continuous tracking.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-hardwaretrackingmobilefallbackenabled", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.HardwareTrackingLocationRetentionDays", + "Locale": "en", + "TitleKey": "Setting.HardwareTrackingLocationRetentionDays", + "Body": "Hardware Tracking Location Retention Days. Tracking retention bounded by host configuration and applicable holds. Shortening retention can make data eligible for deletion.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-hardwaretrackinglocationretentiondays", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.UnitStatusThresholds", + "Locale": "en", + "TitleKey": "Setting.UnitStatusThresholds", + "Body": "Unit Status Thresholds. Visual warning thresholds for time in a unit status. A highlight does not change the unit status or certify availability.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-unitstatusthresholds", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "module.MappingDisabled", + "Locale": "en", + "TitleKey": "Module.MappingDisabled", + "Body": "Mapping availability. Controls availability of maps and location views. Disabling this module can remove navigation and block module operations; it does not delete its records. Turning it on still requires the applicable subscription, rollout and permissions.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "module-mappingdisabled", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "module.MappingNameOverride", + "Locale": "en", + "TitleKey": "Module.MappingNameOverride", + "Body": "Mapping menu name. Optional display name for maps and location views in consumers that support the override. It changes the label, not permissions or functionality. This release has no separate name-override control in Module Settings.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "module-mappingnameoverride", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "field.UnitStatusThresholds.Thresholds", + "Locale": "en", + "TitleKey": "Field.UnitStatusThresholds.Thresholds", + "Body": "Unit Status Thresholds / Thresholds. Board highlighting thresholds grouped by base status meaning. An empty list disables highlighting.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-unitstatusthresholds-thresholds", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "field.UnitStatusThreshold.BaseType", + "Locale": "en", + "TitleKey": "Field.UnitStatusThreshold.BaseType", + "Body": "Unit Status Threshold / Base Type. Base status meaning for a threshold, independent of custom status names and colors.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-unitstatusthreshold-basetype", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "field.UnitStatusThreshold.WarnSeconds", + "Locale": "en", + "TitleKey": "Field.UnitStatusThreshold.WarnSeconds", + "Body": "Unit Status Threshold / Warn Seconds. Seconds in this status before a warning highlight; zero disables it. An alert at or before this threshold makes the row alert-only.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-unitstatusthreshold-warnseconds", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "field.UnitStatusThreshold.AlertSeconds", + "Locale": "en", + "TitleKey": "Field.UnitStatusThreshold.AlertSeconds", + "Body": "Unit Status Threshold / Alert Seconds. Seconds in this status before a higher-priority highlight; zero disables it. Highlighting does not send a page or change status.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-unitstatusthreshold-alertseconds", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.Department.AddressId", + "Locale": "en", + "TitleKey": "TableField.Department.AddressId", + "Body": "Department / Address Id. Department address reference. Review the owning address and mapping consumers; it is separate from station and site addresses.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-department-addressid", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.WeatherAlertZone.Name", + "Locale": "en", + "TitleKey": "TableField.WeatherAlertZone.Name", + "Body": "Weather Alert Zone / Name. Administrator-facing label for this weather zone. The name does not define its geographic coverage.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-weatheralertzone-name", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.WeatherAlertZone.ZoneCode", + "Locale": "en", + "TitleKey": "TableField.WeatherAlertZone.ZoneCode", + "Body": "Weather Alert Zone / Zone Code. Provider weather-zone identifier. Verify the intended jurisdiction and source coverage.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-weatheralertzone-zonecode", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.WeatherAlertZone.CenterGeoLocation", + "Locale": "en", + "TitleKey": "TableField.WeatherAlertZone.CenterGeoLocation", + "Body": "Weather Alert Zone / Center Geo Location. Center point used by supported radius-based weather coverage. Coordinate values stay on their owning map/editor.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-weatheralertzone-centergeolocation", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.WeatherAlertZone.RadiusMiles", + "Locale": "en", + "TitleKey": "TableField.WeatherAlertZone.RadiusMiles", + "Body": "Weather Alert Zone / Radius Miles. Radius in miles for supported geographic matching. Review actual coverage and provider behavior; larger is not automatically safer.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-weatheralertzone-radiusmiles", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.WeatherAlertZone.IsActive", + "Locale": "en", + "TitleKey": "TableField.WeatherAlertZone.IsActive", + "Body": "Weather Alert Zone / Is Active. Whether the zone participates in supported weather processing. Department weather settings and worker operation are additional requirements.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-weatheralertzone-isactive", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.WeatherAlertZone.IsPrimary", + "Locale": "en", + "TitleKey": "TableField.WeatherAlertZone.IsPrimary", + "Body": "Weather Alert Zone / Is Primary. Marks the primary zone for consumers that select a default. Review other active zones rather than assuming they are disabled.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-weatheralertzone-isprimary", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "units", + "Locale": "en", + "TitleKey": "Feature.units", + "Body": "Units Apparatus, vehicles and teams Make resource information easier to find and show when positions are stale. Review an apparatus location and its last update before relying on the map. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "units", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "new-unit", + "Locale": "en", + "TitleKey": "Feature.new-unit", + "Body": "New Unit Add an apparatus, vehicle or team Make resource information easier to find and show when positions are stale. Review an apparatus location and its last update before relying on the map. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "new-unit", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "unit-staffing", + "Locale": "en", + "TitleKey": "Feature.unit-staffing", + "Body": "Unit Staffing Assign personnel to units Make resource information easier to find and show when positions are stale. Review an apparatus location and its last update before relying on the map. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "unit-staffing", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "mapping", + "Locale": "en", + "TitleKey": "Feature.mapping", + "Body": "Mapping Large map with layers, personnel and units Make resource information easier to find and show when positions are stale. Review an apparatus location and its last update before relying on the map. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "mapping", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "pois", + "Locale": "en", + "TitleKey": "Feature.pois", + "Body": "Points of Interest Manage map points of interest Make resource information easier to find and show when positions are stale. Review an apparatus location and its last update before relying on the map. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "pois", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "map-layers", + "Locale": "en", + "TitleKey": "Feature.map-layers", + "Body": "Map Layers Manage map layers Make resource information easier to find and show when positions are stale. Review an apparatus location and its last update before relying on the map. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "map-layers", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "live-routing", + "Locale": "en", + "TitleKey": "Feature.live-routing", + "Body": "Live Routing Routing and directions for active resources Make resource information easier to find and show when positions are stale. Review an apparatus location and its last update before relying on the map. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "live-routing", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "unit-types", + "Locale": "en", + "TitleKey": "Feature.unit-types", + "Body": "Unit types Configure apparatus and resource types and their supported status behavior. Group comparable resources without assuming staffing or qualification. Review an apparatus location and its last update before relying on the map. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "unit-types", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "hardware-tracking", + "Locale": "en", + "TitleKey": "Feature.hardware-tracking", + "Body": "Hardware location tracking Associate supported tracking devices with department resources. Review device ownership, stale-position behavior and fallback sources. Review an apparatus location and its last update before relying on the map. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "hardware-tracking", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "custom-maps", + "Locale": "en", + "TitleKey": "Feature.custom-maps", + "Body": "Custom map layers Manage supported custom map content for the department. Give responders useful reference layers with an identified owner and update process. Review an apparatus location and its last update before relying on the map. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "custom-maps", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "indoor-maps", + "Locale": "en", + "TitleKey": "Feature.indoor-maps", + "Body": "Indoor maps Organize indoor map references and zones where supported. Keep building references available to authorized users without inferring safe routes. Review an apparatus location and its last update before relying on the map. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "indoor-maps", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "routes", + "Locale": "en", + "TitleKey": "Feature.routes", + "Body": "Routes Manage configured routes and supported route assignments. Prepare recurring route information with current source ownership. Review an apparatus location and its last update before relying on the map. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "routes", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "weather", + "Locale": "en", + "TitleKey": "Feature.weather", + "Body": "Weather zones and alerts Configure monitored zones and weather alert processing. Review alert coverage and automatic-message settings before use. Review an apparatus location and its last update before relying on the map. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "weather", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + } + ] +} diff --git a/Core/Resgrid.AdminAssist/Catalog/maintenance.yaml b/Core/Resgrid.AdminAssist/Catalog/maintenance.yaml new file mode 100644 index 000000000..fc1dc1e4b --- /dev/null +++ b/Core/Resgrid.AdminAssist/Catalog/maintenance.yaml @@ -0,0 +1,581 @@ +{ + "Version": "2026.09.24.1", + "Areas": [ + { + "Id": "maintenance", + "LabelKey": "Area.maintenance", + "PurposeKey": "AreaPurpose.maintenance", + "Order": 7, + "Archetypes": [] + } + ], + "Settings": [ + { + "Id": "module.MaintenanceDisabled", + "AreaId": "maintenance", + "LabelKey": "Module.MaintenanceDisabled", + "HelpKey": "ModuleHelp.MaintenanceDisabled", + "Binding": "DepartmentModuleSettings.MaintenanceDisabled", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "ModuleSettings", + "Field": "MaintenanceEnabled" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.maintenance", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.ModuleSettings" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "module.MaintenanceDisabled", + "DefaultValue": "false", + "AllowedValues": "true or false", + "EvidenceSource": "DepartmentSettingsService.GetDepartmentModuleSettingsAsync", + "Availability": "available" + }, + { + "Id": "module.MaintenanceNameOverride", + "AreaId": "maintenance", + "LabelKey": "Module.MaintenanceNameOverride", + "HelpKey": "ModuleHelp.MaintenanceNameOverride", + "Binding": "DepartmentModuleSettings.MaintenanceNameOverride", + "ValueType": "string", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "ModuleSettings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.maintenance", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.ModuleSettings" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "module.MaintenanceNameOverride", + "DefaultValue": "unset", + "AllowedValues": "owning serializer and consuming UI", + "EvidenceSource": "DepartmentSettingsService.GetDepartmentModuleSettingsAsync", + "Availability": "reference-only" + }, + { + "Id": "module.ChecklistsDisabled", + "AreaId": "maintenance", + "LabelKey": "Module.ChecklistsDisabled", + "HelpKey": "ModuleHelp.ChecklistsDisabled", + "Binding": "DepartmentModuleSettings.ChecklistsDisabled", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "ModuleSettings", + "Field": "ChecklistsEnabled" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.maintenance", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.ModuleSettings" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "module.ChecklistsDisabled", + "DefaultValue": "false", + "AllowedValues": "true or false", + "EvidenceSource": "DepartmentSettingsService.GetDepartmentModuleSettingsAsync", + "Availability": "available" + } + ], + "Capabilities": [ + { + "Id": "checklists", + "AreaId": "maintenance", + "LabelKey": "Feature.checklists", + "PurposeKey": "FeaturePurpose.checklists", + "ValueKey": "AreaValue.maintenance", + "ExampleKey": "AreaExample.maintenance", + "AdoptionKey": "FeatureAdoption.checklists", + "ReleaseStatus": "available", + "Location": { + "Controller": "Checklists", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "flag", + "Id": "Checklists.System" + } + ], + "SettingIds": [], + "RuleIds": [ + "checklist-overdue" + ] + }, + { + "Id": "new-checklist", + "AreaId": "maintenance", + "LabelKey": "Feature.new-checklist", + "PurposeKey": "FeaturePurpose.new-checklist", + "ValueKey": "AreaValue.maintenance", + "ExampleKey": "AreaExample.maintenance", + "AdoptionKey": "FeatureAdoption.new-checklist", + "ReleaseStatus": "available", + "Location": { + "Controller": "Checklists", + "Action": "New" + }, + "Requirements": [ + { + "Kind": "flag", + "Id": "Checklists.System" + } + ], + "SettingIds": [], + "RuleIds": [] + }, + { + "Id": "checklist-templates", + "AreaId": "maintenance", + "LabelKey": "Feature.checklist-templates", + "PurposeKey": "FeaturePurpose.checklist-templates", + "ValueKey": "AreaValue.maintenance", + "ExampleKey": "AreaExample.maintenance", + "AdoptionKey": "FeatureAdoption.checklist-templates", + "ReleaseStatus": "available", + "Location": { + "Controller": "Checklists", + "Action": "Templates" + }, + "Requirements": [ + { + "Kind": "flag", + "Id": "Checklists.System" + } + ], + "SettingIds": [], + "RuleIds": [] + }, + { + "Id": "work-orders", + "AreaId": "maintenance", + "LabelKey": "Feature.work-orders", + "PurposeKey": "FeaturePurpose.work-orders", + "ValueKey": "AreaValue.maintenance", + "ExampleKey": "AreaExample.maintenance", + "AdoptionKey": "FeatureAdoption.work-orders", + "ReleaseStatus": "available", + "Location": { + "Controller": "WorkOrders", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "flag", + "Id": "Maintenance.WorkOrders" + }, + { + "Kind": "module", + "Id": "Maintenance" + }, + { + "Kind": "addon", + "Id": "ReadinessPro" + } + ], + "SettingIds": [ + "module.MaintenanceDisabled" + ], + "RuleIds": [ + "equipment-holds" + ] + }, + { + "Id": "new-work-order", + "AreaId": "maintenance", + "LabelKey": "Feature.new-work-order", + "PurposeKey": "FeaturePurpose.new-work-order", + "ValueKey": "AreaValue.maintenance", + "ExampleKey": "AreaExample.maintenance", + "AdoptionKey": "FeatureAdoption.new-work-order", + "ReleaseStatus": "available", + "Location": { + "Controller": "WorkOrders", + "Action": "New" + }, + "Requirements": [ + { + "Kind": "flag", + "Id": "Maintenance.WorkOrders" + }, + { + "Kind": "module", + "Id": "Maintenance" + }, + { + "Kind": "addon", + "Id": "ReadinessPro" + } + ], + "SettingIds": [ + "module.MaintenanceDisabled" + ], + "RuleIds": [] + }, + { + "Id": "checklist-schedules", + "AreaId": "maintenance", + "LabelKey": "Feature.checklist-schedules", + "PurposeKey": "FeaturePurpose.checklist-schedules", + "ValueKey": "FeatureValue.checklist-schedules", + "ExampleKey": "AreaExample.maintenance", + "AdoptionKey": "FeatureAdoption.checklist-schedules", + "ReleaseStatus": "available", + "Location": { + "Controller": "Checklists", + "Action": "Due" + }, + "Requirements": [ + { + "Kind": "module", + "Id": "Checklists" + } + ], + "SettingIds": [ + "module.ChecklistsDisabled" + ], + "RuleIds": [] + }, + { + "Id": "checklist-reports", + "AreaId": "maintenance", + "LabelKey": "Feature.checklist-reports", + "PurposeKey": "FeaturePurpose.checklist-reports", + "ValueKey": "FeatureValue.checklist-reports", + "ExampleKey": "AreaExample.maintenance", + "AdoptionKey": "FeatureAdoption.checklist-reports", + "ReleaseStatus": "available", + "Location": { + "Controller": "Checklists", + "Action": "Compliance" + }, + "Requirements": [ + { + "Kind": "module", + "Id": "Checklists" + } + ], + "SettingIds": [ + "module.ChecklistsDisabled" + ], + "RuleIds": [] + }, + { + "Id": "preventive-maintenance", + "AreaId": "maintenance", + "LabelKey": "Feature.preventive-maintenance", + "PurposeKey": "FeaturePurpose.preventive-maintenance", + "ValueKey": "FeatureValue.preventive-maintenance", + "ExampleKey": "AreaExample.maintenance", + "AdoptionKey": "FeatureAdoption.preventive-maintenance", + "ReleaseStatus": "available", + "Location": { + "Controller": "WorkOrders", + "Action": "Recurrences" + }, + "Requirements": [ + { + "Kind": "addon", + "Id": "ReadinessPro" + }, + { + "Kind": "module", + "Id": "Maintenance" + } + ], + "SettingIds": [ + "module.MaintenanceDisabled" + ], + "RuleIds": [] + }, + { + "Id": "maintenance-reports", + "AreaId": "maintenance", + "LabelKey": "Feature.maintenance-reports", + "PurposeKey": "FeaturePurpose.maintenance-reports", + "ValueKey": "FeatureValue.maintenance-reports", + "ExampleKey": "AreaExample.maintenance", + "AdoptionKey": "FeatureAdoption.maintenance-reports", + "ReleaseStatus": "available", + "Location": { + "Controller": "WorkOrders", + "Action": "Reports" + }, + "Requirements": [ + { + "Kind": "addon", + "Id": "ReadinessPro" + }, + { + "Kind": "module", + "Id": "Maintenance" + } + ], + "SettingIds": [ + "module.MaintenanceDisabled" + ], + "RuleIds": [] + }, + { + "Id": "maintenance-policy", + "AreaId": "maintenance", + "LabelKey": "Feature.maintenance-policy", + "PurposeKey": "FeaturePurpose.maintenance-policy", + "ValueKey": "FeatureValue.maintenance-policy", + "ExampleKey": "AreaExample.maintenance", + "AdoptionKey": "FeatureAdoption.maintenance-policy", + "ReleaseStatus": "available", + "Location": { + "Controller": "WorkOrders", + "Action": "Settings" + }, + "Requirements": [ + { + "Kind": "addon", + "Id": "ReadinessPro" + }, + { + "Kind": "module", + "Id": "Maintenance" + } + ], + "SettingIds": [ + "module.MaintenanceDisabled" + ], + "RuleIds": [] + } + ], + "Rules": [ + { + "Id": "checklist-overdue", + "AreaId": "maintenance", + "Severity": "Warning", + "TitleKey": "Rule.checklist-overdue", + "ExplanationKey": "RuleWhy.checklist-overdue", + "NextActionKey": "RuleNext.checklist-overdue", + "Location": { + "Controller": "Checklists", + "Action": "Index" + }, + "AppliesWhen": [], + "FailsWhen": [ + { + "EvidenceId": "overdueChecklistCount", + "Comparison": "Greater", + "Number": 0 + } + ] + }, + { + "Id": "equipment-holds", + "AreaId": "maintenance", + "Severity": "Critical", + "TitleKey": "Rule.equipment-holds", + "ExplanationKey": "RuleWhy.equipment-holds", + "NextActionKey": "RuleNext.equipment-holds", + "Location": { + "Controller": "WorkOrders", + "Action": "Index" + }, + "AppliesWhen": [ + { + "EvidenceId": "maintenanceAvailable", + "Comparison": "IsTrue", + "Number": null + } + ], + "FailsWhen": [ + { + "EvidenceId": "activeSafetyHoldCount", + "Comparison": "Greater", + "Number": 0 + } + ] + } + ], + "Packs": [], + "Articles": [ + { + "Id": "module.MaintenanceDisabled", + "Locale": "en", + "TitleKey": "Module.MaintenanceDisabled", + "Body": "Maintenance availability. Controls availability of maintenance and work orders. Disabling this module can remove navigation and block module operations; it does not delete its records. Turning it on still requires the applicable subscription, rollout and permissions.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "module-maintenancedisabled", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "module.MaintenanceNameOverride", + "Locale": "en", + "TitleKey": "Module.MaintenanceNameOverride", + "Body": "Maintenance menu name. Optional display name for maintenance and work orders in consumers that support the override. It changes the label, not permissions or functionality. This release has no separate name-override control in Module Settings.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "module-maintenancenameoverride", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "module.ChecklistsDisabled", + "Locale": "en", + "TitleKey": "Module.ChecklistsDisabled", + "Body": "Checklists availability. Controls availability of checklists. Disabling this module can remove navigation and block module operations; it does not delete its records. Turning it on still requires the applicable subscription, rollout and permissions.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "module-checklistsdisabled", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "checklists", + "Locale": "en", + "TitleKey": "Feature.checklists", + "Body": "Checklists Apparatus, station and readiness checklists Connect recorded equipment checks with owned corrective work when maintenance is enabled. Record an equipment defect, assign repair and review return to service through the owning module. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "checklists", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "new-checklist", + "Locale": "en", + "TitleKey": "Feature.new-checklist", + "Body": "New Checklist Author a checklist definition Connect recorded equipment checks with owned corrective work when maintenance is enabled. Record an equipment defect, assign repair and review return to service through the owning module. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "new-checklist", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "checklist-templates", + "Locale": "en", + "TitleKey": "Feature.checklist-templates", + "Body": "Checklist Templates Start from a checklist template Connect recorded equipment checks with owned corrective work when maintenance is enabled. Record an equipment defect, assign repair and review return to service through the owning module. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "checklist-templates", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "work-orders", + "Locale": "en", + "TitleKey": "Feature.work-orders", + "Body": "Work Orders Maintenance and repair work orders Connect recorded equipment checks with owned corrective work when maintenance is enabled. Record an equipment defect, assign repair and review return to service through the owning module. Checklists do not require Readiness Pro. Maintenance requires the add-on, module access and reviewed repair and approval procedures.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "work-orders", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "new-work-order", + "Locale": "en", + "TitleKey": "Feature.new-work-order", + "Body": "New Work Order Open a maintenance work order Connect recorded equipment checks with owned corrective work when maintenance is enabled. Record an equipment defect, assign repair and review return to service through the owning module. Checklists do not require Readiness Pro. Maintenance requires the add-on, module access and reviewed repair and approval procedures.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "new-work-order", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "checklist-schedules", + "Locale": "en", + "TitleKey": "Feature.checklist-schedules", + "Body": "Checklist schedules and due work Assign recurring checks and review due occurrences. Make recorded equipment and procedure checks visible to their owners. Record an equipment defect, assign repair and review return to service through the owning module. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "checklist-schedules", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "checklist-reports", + "Locale": "en", + "TitleKey": "Feature.checklist-reports", + "Body": "Checklist compliance reports Review expected, completed, skipped and overdue checks. Find missing recorded checks without treating a report as safety certification. Record an equipment defect, assign repair and review return to service through the owning module. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "checklist-reports", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "preventive-maintenance", + "Locale": "en", + "TitleKey": "Feature.preventive-maintenance", + "Body": "Preventive maintenance Configure recurring maintenance work and due tasks. Plan equipment upkeep and track corrective responsibility. Record an equipment defect, assign repair and review return to service through the owning module. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "preventive-maintenance", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "maintenance-reports", + "Locale": "en", + "TitleKey": "Feature.maintenance-reports", + "Body": "Maintenance history and reports Review maintenance work, recorded holds and repair history. Support accountable return-to-service review through the owning workflow. Record an equipment defect, assign repair and review return to service through the owning module. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "maintenance-reports", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "maintenance-policy", + "Locale": "en", + "TitleKey": "Feature.maintenance-policy", + "Body": "Maintenance policies and approvals Configure supported work-order policies and approval requirements. Make authority and repair completion requirements explicit. Record an equipment defect, assign repair and review return to service through the owning module. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "maintenance-policy", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + } + ] +} diff --git a/Core/Resgrid.AdminAssist/Catalog/people.yaml b/Core/Resgrid.AdminAssist/Catalog/people.yaml new file mode 100644 index 000000000..0d9ce87eb --- /dev/null +++ b/Core/Resgrid.AdminAssist/Catalog/people.yaml @@ -0,0 +1,1403 @@ +{ + "Version": "2026.09.24.1", + "Areas": [ + { + "Id": "people", + "LabelKey": "Area.people", + "PurposeKey": "AreaPurpose.people", + "Order": 2, + "Archetypes": [] + } + ], + "Settings": [ + { + "Id": "setting.DisabledAutoAvailable", + "AreaId": "people", + "LabelKey": "Setting.DisabledAutoAvailable", + "HelpKey": "SettingHelp.DisabledAutoAvailable", + "Binding": "DepartmentSettingTypes.DisabledAutoAvailable", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "Settings", + "Field": "DisabledAutoAvailable" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.people", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.DisabledAutoAvailable", + "DefaultValue": "false", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.PersonnelSortOrder", + "AreaId": "people", + "LabelKey": "Setting.PersonnelSortOrder", + "HelpKey": "SettingHelp.PersonnelSortOrder", + "Binding": "DepartmentSettingTypes.PersonnelSortOrder", + "ValueType": "enum", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "Settings", + "Field": "PersonnelSortOrder" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.people", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.PersonnelSortOrder", + "DefaultValue": "owning view default", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.PersonnelListStatusSortOrder", + "AreaId": "people", + "LabelKey": "Setting.PersonnelListStatusSortOrder", + "HelpKey": "SettingHelp.PersonnelListStatusSortOrder", + "Binding": "DepartmentSettingTypes.PersonnelListStatusSortOrder", + "ValueType": "protobuf", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "Settings", + "Field": "PersonnelListStatusSortOrder" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.people", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.PersonnelListStatusSortOrder", + "DefaultValue": "empty ordering", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.AllowSignupsForMultipleShiftGroups", + "AreaId": "people", + "LabelKey": "Setting.AllowSignupsForMultipleShiftGroups", + "HelpKey": "SettingHelp.AllowSignupsForMultipleShiftGroups", + "Binding": "DepartmentSettingTypes.AllowSignupsForMultipleShiftGroups", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "ShiftSettings", + "Field": "AllowSignupsForMultipleShiftGroups" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.people", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.AllowSignupsForMultipleShiftGroups", + "DefaultValue": "false", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.StaffingSuppressStaffingLevels", + "AreaId": "people", + "LabelKey": "Setting.StaffingSuppressStaffingLevels", + "HelpKey": "SettingHelp.StaffingSuppressStaffingLevels", + "Binding": "DepartmentSettingTypes.StaffingSuppressStaffingLevels", + "ValueType": "protobuf", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "Settings", + "Field": "StaffingSuppressStaffingLevels" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.people", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.StaffingSuppressStaffingLevels", + "DefaultValue": "empty suppression list", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "module.ShiftsDisabled", + "AreaId": "people", + "LabelKey": "Module.ShiftsDisabled", + "HelpKey": "ModuleHelp.ShiftsDisabled", + "Binding": "DepartmentModuleSettings.ShiftsDisabled", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "ModuleSettings", + "Field": "ShiftsEnabled" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.people", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.ModuleSettings" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "module.ShiftsDisabled", + "DefaultValue": "false", + "AllowedValues": "true or false", + "EvidenceSource": "DepartmentSettingsService.GetDepartmentModuleSettingsAsync", + "Availability": "available" + }, + { + "Id": "module.ShiftsNameOverride", + "AreaId": "people", + "LabelKey": "Module.ShiftsNameOverride", + "HelpKey": "ModuleHelp.ShiftsNameOverride", + "Binding": "DepartmentModuleSettings.ShiftsNameOverride", + "ValueType": "string", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "ModuleSettings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.people", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.ModuleSettings" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "module.ShiftsNameOverride", + "DefaultValue": "unset", + "AllowedValues": "owning serializer and consuming UI", + "EvidenceSource": "DepartmentSettingsService.GetDepartmentModuleSettingsAsync", + "Availability": "reference-only" + }, + { + "Id": "module.CalendarDisabled", + "AreaId": "people", + "LabelKey": "Module.CalendarDisabled", + "HelpKey": "ModuleHelp.CalendarDisabled", + "Binding": "DepartmentModuleSettings.CalendarDisabled", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "ModuleSettings", + "Field": "CalendarEnabled" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.people", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.ModuleSettings" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "module.CalendarDisabled", + "DefaultValue": "false", + "AllowedValues": "true or false", + "EvidenceSource": "DepartmentSettingsService.GetDepartmentModuleSettingsAsync", + "Availability": "available" + }, + { + "Id": "module.CalendarNameOverride", + "AreaId": "people", + "LabelKey": "Module.CalendarNameOverride", + "HelpKey": "ModuleHelp.CalendarNameOverride", + "Binding": "DepartmentModuleSettings.CalendarNameOverride", + "ValueType": "string", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "ModuleSettings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.people", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.ModuleSettings" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "module.CalendarNameOverride", + "DefaultValue": "unset", + "AllowedValues": "owning serializer and consuming UI", + "EvidenceSource": "DepartmentSettingsService.GetDepartmentModuleSettingsAsync", + "Availability": "reference-only" + }, + { + "Id": "module.TrainingDisabled", + "AreaId": "people", + "LabelKey": "Module.TrainingDisabled", + "HelpKey": "ModuleHelp.TrainingDisabled", + "Binding": "DepartmentModuleSettings.TrainingDisabled", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "ModuleSettings", + "Field": "TrainingEnabled" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.people", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.ModuleSettings" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "module.TrainingDisabled", + "DefaultValue": "false", + "AllowedValues": "true or false", + "EvidenceSource": "DepartmentSettingsService.GetDepartmentModuleSettingsAsync", + "Availability": "available" + }, + { + "Id": "module.TrainingNameOverride", + "AreaId": "people", + "LabelKey": "Module.TrainingNameOverride", + "HelpKey": "ModuleHelp.TrainingNameOverride", + "Binding": "DepartmentModuleSettings.TrainingNameOverride", + "ValueType": "string", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "ModuleSettings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.people", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.ModuleSettings" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "module.TrainingNameOverride", + "DefaultValue": "unset", + "AllowedValues": "owning serializer and consuming UI", + "EvidenceSource": "DepartmentSettingsService.GetDepartmentModuleSettingsAsync", + "Availability": "reference-only" + }, + { + "Id": "field.PersonnelListStatusOrderSetting.Orders", + "AreaId": "people", + "LabelKey": "Field.PersonnelListStatusOrderSetting.Orders", + "HelpKey": "FieldHelp.PersonnelListStatusOrderSetting.Orders", + "Binding": "PersonnelListStatusOrderSetting.Orders", + "ValueType": "list", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "Settings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.people", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.PersonnelListStatusSortOrder" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.PersonnelListStatusOrderSetting.Orders", + "DefaultValue": "empty", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "PersonnelListStatusOrderSetting", + "Availability": "reference-only" + }, + { + "Id": "field.PersonnelListStatusOrder.Weight", + "AreaId": "people", + "LabelKey": "Field.PersonnelListStatusOrder.Weight", + "HelpKey": "FieldHelp.PersonnelListStatusOrder.Weight", + "Binding": "PersonnelListStatusOrder.Weight", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "Settings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.people", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.PersonnelListStatusSortOrder" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.PersonnelListStatusOrder.Weight", + "DefaultValue": "0", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "PersonnelListStatusOrder", + "Availability": "reference-only" + }, + { + "Id": "field.PersonnelListStatusOrder.StatusId", + "AreaId": "people", + "LabelKey": "Field.PersonnelListStatusOrder.StatusId", + "HelpKey": "FieldHelp.PersonnelListStatusOrder.StatusId", + "Binding": "PersonnelListStatusOrder.StatusId", + "ValueType": "reference", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "Settings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.people", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.PersonnelListStatusSortOrder" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.PersonnelListStatusOrder.StatusId", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "PersonnelListStatusOrder", + "Availability": "reference-only" + }, + { + "Id": "field.DepartmentSuppressStaffingInfo.EnableSupressStaffing", + "AreaId": "people", + "LabelKey": "Field.DepartmentSuppressStaffingInfo.EnableSupressStaffing", + "HelpKey": "FieldHelp.DepartmentSuppressStaffingInfo.EnableSupressStaffing", + "Binding": "DepartmentSuppressStaffingInfo.EnableSupressStaffing", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "Settings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.people", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.StaffingSuppressStaffingLevels" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.DepartmentSuppressStaffingInfo.EnableSupressStaffing", + "DefaultValue": "false", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "DepartmentSuppressStaffingInfo", + "Availability": "reference-only" + }, + { + "Id": "field.DepartmentSuppressStaffingInfo.StaffingLevelsToSupress", + "AreaId": "people", + "LabelKey": "Field.DepartmentSuppressStaffingInfo.StaffingLevelsToSupress", + "HelpKey": "FieldHelp.DepartmentSuppressStaffingInfo.StaffingLevelsToSupress", + "Binding": "DepartmentSuppressStaffingInfo.StaffingLevelsToSupress", + "ValueType": "reference-list", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "Settings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.people", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.StaffingSuppressStaffingLevels" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.DepartmentSuppressStaffingInfo.StaffingLevelsToSupress", + "DefaultValue": "empty", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "DepartmentSuppressStaffingInfo", + "Availability": "reference-only" + } + ], + "Capabilities": [ + { + "Id": "personnel", + "AreaId": "people", + "LabelKey": "Feature.personnel", + "PurposeKey": "FeaturePurpose.personnel", + "ValueKey": "AreaValue.people", + "ExampleKey": "AreaExample.people", + "AdoptionKey": "FeatureAdoption.personnel", + "ReleaseStatus": "available", + "Location": { + "Controller": "Personnel", + "Action": "Index" + }, + "Requirements": [], + "SettingIds": [], + "RuleIds": [], + "Setup": { + "EvidenceId": "activePersonnelCount", + "Minimum": 1, + "RuleIds": [], + "GuidanceKey": "SetupEvidence.personnel" + } + }, + { + "Id": "add-person", + "AreaId": "people", + "LabelKey": "Feature.add-person", + "PurposeKey": "FeaturePurpose.add-person", + "ValueKey": "AreaValue.people", + "ExampleKey": "AreaExample.people", + "AdoptionKey": "FeatureAdoption.add-person", + "ReleaseStatus": "available", + "Location": { + "Controller": "Personnel", + "Action": "AddPerson" + }, + "Requirements": [], + "SettingIds": [], + "RuleIds": [] + }, + { + "Id": "groups", + "AreaId": "people", + "LabelKey": "Feature.groups", + "PurposeKey": "FeaturePurpose.groups", + "ValueKey": "AreaValue.people", + "ExampleKey": "AreaExample.people", + "AdoptionKey": "FeatureAdoption.groups", + "ReleaseStatus": "available", + "Location": { + "Controller": "Groups", + "Action": "Index" + }, + "Requirements": [], + "SettingIds": [], + "RuleIds": [ + "empty-groups", + "station-address" + ], + "Setup": { + "EvidenceId": "groupCount", + "Minimum": 1, + "RuleIds": [ + "empty-groups", + "station-address" + ], + "GuidanceKey": "SetupEvidence.groups" + } + }, + { + "Id": "new-group", + "AreaId": "people", + "LabelKey": "Feature.new-group", + "PurposeKey": "FeaturePurpose.new-group", + "ValueKey": "AreaValue.people", + "ExampleKey": "AreaExample.people", + "AdoptionKey": "FeatureAdoption.new-group", + "ReleaseStatus": "available", + "Location": { + "Controller": "Groups", + "Action": "NewGroup" + }, + "Requirements": [], + "SettingIds": [], + "RuleIds": [] + }, + { + "Id": "shifts", + "AreaId": "people", + "LabelKey": "Feature.shifts", + "PurposeKey": "FeaturePurpose.shifts", + "ValueKey": "AreaValue.people", + "ExampleKey": "AreaExample.people", + "AdoptionKey": "FeatureAdoption.shifts", + "ReleaseStatus": "available", + "Location": { + "Controller": "Shifts", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "module", + "Id": "Shifts" + } + ], + "SettingIds": [ + "module.ShiftsDisabled" + ], + "RuleIds": [ + "shift-open-slots", + "shift-overlaps", + "shift-trades" + ] + }, + { + "Id": "calendar", + "AreaId": "people", + "LabelKey": "Feature.calendar", + "PurposeKey": "FeaturePurpose.calendar", + "ValueKey": "AreaValue.people", + "ExampleKey": "AreaExample.people", + "AdoptionKey": "FeatureAdoption.calendar", + "ReleaseStatus": "available", + "Location": { + "Controller": "Calendar", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "module", + "Id": "Calendar" + } + ], + "SettingIds": [ + "module.CalendarDisabled" + ], + "RuleIds": [] + }, + { + "Id": "new-calendar-item", + "AreaId": "people", + "LabelKey": "Feature.new-calendar-item", + "PurposeKey": "FeaturePurpose.new-calendar-item", + "ValueKey": "AreaValue.people", + "ExampleKey": "AreaExample.people", + "AdoptionKey": "FeatureAdoption.new-calendar-item", + "ReleaseStatus": "available", + "Location": { + "Controller": "Calendar", + "Action": "New" + }, + "Requirements": [ + { + "Kind": "module", + "Id": "Calendar" + } + ], + "SettingIds": [ + "module.CalendarDisabled" + ], + "RuleIds": [] + }, + { + "Id": "trainings", + "AreaId": "people", + "LabelKey": "Feature.trainings", + "PurposeKey": "FeaturePurpose.trainings", + "ValueKey": "AreaValue.people", + "ExampleKey": "AreaExample.people", + "AdoptionKey": "FeatureAdoption.trainings", + "ReleaseStatus": "available", + "Location": { + "Controller": "Trainings", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "module", + "Id": "Training" + } + ], + "SettingIds": [ + "module.TrainingDisabled" + ], + "RuleIds": [] + }, + { + "Id": "new-training", + "AreaId": "people", + "LabelKey": "Feature.new-training", + "PurposeKey": "FeaturePurpose.new-training", + "ValueKey": "AreaValue.people", + "ExampleKey": "AreaExample.people", + "AdoptionKey": "FeatureAdoption.new-training", + "ReleaseStatus": "available", + "Location": { + "Controller": "Trainings", + "Action": "New" + }, + "Requirements": [ + { + "Kind": "module", + "Id": "Training" + } + ], + "SettingIds": [ + "module.TrainingDisabled" + ], + "RuleIds": [] + }, + { + "Id": "certification-dashboard", + "AreaId": "people", + "LabelKey": "Feature.certification-dashboard", + "PurposeKey": "FeaturePurpose.certification-dashboard", + "ValueKey": "AreaValue.people", + "ExampleKey": "AreaExample.people", + "AdoptionKey": "FeatureAdoption.certification-dashboard", + "ReleaseStatus": "available", + "Location": { + "Controller": "Certifications", + "Action": "Index" + }, + "Requirements": [], + "SettingIds": [], + "RuleIds": [ + "credential-expiry", + "qualified-coverage" + ] + }, + { + "Id": "certification-types", + "AreaId": "people", + "LabelKey": "Feature.certification-types", + "PurposeKey": "FeaturePurpose.certification-types", + "ValueKey": "AreaValue.people", + "ExampleKey": "AreaExample.people", + "AdoptionKey": "FeatureAdoption.certification-types", + "ReleaseStatus": "available", + "Location": { + "Controller": "Certifications", + "Action": "Types" + }, + "Requirements": [], + "SettingIds": [], + "RuleIds": [] + }, + { + "Id": "certification-settings", + "AreaId": "people", + "LabelKey": "Feature.certification-settings", + "PurposeKey": "FeaturePurpose.certification-settings", + "ValueKey": "AreaValue.people", + "ExampleKey": "AreaExample.people", + "AdoptionKey": "FeatureAdoption.certification-settings", + "ReleaseStatus": "available", + "Location": { + "Controller": "Certifications", + "Action": "Settings" + }, + "Requirements": [], + "SettingIds": [], + "RuleIds": [] + }, + { + "Id": "my-certifications", + "AreaId": "people", + "LabelKey": "Feature.my-certifications", + "PurposeKey": "FeaturePurpose.my-certifications", + "ValueKey": "AreaValue.people", + "ExampleKey": "AreaExample.people", + "AdoptionKey": "FeatureAdoption.my-certifications", + "ReleaseStatus": "available", + "Location": { + "Controller": "Certifications", + "Action": "Index" + }, + "Requirements": [], + "SettingIds": [], + "RuleIds": [ + "credential-expiry", + "qualified-coverage" + ] + }, + { + "Id": "personnel-roles", + "AreaId": "people", + "LabelKey": "Feature.personnel-roles", + "PurposeKey": "FeaturePurpose.personnel-roles", + "ValueKey": "FeatureValue.personnel-roles", + "ExampleKey": "AreaExample.people", + "AdoptionKey": "FeatureAdoption.personnel-roles", + "ReleaseStatus": "available", + "Location": { + "Controller": "Personnel", + "Action": "Roles" + }, + "Requirements": [], + "SettingIds": [], + "RuleIds": [] + }, + { + "Id": "custom-statuses", + "AreaId": "people", + "LabelKey": "Feature.custom-statuses", + "PurposeKey": "FeaturePurpose.custom-statuses", + "ValueKey": "FeatureValue.custom-statuses", + "ExampleKey": "AreaExample.people", + "AdoptionKey": "FeatureAdoption.custom-statuses", + "ReleaseStatus": "available", + "Location": { + "Controller": "CustomStatuses", + "Action": "Index" + }, + "Requirements": [], + "SettingIds": [], + "RuleIds": [] + }, + { + "Id": "workshifts", + "AreaId": "people", + "LabelKey": "Feature.workshifts", + "PurposeKey": "FeaturePurpose.workshifts", + "ValueKey": "FeatureValue.workshifts", + "ExampleKey": "AreaExample.people", + "AdoptionKey": "FeatureAdoption.workshifts", + "ReleaseStatus": "available", + "Location": { + "Controller": "Workshifts", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "module", + "Id": "Shifts" + } + ], + "SettingIds": [ + "module.ShiftsDisabled" + ], + "RuleIds": [] + } + ], + "Rules": [ + { + "Id": "empty-groups", + "AreaId": "people", + "Severity": "Warning", + "TitleKey": "Rule.empty-groups", + "ExplanationKey": "RuleWhy.empty-groups", + "NextActionKey": "RuleNext.empty-groups", + "Location": { + "Controller": "Groups", + "Action": "Index" + }, + "AppliesWhen": [], + "FailsWhen": [ + { + "EvidenceId": "emptyGroupCount", + "Comparison": "Greater", + "Number": 0 + } + ] + }, + { + "Id": "qualified-coverage", + "AreaId": "people", + "Severity": "Critical", + "TitleKey": "Rule.qualified-coverage", + "ExplanationKey": "RuleWhy.qualified-coverage", + "NextActionKey": "RuleNext.qualified-coverage", + "Location": { + "Controller": "Certifications", + "Action": "Index" + }, + "AppliesWhen": [], + "FailsWhen": [ + { + "EvidenceId": "uncoveredQualificationCount", + "Comparison": "Greater", + "Number": 0 + } + ] + }, + { + "Id": "credential-expiry", + "AreaId": "people", + "Severity": "Warning", + "TitleKey": "Rule.credential-expiry", + "ExplanationKey": "RuleWhy.credential-expiry", + "NextActionKey": "RuleNext.credential-expiry", + "Location": { + "Controller": "Certifications", + "Action": "Index" + }, + "AppliesWhen": [], + "FailsWhen": [ + { + "EvidenceId": "qualificationsExpiring30Days", + "Comparison": "Greater", + "Number": 0 + } + ] + }, + { + "Id": "shift-open-slots", + "AreaId": "people", + "Severity": "Warning", + "TitleKey": "Rule.shift-open-slots", + "ExplanationKey": "RuleWhy.shift-open-slots", + "NextActionKey": "RuleNext.shift-open-slots", + "Location": { + "Controller": "Shifts", + "Action": "Index" + }, + "AppliesWhen": [ + { + "EvidenceId": "upcomingShiftCount", + "Comparison": "Greater", + "Number": 0 + } + ], + "FailsWhen": [ + { + "EvidenceId": "upcomingOpenShiftSlots", + "Comparison": "Greater", + "Number": 0 + } + ] + }, + { + "Id": "shift-overlaps", + "AreaId": "people", + "Severity": "Warning", + "TitleKey": "Rule.shift-overlaps", + "ExplanationKey": "RuleWhy.shift-overlaps", + "NextActionKey": "RuleNext.shift-overlaps", + "Location": { + "Controller": "Shifts", + "Action": "Index" + }, + "AppliesWhen": [ + { + "EvidenceId": "upcomingShiftCount", + "Comparison": "Greater", + "Number": 0 + } + ], + "FailsWhen": [ + { + "EvidenceId": "overlappingShiftPersonnel", + "Comparison": "Greater", + "Number": 0 + } + ] + }, + { + "Id": "shift-trades", + "AreaId": "people", + "Severity": "Warning", + "TitleKey": "Rule.shift-trades", + "ExplanationKey": "RuleWhy.shift-trades", + "NextActionKey": "RuleNext.shift-trades", + "Location": { + "Controller": "Shifts", + "Action": "Index" + }, + "AppliesWhen": [ + { + "EvidenceId": "upcomingShiftCount", + "Comparison": "Greater", + "Number": 0 + } + ], + "FailsWhen": [ + { + "EvidenceId": "unfilledShiftTrades", + "Comparison": "Greater", + "Number": 0 + } + ] + } + ], + "Packs": [], + "Articles": [ + { + "Id": "setting.DisabledAutoAvailable", + "Locale": "en", + "TitleKey": "Setting.DisabledAutoAvailable", + "Body": "Disabled Auto Available. Disables automatic availability behavior. Review staffing/status reset interactions before changing it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-disabledautoavailable", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.PersonnelSortOrder", + "Locale": "en", + "TitleKey": "Setting.PersonnelSortOrder", + "Body": "Personnel Sort Order. Sort order for personnel lists; does not change availability, access or dispatch priority.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-personnelsortorder", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.PersonnelListStatusSortOrder", + "Locale": "en", + "TitleKey": "Setting.PersonnelListStatusSortOrder", + "Body": "Personnel List Status Sort Order. Custom ordering of personnel statuses in lists. Review all configured statuses before replacing the order.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-personnelliststatussortorder", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.AllowSignupsForMultipleShiftGroups", + "Locale": "en", + "TitleKey": "Setting.AllowSignupsForMultipleShiftGroups", + "Body": "Allow Signups For Multiple Shift Groups. Allow a member to sign up for multiple shift groups. Review overlaps and local coverage rules separately.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-allowsignupsformultipleshiftgroups", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.StaffingSuppressStaffingLevels", + "Locale": "en", + "TitleKey": "Setting.StaffingSuppressStaffingLevels", + "Body": "Staffing Suppress Staffing Levels. Staffing levels suppressed in supported notifications. Suppression can change who hears an alert and is not evidence of delivery.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-staffingsuppressstaffinglevels", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "module.ShiftsDisabled", + "Locale": "en", + "TitleKey": "Module.ShiftsDisabled", + "Body": "Shifts availability. Controls availability of shift scheduling. Disabling this module can remove navigation and block module operations; it does not delete its records. Turning it on still requires the applicable subscription, rollout and permissions.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "module-shiftsdisabled", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "module.ShiftsNameOverride", + "Locale": "en", + "TitleKey": "Module.ShiftsNameOverride", + "Body": "Shifts menu name. Optional display name for shift scheduling in consumers that support the override. It changes the label, not permissions or functionality. This release has no separate name-override control in Module Settings.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "module-shiftsnameoverride", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "module.CalendarDisabled", + "Locale": "en", + "TitleKey": "Module.CalendarDisabled", + "Body": "Calendar availability. Controls availability of calendar events. Disabling this module can remove navigation and block module operations; it does not delete its records. Turning it on still requires the applicable subscription, rollout and permissions.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "module-calendardisabled", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "module.CalendarNameOverride", + "Locale": "en", + "TitleKey": "Module.CalendarNameOverride", + "Body": "Calendar menu name. Optional display name for calendar events in consumers that support the override. It changes the label, not permissions or functionality. This release has no separate name-override control in Module Settings.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "module-calendarnameoverride", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "module.TrainingDisabled", + "Locale": "en", + "TitleKey": "Module.TrainingDisabled", + "Body": "Training availability. Controls availability of training. Disabling this module can remove navigation and block module operations; it does not delete its records. Turning it on still requires the applicable subscription, rollout and permissions.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "module-trainingdisabled", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "module.TrainingNameOverride", + "Locale": "en", + "TitleKey": "Module.TrainingNameOverride", + "Body": "Training menu name. Optional display name for training in consumers that support the override. It changes the label, not permissions or functionality. This release has no separate name-override control in Module Settings.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "module-trainingnameoverride", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "field.PersonnelListStatusOrderSetting.Orders", + "Locale": "en", + "TitleKey": "Field.PersonnelListStatusOrderSetting.Orders", + "Body": "Personnel List Status Order Setting / Orders. Ordered status weights for supported personnel lists. This does not set availability or dispatch priority.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-personnelliststatusordersetting-orders", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "field.PersonnelListStatusOrder.Weight", + "Locale": "en", + "TitleKey": "Field.PersonnelListStatusOrder.Weight", + "Body": "Personnel List Status Order / Weight. Relative list order for this status. Confirm all configured statuses remain represented.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-personnelliststatusorder-weight", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "field.PersonnelListStatusOrder.StatusId", + "Locale": "en", + "TitleKey": "Field.PersonnelListStatusOrder.StatusId", + "Body": "Personnel List Status Order / Status Id. Existing personnel-status identifier whose list position is being configured.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-personnelliststatusorder-statusid", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "field.DepartmentSuppressStaffingInfo.EnableSupressStaffing", + "Locale": "en", + "TitleKey": "Field.DepartmentSuppressStaffingInfo.EnableSupressStaffing", + "Body": "Department Suppress Staffing Info / Enable Supress Staffing. Enables the configured staffing-level suppression in supported notification consumers; review reachability before changing it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-departmentsuppressstaffinginfo-enablesupressstaffing", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "field.DepartmentSuppressStaffingInfo.StaffingLevelsToSupress", + "Locale": "en", + "TitleKey": "Field.DepartmentSuppressStaffingInfo.StaffingLevelsToSupress", + "Body": "Department Suppress Staffing Info / Staffing Levels To Supress. Existing staffing levels to suppress. A suppressed level is not proof that another channel reaches the member.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-departmentsuppressstaffinginfo-staffinglevelstosupress", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "personnel", + "Locale": "en", + "TitleKey": "Feature.personnel", + "Body": "Personnel People in the department, status and staffing Keep access and responsibilities aligned with the people doing the work. Check that the next shift has the locally required qualified crew. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "personnel", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "add-person", + "Locale": "en", + "TitleKey": "Feature.add-person", + "Body": "Add Person Manually create a user account in the department Keep access and responsibilities aligned with the people doing the work. Check that the next shift has the locally required qualified crew. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "add-person", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "groups", + "Locale": "en", + "TitleKey": "Feature.groups", + "Body": "Groups & Stations Department groups and stations Keep access and responsibilities aligned with the people doing the work. Check that the next shift has the locally required qualified crew. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "groups", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "new-group", + "Locale": "en", + "TitleKey": "Feature.new-group", + "Body": "New Group Create a group or station Keep access and responsibilities aligned with the people doing the work. Check that the next shift has the locally required qualified crew. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "new-group", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "shifts", + "Locale": "en", + "TitleKey": "Feature.shifts", + "Body": "Shifts Shift signups, recurring shifts and trades Keep access and responsibilities aligned with the people doing the work. Check that the next shift has the locally required qualified crew. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "shifts", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "calendar", + "Locale": "en", + "TitleKey": "Feature.calendar", + "Body": "Calendar Events, meetings and trainings you can sign up for Keep access and responsibilities aligned with the people doing the work. Check that the next shift has the locally required qualified crew. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "calendar", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "new-calendar-item", + "Locale": "en", + "TitleKey": "Feature.new-calendar-item", + "Body": "New Calendar Event Create a calendar event Keep access and responsibilities aligned with the people doing the work. Check that the next shift has the locally required qualified crew. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "new-calendar-item", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "trainings", + "Locale": "en", + "TitleKey": "Feature.trainings", + "Body": "Trainings Trainings, study guides and procedures Keep access and responsibilities aligned with the people doing the work. Check that the next shift has the locally required qualified crew. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "trainings", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "new-training", + "Locale": "en", + "TitleKey": "Feature.new-training", + "Body": "New Training Create a training with optional quiz Keep access and responsibilities aligned with the people doing the work. Check that the next shift has the locally required qualified crew. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "new-training", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "certification-dashboard", + "Locale": "en", + "TitleKey": "Feature.certification-dashboard", + "Body": "Certification Dashboard Expiring and expired certifications for people and units Keep access and responsibilities aligned with the people doing the work. Check that the next shift has the locally required qualified crew. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "certification-dashboard", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "certification-types", + "Locale": "en", + "TitleKey": "Feature.certification-types", + "Body": "Certification Types The department's certification catalog and template gallery Keep access and responsibilities aligned with the people doing the work. Check that the next shift has the locally required qualified crew. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "certification-types", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "certification-settings", + "Locale": "en", + "TitleKey": "Feature.certification-settings", + "Body": "Certification Settings Enforcement mode, grace period and expiry notifications Keep access and responsibilities aligned with the people doing the work. Check that the next shift has the locally required qualified crew. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "certification-settings", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "my-certifications", + "Locale": "en", + "TitleKey": "Feature.my-certifications", + "Body": "My Certifications Your own certification records Keep access and responsibilities aligned with the people doing the work. Check that the next shift has the locally required qualified crew. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "my-certifications", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "personnel-roles", + "Locale": "en", + "TitleKey": "Feature.personnel-roles", + "Body": "Personnel roles Organize department-defined role assignments. Describe responsibility consistently without assuming that a role proves qualification. Check that the next shift has the locally required qualified crew. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "personnel-roles", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "custom-statuses", + "Locale": "en", + "TitleKey": "Feature.custom-statuses", + "Body": "Personnel and unit statuses Configure status names, base meanings and supported behavior. Use terminology familiar to the department while preserving automation meaning. Check that the next shift has the locally required qualified crew. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "custom-statuses", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "workshifts", + "Locale": "en", + "TitleKey": "Feature.workshifts", + "Body": "Workshifts Configure repeating workforce schedules supported by Workshifts. Review schedule ownership and local time separately from dispatch eligibility. Check that the next shift has the locally required qualified crew. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "workshifts", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + } + ] +} diff --git a/Core/Resgrid.AdminAssist/Catalog/plans.yaml b/Core/Resgrid.AdminAssist/Catalog/plans.yaml new file mode 100644 index 000000000..a79f6d523 --- /dev/null +++ b/Core/Resgrid.AdminAssist/Catalog/plans.yaml @@ -0,0 +1,428 @@ +{ + "Version": "2026.09.24.1", + "Areas": [ + { + "Id": "plans", + "LabelKey": "Area.plans", + "PurposeKey": "AreaPurpose.plans", + "Order": 12, + "Archetypes": [] + } + ], + "Settings": [ + { + "Id": "setting.StripeCustomerId", + "AreaId": "plans", + "LabelKey": "Setting.StripeCustomerId", + "HelpKey": "SettingHelp.StripeCustomerId", + "Binding": "DepartmentSettingTypes.StripeCustomerId", + "ValueType": "provider-reference", + "Classification": "Restricted", + "Secret": true, + "Location": { + "Controller": "Subscription", + "Action": "Index", + "Field": "StripeCustomerId" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.plans", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.StripeCustomerId", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.BrainTreeCustomerId", + "AreaId": "plans", + "LabelKey": "Setting.BrainTreeCustomerId", + "HelpKey": "SettingHelp.BrainTreeCustomerId", + "Binding": "DepartmentSettingTypes.BrainTreeCustomerId", + "ValueType": "provider-reference", + "Classification": "Restricted", + "Secret": true, + "Location": { + "Controller": "Subscription", + "Action": "Index", + "Field": "BrainTreeCustomerId" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.plans", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.BrainTreeCustomerId", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.PaddleCustomerId", + "AreaId": "plans", + "LabelKey": "Setting.PaddleCustomerId", + "HelpKey": "SettingHelp.PaddleCustomerId", + "Binding": "DepartmentSettingTypes.PaddleCustomerId", + "ValueType": "provider-reference", + "Classification": "Restricted", + "Secret": true, + "Location": { + "Controller": "Subscription", + "Action": "Index", + "Field": "PaddleCustomerId" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.plans", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.PaddleCustomerId", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + } + ], + "Capabilities": [ + { + "Id": "addon-ptt", + "AreaId": "plans", + "LabelKey": "Feature.addon-ptt", + "PurposeKey": "FeaturePurpose.addon-ptt", + "ValueKey": "FeatureValue.addon-ptt", + "ExampleKey": "FeatureExample.addon-ptt", + "AdoptionKey": "FeatureAdoption.addon-ptt", + "ReleaseStatus": "available", + "Location": { + "Controller": "Subscription", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "addon", + "Id": "PTT" + } + ], + "SettingIds": [ + "setting.BrainTreeCustomerId", + "setting.PaddleCustomerId", + "setting.StripeCustomerId" + ], + "RuleIds": [ + "personnel-limit", + "unit-limit" + ] + }, + { + "Id": "addon-adp", + "AreaId": "plans", + "LabelKey": "Feature.addon-adp", + "PurposeKey": "FeaturePurpose.addon-adp", + "ValueKey": "FeatureValue.addon-adp", + "ExampleKey": "FeatureExample.addon-adp", + "AdoptionKey": "FeatureAdoption.addon-adp", + "ReleaseStatus": "available", + "Location": { + "Controller": "Subscription", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "addon", + "Id": "ADP" + } + ], + "SettingIds": [ + "setting.BrainTreeCustomerId", + "setting.PaddleCustomerId", + "setting.StripeCustomerId" + ], + "RuleIds": [ + "personnel-limit", + "unit-limit" + ] + }, + { + "Id": "addon-readiness", + "AreaId": "plans", + "LabelKey": "Feature.addon-readiness", + "PurposeKey": "FeaturePurpose.addon-readiness", + "ValueKey": "FeatureValue.addon-readiness", + "ExampleKey": "FeatureExample.addon-readiness", + "AdoptionKey": "FeatureAdoption.addon-readiness", + "ReleaseStatus": "available", + "Location": { + "Controller": "Subscription", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "addon", + "Id": "ReadinessPro" + } + ], + "SettingIds": [ + "setting.BrainTreeCustomerId", + "setting.PaddleCustomerId", + "setting.StripeCustomerId" + ], + "RuleIds": [ + "personnel-limit", + "unit-limit" + ] + }, + { + "Id": "addon-business", + "AreaId": "plans", + "LabelKey": "Feature.addon-business", + "PurposeKey": "FeaturePurpose.addon-business", + "ValueKey": "FeatureValue.addon-business", + "ExampleKey": "FeatureExample.addon-business", + "AdoptionKey": "FeatureAdoption.addon-business", + "ReleaseStatus": "available", + "Location": { + "Controller": "Subscription", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "addon", + "Id": "BusinessOperations" + } + ], + "SettingIds": [ + "setting.BrainTreeCustomerId", + "setting.PaddleCustomerId", + "setting.StripeCustomerId" + ], + "RuleIds": [ + "personnel-limit", + "unit-limit" + ] + }, + { + "Id": "addon-ai", + "AreaId": "plans", + "LabelKey": "Feature.addon-ai", + "PurposeKey": "FeaturePurpose.addon-ai", + "ValueKey": "FeatureValue.addon-ai", + "ExampleKey": "FeatureExample.addon-ai", + "AdoptionKey": "FeatureAdoption.addon-ai", + "ReleaseStatus": "planned", + "Location": { + "Controller": "Subscription", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "addon", + "Id": "EnhancedAi" + } + ], + "SettingIds": [ + "setting.BrainTreeCustomerId", + "setting.PaddleCustomerId", + "setting.StripeCustomerId" + ], + "RuleIds": [ + "personnel-limit", + "unit-limit" + ] + }, + { + "Id": "subscription", + "AreaId": "plans", + "LabelKey": "Feature.subscription", + "PurposeKey": "FeaturePurpose.subscription", + "ValueKey": "FeatureValue.subscription", + "ExampleKey": "AreaExample.plans", + "AdoptionKey": "FeatureAdoption.subscription", + "ReleaseStatus": "available", + "Location": { + "Controller": "Subscription", + "Action": "Index" + }, + "Requirements": [], + "SettingIds": [ + "setting.BrainTreeCustomerId", + "setting.PaddleCustomerId", + "setting.StripeCustomerId" + ], + "RuleIds": [ + "personnel-limit", + "unit-limit" + ] + } + ], + "Rules": [ + { + "Id": "personnel-limit", + "AreaId": "plans", + "Severity": "Warning", + "TitleKey": "Rule.personnel-limit", + "ExplanationKey": "RuleWhy.personnel-limit", + "NextActionKey": "RuleNext.personnel-limit", + "Location": { + "Controller": "Subscription", + "Action": "Index" + }, + "AppliesWhen": [], + "FailsWhen": [ + { + "EvidenceId": "personnelUtilization", + "Comparison": "GreaterOrEqual", + "Number": 0.9 + } + ] + }, + { + "Id": "unit-limit", + "AreaId": "plans", + "Severity": "Warning", + "TitleKey": "Rule.unit-limit", + "ExplanationKey": "RuleWhy.unit-limit", + "NextActionKey": "RuleNext.unit-limit", + "Location": { + "Controller": "Subscription", + "Action": "Index" + }, + "AppliesWhen": [], + "FailsWhen": [ + { + "EvidenceId": "unitUtilization", + "Comparison": "GreaterOrEqual", + "Number": 0.9 + } + ] + } + ], + "Packs": [], + "Articles": [ + { + "Id": "setting.StripeCustomerId", + "Locale": "en", + "TitleKey": "Setting.StripeCustomerId", + "Body": "Stripe Customer Id. Billing-provider customer reference managed by subscription workflows; never edit this as an ordinary department setting.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-stripecustomerid", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.BrainTreeCustomerId", + "Locale": "en", + "TitleKey": "Setting.BrainTreeCustomerId", + "Body": "Brain Tree Customer Id. Legacy billing reference. Use the subscription provider workflow, not a direct settings edit.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-braintreecustomerid", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.PaddleCustomerId", + "Locale": "en", + "TitleKey": "Setting.PaddleCustomerId", + "Body": "Paddle Customer Id. Billing-provider customer reference. Managed by authorized billing flows and never returned in assistant evidence.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-paddlecustomerid", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "addon-ptt", + "Locale": "en", + "TitleKey": "Feature.addon-ptt", + "Body": "Push-to-Talk Voice channels for crew communication in supported Resgrid clients. Coordinate field teams through configured voice channels. A SAR coordinator uses a team channel during a training exercise. Review seats, supported clients and channel setup. PTT does not provide phone dispatch alerts or certify radio replacement.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "addon-ptt", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "addon-adp", + "Locale": "en", + "TitleKey": "Feature.addon-adp", + "Body": "Advanced Data Protection Additional protection and scoped access for cataloged sensitive department content. Control protected content disclosure and recovery through the department protection lifecycle. An administrator enrolls the department and verifies its recovery arrangements. Buying does not enroll the department. Review MFA, recovery, migration and integration behavior; this is not a compliance certification.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "addon-adp", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "addon-readiness", + "Locale": "en", + "TitleKey": "Feature.addon-readiness", + "Body": "Readiness Pro Maintenance, work orders, preventive tasks, corrective work, approvals and safety holds. Give equipment defects an owner and a recorded repair and return-to-service review. A failed apparatus check leads to an explicitly created maintenance work order. Checklists do not require this add-on. Maintenance needs its module, permissions and active entitlement. Historical evidence and hold release follow owning-module rules.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "addon-readiness", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "addon-business", + "Locale": "en", + "TitleKey": "Feature.addon-business", + "Body": "Business Operations Invoicing, payments, rates, contracts, bids, MARS reimbursement, workforce and field costing. Connect administrative commercial work to the department activities it supports. Prepare an event standby invoice or review documented mutual-aid costs. Certifications and Deployment Finance do not require this add-on. Pay-data reporting additionally needs ADP Enabled; online payments require provider setup. No reimbursement or savings are guaranteed.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "addon-business", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "addon-ai", + "Locale": "en", + "TitleKey": "Feature.addon-ai", + "Body": "Enhanced AI Planned summaries, drafts, knowledge assistance and optional Admin Assist conversation. Help explain approved evidence and prepare drafts for human review when released. Ask for an explanation of a configuration finding when the conversational feature is available. Planned until product and runtime availability are verified. Deterministic Setup Wizard, Setup Report and Admin Assist do not require an AI purchase.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "addon-ai", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "subscription", + "Locale": "en", + "TitleKey": "Feature.subscription", + "Body": "Base plans and capacity Review current plan allowances and authorized subscription options. Choose capacity for actual department needs without inferring prices or terms. Review feature requirements with the managing member before using the normal billing screen. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "subscription", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + } + ] +} diff --git a/Core/Resgrid.AdminAssist/Catalog/records.yaml b/Core/Resgrid.AdminAssist/Catalog/records.yaml new file mode 100644 index 000000000..e18144121 --- /dev/null +++ b/Core/Resgrid.AdminAssist/Catalog/records.yaml @@ -0,0 +1,2267 @@ +{ + "Version": "2026.09.24.1", + "Areas": [ + { + "Id": "records", + "LabelKey": "Area.records", + "PurposeKey": "AreaPurpose.records", + "Order": 6, + "Archetypes": [] + } + ], + "Settings": [ + { + "Id": "setting.RecordsDefaultLifecyclePreset", + "AreaId": "records", + "LabelKey": "Setting.RecordsDefaultLifecyclePreset", + "HelpKey": "SettingHelp.RecordsDefaultLifecyclePreset", + "Binding": "DepartmentSettingTypes.RecordsDefaultLifecyclePreset", + "ValueType": "enum", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Records", + "Action": "Settings", + "Field": "RecordsDefaultLifecyclePreset" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.records", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.RecordsDefaultLifecyclePreset", + "DefaultValue": "owning records default", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.RecordsReviewDueHours", + "AreaId": "records", + "LabelKey": "Setting.RecordsReviewDueHours", + "HelpKey": "SettingHelp.RecordsReviewDueHours", + "Binding": "DepartmentSettingTypes.RecordsReviewDueHours", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Records", + "Action": "Settings", + "Field": "RecordsReviewDueHours" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.records", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.RecordsReviewDueHours", + "DefaultValue": "owning records default", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.RecordsNumberingConfig", + "AreaId": "records", + "LabelKey": "Setting.RecordsNumberingConfig", + "HelpKey": "SettingHelp.RecordsNumberingConfig", + "Binding": "DepartmentSettingTypes.RecordsNumberingConfig", + "ValueType": "protobuf", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Records", + "Action": "Settings", + "Field": "RecordsNumberingConfig" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.records", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.RecordsNumberingConfig", + "DefaultValue": "RecordsNumberingConfig constructor", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.RecordsSearchConfig", + "AreaId": "records", + "LabelKey": "Setting.RecordsSearchConfig", + "HelpKey": "SettingHelp.RecordsSearchConfig", + "Binding": "DepartmentSettingTypes.RecordsSearchConfig", + "ValueType": "protobuf", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Records", + "Action": "Settings", + "Field": "RecordsSearchConfig" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.records", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.RecordsSearchConfig", + "DefaultValue": "RecordsSearchConfig constructor", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.RecordsRetentionPolicy", + "AreaId": "records", + "LabelKey": "Setting.RecordsRetentionPolicy", + "HelpKey": "SettingHelp.RecordsRetentionPolicy", + "Binding": "DepartmentSettingTypes.RecordsRetentionPolicy", + "ValueType": "protobuf", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Records", + "Action": "Settings", + "Field": "RecordsRetentionPolicy" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.records", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.RecordsRetentionPolicy", + "DefaultValue": "class defaults, no department override", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.RecordsGroupVisibilityMode", + "AreaId": "records", + "LabelKey": "Setting.RecordsGroupVisibilityMode", + "HelpKey": "SettingHelp.RecordsGroupVisibilityMode", + "Binding": "DepartmentSettingTypes.RecordsGroupVisibilityMode", + "ValueType": "enum", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Records", + "Action": "Settings", + "Field": "RecordsGroupVisibilityMode" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.records", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.RecordsGroupVisibilityMode", + "DefaultValue": "0", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.RecordsGroupScopeConfig", + "AreaId": "records", + "LabelKey": "Setting.RecordsGroupScopeConfig", + "HelpKey": "SettingHelp.RecordsGroupScopeConfig", + "Binding": "DepartmentSettingTypes.RecordsGroupScopeConfig", + "ValueType": "reserved", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Records", + "Action": "Settings", + "Field": "RecordsGroupScopeConfig" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.records", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.RecordsGroupScopeConfig", + "DefaultValue": "unused", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "retired" + }, + { + "Id": "setting.RecordsDisclosureConfig", + "AreaId": "records", + "LabelKey": "Setting.RecordsDisclosureConfig", + "HelpKey": "SettingHelp.RecordsDisclosureConfig", + "Binding": "DepartmentSettingTypes.RecordsDisclosureConfig", + "ValueType": "protobuf", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Records", + "Action": "Settings", + "Field": "RecordsDisclosureConfig" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.records", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.RecordsDisclosureConfig", + "DefaultValue": "RecordsDisclosureConfig constructor", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "module.LogsDisabled", + "AreaId": "records", + "LabelKey": "Module.LogsDisabled", + "HelpKey": "ModuleHelp.LogsDisabled", + "Binding": "DepartmentModuleSettings.LogsDisabled", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "ModuleSettings", + "Field": "LogsEnabled" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.records", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.ModuleSettings" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "module.LogsDisabled", + "DefaultValue": "false", + "AllowedValues": "true or false", + "EvidenceSource": "DepartmentSettingsService.GetDepartmentModuleSettingsAsync", + "Availability": "available" + }, + { + "Id": "module.LogsNameOverride", + "AreaId": "records", + "LabelKey": "Module.LogsNameOverride", + "HelpKey": "ModuleHelp.LogsNameOverride", + "Binding": "DepartmentModuleSettings.LogsNameOverride", + "ValueType": "string", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "ModuleSettings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.records", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.ModuleSettings" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "module.LogsNameOverride", + "DefaultValue": "unset", + "AllowedValues": "owning serializer and consuming UI", + "EvidenceSource": "DepartmentSettingsService.GetDepartmentModuleSettingsAsync", + "Availability": "reference-only" + }, + { + "Id": "module.ReportsDisabled", + "AreaId": "records", + "LabelKey": "Module.ReportsDisabled", + "HelpKey": "ModuleHelp.ReportsDisabled", + "Binding": "DepartmentModuleSettings.ReportsDisabled", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "ModuleSettings", + "Field": "ReportsEnabled" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.records", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.ModuleSettings" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "module.ReportsDisabled", + "DefaultValue": "false", + "AllowedValues": "true or false", + "EvidenceSource": "DepartmentSettingsService.GetDepartmentModuleSettingsAsync", + "Availability": "available" + }, + { + "Id": "module.ReportsNameOverride", + "AreaId": "records", + "LabelKey": "Module.ReportsNameOverride", + "HelpKey": "ModuleHelp.ReportsNameOverride", + "Binding": "DepartmentModuleSettings.ReportsNameOverride", + "ValueType": "string", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "ModuleSettings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.records", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.ModuleSettings" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "module.ReportsNameOverride", + "DefaultValue": "unset", + "AllowedValues": "owning serializer and consuming UI", + "EvidenceSource": "DepartmentSettingsService.GetDepartmentModuleSettingsAsync", + "Availability": "reference-only" + }, + { + "Id": "field.RecordsNumberingConfig.NumberAssignment", + "AreaId": "records", + "LabelKey": "Field.RecordsNumberingConfig.NumberAssignment", + "HelpKey": "FieldHelp.RecordsNumberingConfig.NumberAssignment", + "Binding": "RecordsNumberingConfig.NumberAssignment", + "ValueType": "enum", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Records", + "Action": "Settings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.records", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.RecordsNumberingConfig" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.RecordsNumberingConfig.NumberAssignment", + "DefaultValue": "OnFinalize", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "RecordsNumberingConfig", + "Availability": "reference-only" + }, + { + "Id": "field.RecordsNumberingConfig.ResetYearly", + "AreaId": "records", + "LabelKey": "Field.RecordsNumberingConfig.ResetYearly", + "HelpKey": "FieldHelp.RecordsNumberingConfig.ResetYearly", + "Binding": "RecordsNumberingConfig.ResetYearly", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Records", + "Action": "Settings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.records", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.RecordsNumberingConfig" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.RecordsNumberingConfig.ResetYearly", + "DefaultValue": "true", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "RecordsNumberingConfig", + "Availability": "reference-only" + }, + { + "Id": "field.RecordsNumberingConfig.SequenceWidth", + "AreaId": "records", + "LabelKey": "Field.RecordsNumberingConfig.SequenceWidth", + "HelpKey": "FieldHelp.RecordsNumberingConfig.SequenceWidth", + "Binding": "RecordsNumberingConfig.SequenceWidth", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Records", + "Action": "Settings", + "Field": "SequenceWidth" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.records", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.RecordsNumberingConfig" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.RecordsNumberingConfig.SequenceWidth", + "DefaultValue": "4", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "RecordsNumberingConfig", + "Availability": "available" + }, + { + "Id": "field.RecordsNumberingConfig.IncludeYear", + "AreaId": "records", + "LabelKey": "Field.RecordsNumberingConfig.IncludeYear", + "HelpKey": "FieldHelp.RecordsNumberingConfig.IncludeYear", + "Binding": "RecordsNumberingConfig.IncludeYear", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Records", + "Action": "Settings", + "Field": "IncludeYear" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.records", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.RecordsNumberingConfig" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.RecordsNumberingConfig.IncludeYear", + "DefaultValue": "true", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "RecordsNumberingConfig", + "Availability": "available" + }, + { + "Id": "field.RecordsNumberingConfig.PerGroupSequence", + "AreaId": "records", + "LabelKey": "Field.RecordsNumberingConfig.PerGroupSequence", + "HelpKey": "FieldHelp.RecordsNumberingConfig.PerGroupSequence", + "Binding": "RecordsNumberingConfig.PerGroupSequence", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Records", + "Action": "Settings", + "Field": "PerGroupSequence" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.records", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.RecordsNumberingConfig" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.RecordsNumberingConfig.PerGroupSequence", + "DefaultValue": "false", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "RecordsNumberingConfig", + "Availability": "available" + }, + { + "Id": "field.RecordsSearchConfig.IndexNarrative", + "AreaId": "records", + "LabelKey": "Field.RecordsSearchConfig.IndexNarrative", + "HelpKey": "FieldHelp.RecordsSearchConfig.IndexNarrative", + "Binding": "RecordsSearchConfig.IndexNarrative", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Records", + "Action": "Settings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.records", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.RecordsSearchConfig" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.RecordsSearchConfig.IndexNarrative", + "DefaultValue": "true", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "RecordsSearchConfig", + "Availability": "reference-only" + }, + { + "Id": "field.RecordsSearchConfig.IncludeLegacyHistory", + "AreaId": "records", + "LabelKey": "Field.RecordsSearchConfig.IncludeLegacyHistory", + "HelpKey": "FieldHelp.RecordsSearchConfig.IncludeLegacyHistory", + "Binding": "RecordsSearchConfig.IncludeLegacyHistory", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Records", + "Action": "Settings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.records", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.RecordsSearchConfig" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.RecordsSearchConfig.IncludeLegacyHistory", + "DefaultValue": "true", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "RecordsSearchConfig", + "Availability": "reference-only" + }, + { + "Id": "field.RecordsRetentionPolicy.DepartmentDefaultYears", + "AreaId": "records", + "LabelKey": "Field.RecordsRetentionPolicy.DepartmentDefaultYears", + "HelpKey": "FieldHelp.RecordsRetentionPolicy.DepartmentDefaultYears", + "Binding": "RecordsRetentionPolicy.DepartmentDefaultYears", + "ValueType": "nullable-integer", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Records", + "Action": "Settings", + "Field": "DepartmentDefaultYears" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.records", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.RecordsRetentionPolicy" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.RecordsRetentionPolicy.DepartmentDefaultYears", + "DefaultValue": "system class default", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "RecordsRetentionPolicy", + "Availability": "available" + }, + { + "Id": "field.RecordsRetentionPolicy.Overrides", + "AreaId": "records", + "LabelKey": "Field.RecordsRetentionPolicy.Overrides", + "HelpKey": "FieldHelp.RecordsRetentionPolicy.Overrides", + "Binding": "RecordsRetentionPolicy.Overrides", + "ValueType": "list", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Records", + "Action": "Settings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.records", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.RecordsRetentionPolicy" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.RecordsRetentionPolicy.Overrides", + "DefaultValue": "empty", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "RecordsRetentionPolicy", + "Availability": "reference-only" + }, + { + "Id": "field.RecordsRetentionPolicy.LastChangedByUserId", + "AreaId": "records", + "LabelKey": "Field.RecordsRetentionPolicy.LastChangedByUserId", + "HelpKey": "FieldHelp.RecordsRetentionPolicy.LastChangedByUserId", + "Binding": "RecordsRetentionPolicy.LastChangedByUserId", + "ValueType": "server-metadata", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Records", + "Action": "Settings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.records", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.RecordsRetentionPolicy" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.RecordsRetentionPolicy.LastChangedByUserId", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "RecordsRetentionPolicy", + "Availability": "read-only" + }, + { + "Id": "field.RecordsRetentionPolicy.LastChangedOn", + "AreaId": "records", + "LabelKey": "Field.RecordsRetentionPolicy.LastChangedOn", + "HelpKey": "FieldHelp.RecordsRetentionPolicy.LastChangedOn", + "Binding": "RecordsRetentionPolicy.LastChangedOn", + "ValueType": "server-metadata", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Records", + "Action": "Settings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.records", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.RecordsRetentionPolicy" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.RecordsRetentionPolicy.LastChangedOn", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "RecordsRetentionPolicy", + "Availability": "read-only" + }, + { + "Id": "field.RecordsRetentionPolicy.History", + "AreaId": "records", + "LabelKey": "Field.RecordsRetentionPolicy.History", + "HelpKey": "FieldHelp.RecordsRetentionPolicy.History", + "Binding": "RecordsRetentionPolicy.History", + "ValueType": "server-metadata", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Records", + "Action": "Settings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.records", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.RecordsRetentionPolicy" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.RecordsRetentionPolicy.History", + "DefaultValue": "empty", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "RecordsRetentionPolicy", + "Availability": "read-only" + }, + { + "Id": "field.RecordsRetentionOverride.DefinitionKey", + "AreaId": "records", + "LabelKey": "Field.RecordsRetentionOverride.DefinitionKey", + "HelpKey": "FieldHelp.RecordsRetentionOverride.DefinitionKey", + "Binding": "RecordsRetentionOverride.DefinitionKey", + "ValueType": "reference", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Records", + "Action": "Settings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.records", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.RecordsRetentionPolicy" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.RecordsRetentionOverride.DefinitionKey", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "RecordsRetentionOverride", + "Availability": "reference-only" + }, + { + "Id": "field.RecordsRetentionOverride.RetentionYears", + "AreaId": "records", + "LabelKey": "Field.RecordsRetentionOverride.RetentionYears", + "HelpKey": "FieldHelp.RecordsRetentionOverride.RetentionYears", + "Binding": "RecordsRetentionOverride.RetentionYears", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Records", + "Action": "Settings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.records", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.RecordsRetentionPolicy" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.RecordsRetentionOverride.RetentionYears", + "DefaultValue": "0", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "RecordsRetentionOverride", + "Availability": "reference-only" + }, + { + "Id": "field.RecordsRetentionOverride.AppliesFrom", + "AreaId": "records", + "LabelKey": "Field.RecordsRetentionOverride.AppliesFrom", + "HelpKey": "FieldHelp.RecordsRetentionOverride.AppliesFrom", + "Binding": "RecordsRetentionOverride.AppliesFrom", + "ValueType": "date", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Records", + "Action": "Settings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.records", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.RecordsRetentionPolicy" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.RecordsRetentionOverride.AppliesFrom", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "RecordsRetentionOverride", + "Availability": "reference-only" + }, + { + "Id": "field.RecordsRetentionPolicyVersion.EffectiveOn", + "AreaId": "records", + "LabelKey": "Field.RecordsRetentionPolicyVersion.EffectiveOn", + "HelpKey": "FieldHelp.RecordsRetentionPolicyVersion.EffectiveOn", + "Binding": "RecordsRetentionPolicyVersion.EffectiveOn", + "ValueType": "server-metadata", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Records", + "Action": "Settings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.records", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.RecordsRetentionPolicy" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.RecordsRetentionPolicyVersion.EffectiveOn", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "RecordsRetentionPolicyVersion", + "Availability": "read-only" + }, + { + "Id": "field.RecordsRetentionPolicyVersion.Policy", + "AreaId": "records", + "LabelKey": "Field.RecordsRetentionPolicyVersion.Policy", + "HelpKey": "FieldHelp.RecordsRetentionPolicyVersion.Policy", + "Binding": "RecordsRetentionPolicyVersion.Policy", + "ValueType": "server-metadata", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Records", + "Action": "Settings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.records", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.RecordsRetentionPolicy" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.RecordsRetentionPolicyVersion.Policy", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "RecordsRetentionPolicyVersion", + "Availability": "read-only" + }, + { + "Id": "field.RecordsDisclosureConfig.StatutoryClockDays", + "AreaId": "records", + "LabelKey": "Field.RecordsDisclosureConfig.StatutoryClockDays", + "HelpKey": "FieldHelp.RecordsDisclosureConfig.StatutoryClockDays", + "Binding": "RecordsDisclosureConfig.StatutoryClockDays", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Records", + "Action": "Settings", + "Field": "DisclosureStatutoryClockDays" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.records", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.RecordsDisclosureConfig" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.RecordsDisclosureConfig.StatutoryClockDays", + "DefaultValue": "10", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "RecordsDisclosureConfig", + "Availability": "available" + }, + { + "Id": "field.RecordsDisclosureConfig.DefaultRedactionProfile", + "AreaId": "records", + "LabelKey": "Field.RecordsDisclosureConfig.DefaultRedactionProfile", + "HelpKey": "FieldHelp.RecordsDisclosureConfig.DefaultRedactionProfile", + "Binding": "RecordsDisclosureConfig.DefaultRedactionProfile", + "ValueType": "reference", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Records", + "Action": "Settings", + "Field": "DisclosureDefaultRedactionProfile" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.records", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.RecordsDisclosureConfig" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.RecordsDisclosureConfig.DefaultRedactionProfile", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "RecordsDisclosureConfig", + "Availability": "available" + }, + { + "Id": "field.RecordsDisclosureConfig.ReleaseApproverUserId", + "AreaId": "records", + "LabelKey": "Field.RecordsDisclosureConfig.ReleaseApproverUserId", + "HelpKey": "FieldHelp.RecordsDisclosureConfig.ReleaseApproverUserId", + "Binding": "RecordsDisclosureConfig.ReleaseApproverUserId", + "ValueType": "reference", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Records", + "Action": "Settings", + "Field": "DisclosureReleaseApproverUserId" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.records", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.RecordsDisclosureConfig" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.RecordsDisclosureConfig.ReleaseApproverUserId", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "RecordsDisclosureConfig", + "Availability": "available" + } + ], + "Capabilities": [ + { + "Id": "logs", + "AreaId": "records", + "LabelKey": "Feature.logs", + "PurposeKey": "FeaturePurpose.logs", + "ValueKey": "AreaValue.records", + "ExampleKey": "AreaExample.records", + "AdoptionKey": "FeatureAdoption.logs", + "ReleaseStatus": "available", + "Location": { + "Controller": "Logs", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "module", + "Id": "Logs" + } + ], + "SettingIds": [ + "module.LogsDisabled" + ], + "RuleIds": [] + }, + { + "Id": "new-log", + "AreaId": "records", + "LabelKey": "Feature.new-log", + "PurposeKey": "FeaturePurpose.new-log", + "ValueKey": "AreaValue.records", + "ExampleKey": "AreaExample.records", + "AdoptionKey": "FeatureAdoption.new-log", + "ReleaseStatus": "available", + "Location": { + "Controller": "Logs", + "Action": "NewLog" + }, + "Requirements": [ + { + "Kind": "module", + "Id": "Logs" + } + ], + "SettingIds": [ + "module.LogsDisabled" + ], + "RuleIds": [] + }, + { + "Id": "records", + "AreaId": "records", + "LabelKey": "Feature.records", + "PurposeKey": "FeaturePurpose.records", + "ValueKey": "AreaValue.records", + "ExampleKey": "AreaExample.records", + "AdoptionKey": "FeatureAdoption.records", + "ReleaseStatus": "available", + "Location": { + "Controller": "Records", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "flag", + "Id": "Records.System" + }, + { + "Kind": "module", + "Id": "Logs" + } + ], + "SettingIds": [ + "module.LogsDisabled" + ], + "RuleIds": [ + "record-review" + ] + }, + { + "Id": "records-dashboard", + "AreaId": "records", + "LabelKey": "Feature.records-dashboard", + "PurposeKey": "FeaturePurpose.records-dashboard", + "ValueKey": "AreaValue.records", + "ExampleKey": "AreaExample.records", + "AdoptionKey": "FeatureAdoption.records-dashboard", + "ReleaseStatus": "available", + "Location": { + "Controller": "Records", + "Action": "Dashboard" + }, + "Requirements": [ + { + "Kind": "flag", + "Id": "Records.System" + }, + { + "Kind": "module", + "Id": "Logs" + } + ], + "SettingIds": [ + "module.LogsDisabled" + ], + "RuleIds": [] + }, + { + "Id": "records-settings", + "AreaId": "records", + "LabelKey": "Feature.records-settings", + "PurposeKey": "FeaturePurpose.records-settings", + "ValueKey": "AreaValue.records", + "ExampleKey": "AreaExample.records", + "AdoptionKey": "FeatureAdoption.records-settings", + "ReleaseStatus": "available", + "Location": { + "Controller": "Records", + "Action": "Settings" + }, + "Requirements": [ + { + "Kind": "flag", + "Id": "Records.System" + }, + { + "Kind": "module", + "Id": "Logs" + } + ], + "SettingIds": [ + "field.RecordsDisclosureConfig.DefaultRedactionProfile", + "field.RecordsDisclosureConfig.ReleaseApproverUserId", + "field.RecordsDisclosureConfig.StatutoryClockDays", + "field.RecordsNumberingConfig.IncludeYear", + "field.RecordsNumberingConfig.NumberAssignment", + "field.RecordsNumberingConfig.PerGroupSequence", + "field.RecordsNumberingConfig.ResetYearly", + "field.RecordsNumberingConfig.SequenceWidth", + "field.RecordsRetentionOverride.AppliesFrom", + "field.RecordsRetentionOverride.DefinitionKey", + "field.RecordsRetentionOverride.RetentionYears", + "field.RecordsRetentionPolicy.DepartmentDefaultYears", + "field.RecordsRetentionPolicy.History", + "field.RecordsRetentionPolicy.LastChangedByUserId", + "field.RecordsRetentionPolicy.LastChangedOn", + "field.RecordsRetentionPolicy.Overrides", + "field.RecordsRetentionPolicyVersion.EffectiveOn", + "field.RecordsRetentionPolicyVersion.Policy", + "field.RecordsSearchConfig.IncludeLegacyHistory", + "field.RecordsSearchConfig.IndexNarrative", + "module.LogsDisabled", + "setting.RecordsDefaultLifecyclePreset", + "setting.RecordsDisclosureConfig", + "setting.RecordsGroupScopeConfig", + "setting.RecordsGroupVisibilityMode", + "setting.RecordsNumberingConfig", + "setting.RecordsRetentionPolicy", + "setting.RecordsReviewDueHours", + "setting.RecordsSearchConfig" + ], + "RuleIds": [] + }, + { + "Id": "reports", + "AreaId": "records", + "LabelKey": "Feature.reports", + "PurposeKey": "FeaturePurpose.reports", + "ValueKey": "AreaValue.records", + "ExampleKey": "AreaExample.records", + "AdoptionKey": "FeatureAdoption.reports", + "ReleaseStatus": "available", + "Location": { + "Controller": "Reports", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "module", + "Id": "Reports" + } + ], + "SettingIds": [ + "module.ReportsDisabled" + ], + "RuleIds": [] + }, + { + "Id": "record-definitions", + "AreaId": "records", + "LabelKey": "Feature.record-definitions", + "PurposeKey": "FeaturePurpose.record-definitions", + "ValueKey": "FeatureValue.record-definitions", + "ExampleKey": "AreaExample.records", + "AdoptionKey": "FeatureAdoption.record-definitions", + "ReleaseStatus": "available", + "Location": { + "Controller": "RecordDefinitions", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "flag", + "Id": "Records.System" + }, + { + "Kind": "permission", + "Id": "ManageRecordDefinitions" + } + ], + "SettingIds": [ + "permission.ManageRecordDefinitions" + ], + "RuleIds": [] + }, + { + "Id": "incident-reports", + "AreaId": "records", + "LabelKey": "Feature.incident-reports", + "PurposeKey": "FeaturePurpose.incident-reports", + "ValueKey": "FeatureValue.incident-reports", + "ExampleKey": "AreaExample.records", + "AdoptionKey": "FeatureAdoption.incident-reports", + "ReleaseStatus": "available", + "Location": { + "Controller": "IncidentReports", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "flag", + "Id": "Records.System" + } + ], + "SettingIds": [], + "RuleIds": [] + }, + { + "Id": "record-occupancies", + "AreaId": "records", + "LabelKey": "Feature.record-occupancies", + "PurposeKey": "FeaturePurpose.record-occupancies", + "ValueKey": "FeatureValue.record-occupancies", + "ExampleKey": "AreaExample.records", + "AdoptionKey": "FeatureAdoption.record-occupancies", + "ReleaseStatus": "available", + "Location": { + "Controller": "RecordOccupancies", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "flag", + "Id": "Records.System" + }, + { + "Kind": "flag", + "Id": "Records.Prevention.Occupancy" + } + ], + "SettingIds": [], + "RuleIds": [] + }, + { + "Id": "record-inspections", + "AreaId": "records", + "LabelKey": "Feature.record-inspections", + "PurposeKey": "FeaturePurpose.record-inspections", + "ValueKey": "FeatureValue.record-inspections", + "ExampleKey": "AreaExample.records", + "AdoptionKey": "FeatureAdoption.record-inspections", + "ReleaseStatus": "available", + "Location": { + "Controller": "RecordInspections", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "flag", + "Id": "Records.System" + }, + { + "Kind": "flag", + "Id": "Records.Prevention.Inspections" + } + ], + "SettingIds": [], + "RuleIds": [] + }, + { + "Id": "record-hydrants", + "AreaId": "records", + "LabelKey": "Feature.record-hydrants", + "PurposeKey": "FeaturePurpose.record-hydrants", + "ValueKey": "FeatureValue.record-hydrants", + "ExampleKey": "AreaExample.records", + "AdoptionKey": "FeatureAdoption.record-hydrants", + "ReleaseStatus": "available", + "Location": { + "Controller": "RecordHydrants", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "flag", + "Id": "Records.System" + }, + { + "Kind": "flag", + "Id": "Records.Prevention.Hydrants" + } + ], + "SettingIds": [], + "RuleIds": [] + }, + { + "Id": "record-permits", + "AreaId": "records", + "LabelKey": "Feature.record-permits", + "PurposeKey": "FeaturePurpose.record-permits", + "ValueKey": "FeatureValue.record-permits", + "ExampleKey": "AreaExample.records", + "AdoptionKey": "FeatureAdoption.record-permits", + "ReleaseStatus": "available", + "Location": { + "Controller": "RecordPermits", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "flag", + "Id": "Records.System" + }, + { + "Kind": "flag", + "Id": "Records.Prevention.Permits" + } + ], + "SettingIds": [], + "RuleIds": [] + }, + { + "Id": "record-investigations", + "AreaId": "records", + "LabelKey": "Feature.record-investigations", + "PurposeKey": "FeaturePurpose.record-investigations", + "ValueKey": "FeatureValue.record-investigations", + "ExampleKey": "AreaExample.records", + "AdoptionKey": "FeatureAdoption.record-investigations", + "ReleaseStatus": "available", + "Location": { + "Controller": "RecordInvestigations", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "flag", + "Id": "Records.System" + }, + { + "Kind": "flag", + "Id": "Records.Investigations" + } + ], + "SettingIds": [], + "RuleIds": [] + }, + { + "Id": "record-evidence", + "AreaId": "records", + "LabelKey": "Feature.record-evidence", + "PurposeKey": "FeaturePurpose.record-evidence", + "ValueKey": "FeatureValue.record-evidence", + "ExampleKey": "AreaExample.records", + "AdoptionKey": "FeatureAdoption.record-evidence", + "ReleaseStatus": "available", + "Location": { + "Controller": "Records", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "flag", + "Id": "Records.System" + } + ], + "SettingIds": [], + "RuleIds": [ + "record-review" + ] + }, + { + "Id": "record-holds", + "AreaId": "records", + "LabelKey": "Feature.record-holds", + "PurposeKey": "FeaturePurpose.record-holds", + "ValueKey": "FeatureValue.record-holds", + "ExampleKey": "AreaExample.records", + "AdoptionKey": "FeatureAdoption.record-holds", + "ReleaseStatus": "available", + "Location": { + "Controller": "RecordLegalHolds", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "flag", + "Id": "Records.System" + }, + { + "Kind": "permission", + "Id": "ManageRecordLegalHold" + } + ], + "SettingIds": [ + "permission.ManageRecordLegalHold" + ], + "RuleIds": [] + }, + { + "Id": "record-submissions", + "AreaId": "records", + "LabelKey": "Feature.record-submissions", + "PurposeKey": "FeaturePurpose.record-submissions", + "ValueKey": "FeatureValue.record-submissions", + "ExampleKey": "AreaExample.records", + "AdoptionKey": "FeatureAdoption.record-submissions", + "ReleaseStatus": "available", + "Location": { + "Controller": "IncidentReports", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "flag", + "Id": "Records.System" + }, + { + "Kind": "permission", + "Id": "SubmitRecords" + } + ], + "SettingIds": [ + "permission.SubmitRecords" + ], + "RuleIds": [] + }, + { + "Id": "record-analytics", + "AreaId": "records", + "LabelKey": "Feature.record-analytics", + "PurposeKey": "FeaturePurpose.record-analytics", + "ValueKey": "FeatureValue.record-analytics", + "ExampleKey": "AreaExample.records", + "AdoptionKey": "FeatureAdoption.record-analytics", + "ReleaseStatus": "available", + "Location": { + "Controller": "RecordsAnalytics", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "flag", + "Id": "Records.System" + }, + { + "Kind": "flag", + "Id": "Records.Analytics" + } + ], + "SettingIds": [], + "RuleIds": [] + }, + { + "Id": "record-saved-reports", + "AreaId": "records", + "LabelKey": "Feature.record-saved-reports", + "PurposeKey": "FeaturePurpose.record-saved-reports", + "ValueKey": "FeatureValue.record-saved-reports", + "ExampleKey": "AreaExample.records", + "AdoptionKey": "FeatureAdoption.record-saved-reports", + "ReleaseStatus": "available", + "Location": { + "Controller": "RecordSavedReports", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "flag", + "Id": "Records.System" + }, + { + "Kind": "permission", + "Id": "ManageRecordReports" + } + ], + "SettingIds": [ + "permission.ManageRecordReports" + ], + "RuleIds": [] + }, + { + "Id": "record-exports", + "AreaId": "records", + "LabelKey": "Feature.record-exports", + "PurposeKey": "FeaturePurpose.record-exports", + "ValueKey": "FeatureValue.record-exports", + "ExampleKey": "AreaExample.records", + "AdoptionKey": "FeatureAdoption.record-exports", + "ReleaseStatus": "available", + "Location": { + "Controller": "RecordsExportTemplates", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "flag", + "Id": "Records.System" + }, + { + "Kind": "permission", + "Id": "ManageRecordReports" + } + ], + "SettingIds": [ + "permission.ManageRecordReports" + ], + "RuleIds": [] + }, + { + "Id": "record-disclosures", + "AreaId": "records", + "LabelKey": "Feature.record-disclosures", + "PurposeKey": "FeaturePurpose.record-disclosures", + "ValueKey": "FeatureValue.record-disclosures", + "ExampleKey": "AreaExample.records", + "AdoptionKey": "FeatureAdoption.record-disclosures", + "ReleaseStatus": "available", + "Location": { + "Controller": "Disclosures", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "flag", + "Id": "Records.System" + } + ], + "SettingIds": [], + "RuleIds": [] + } + ], + "Rules": [ + { + "Id": "record-review", + "AreaId": "records", + "Severity": "Warning", + "TitleKey": "Rule.record-review", + "ExplanationKey": "RuleWhy.record-review", + "NextActionKey": "RuleNext.record-review", + "Location": { + "Controller": "Records", + "Action": "Index" + }, + "AppliesWhen": [], + "FailsWhen": [ + { + "EvidenceId": "overdueRecordReviewCount", + "Comparison": "Greater", + "Number": 0 + } + ] + } + ], + "Packs": [], + "Articles": [ + { + "Id": "setting.RecordsDefaultLifecyclePreset", + "Locale": "en", + "TitleKey": "Setting.RecordsDefaultLifecyclePreset", + "Body": "Records Default Lifecycle Preset. Default lifecycle for new department-owned definitions. Locked definitions retain their own lifecycle.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-recordsdefaultlifecyclepreset", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.RecordsReviewDueHours", + "Locale": "en", + "TitleKey": "Setting.RecordsReviewDueHours", + "Body": "Records Review Due Hours. Default administrative review deadline; a definition-level override takes precedence. This is not a statutory deadline inference.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-recordsreviewduehours", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.RecordsNumberingConfig", + "Locale": "en", + "TitleKey": "Setting.RecordsNumberingConfig", + "Body": "Records Numbering Config. Number assignment, year reset and sequence formatting for definitions that do not override the department configuration.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-recordsnumberingconfig", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.RecordsSearchConfig", + "Locale": "en", + "TitleKey": "Setting.RecordsSearchConfig", + "Body": "Records Search Config. Allowed record search scope and protected-data behavior. Search access remains bounded by current source permissions.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-recordssearchconfig", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.RecordsRetentionPolicy", + "Locale": "en", + "TitleKey": "Setting.RecordsRetentionPolicy", + "Body": "Records Retention Policy. Default and definition-specific retention, including prior-policy history. Holds and restricted-class rules can prevent purge eligibility.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-recordsretentionpolicy", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.RecordsGroupVisibilityMode", + "Locale": "en", + "TitleKey": "Setting.RecordsGroupVisibilityMode", + "Body": "Records Group Visibility Mode. Whether record visibility is department-wide or group scoped. Group scope narrows existing permission and never grants access.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-recordsgroupvisibilitymode", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.RecordsGroupScopeConfig", + "Locale": "en", + "TitleKey": "Setting.RecordsGroupScopeConfig", + "Body": "Records Group Scope Config. Reserved identifier with no editable behavior in this release. Do not present it as a shipped setting.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-recordsgroupscopeconfig", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.RecordsDisclosureConfig", + "Locale": "en", + "TitleKey": "Setting.RecordsDisclosureConfig", + "Body": "Records Disclosure Config. Disclosure review clock, redaction profile and release approver. Review local obligations through the authorized disclosure workflow.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-recordsdisclosureconfig", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "module.LogsDisabled", + "Locale": "en", + "TitleKey": "Module.LogsDisabled", + "Body": "Logs availability. Controls availability of legacy logs. Disabling this module can remove navigation and block module operations; it does not delete its records. Turning it on still requires the applicable subscription, rollout and permissions.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "module-logsdisabled", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "module.LogsNameOverride", + "Locale": "en", + "TitleKey": "Module.LogsNameOverride", + "Body": "Logs menu name. Optional display name for legacy logs in consumers that support the override. It changes the label, not permissions or functionality. This release has no separate name-override control in Module Settings.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "module-logsnameoverride", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "module.ReportsDisabled", + "Locale": "en", + "TitleKey": "Module.ReportsDisabled", + "Body": "Reports availability. Controls availability of reports. Disabling this module can remove navigation and block module operations; it does not delete its records. Turning it on still requires the applicable subscription, rollout and permissions.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "module-reportsdisabled", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "module.ReportsNameOverride", + "Locale": "en", + "TitleKey": "Module.ReportsNameOverride", + "Body": "Reports menu name. Optional display name for reports in consumers that support the override. It changes the label, not permissions or functionality. This release has no separate name-override control in Module Settings.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "module-reportsnameoverride", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "field.RecordsNumberingConfig.NumberAssignment", + "Locale": "en", + "TitleKey": "Field.RecordsNumberingConfig.NumberAssignment", + "Body": "Records Numbering Config / Number Assignment. Choose when numbers are assigned for definitions using department defaults. Review existing references before changing numbering behavior.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-recordsnumberingconfig-numberassignment", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "field.RecordsNumberingConfig.ResetYearly", + "Locale": "en", + "TitleKey": "Field.RecordsNumberingConfig.ResetYearly", + "Body": "Records Numbering Config / Reset Yearly. Restart record sequences each calendar year in the department time zone.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-recordsnumberingconfig-resetyearly", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "field.RecordsNumberingConfig.SequenceWidth", + "Locale": "en", + "TitleKey": "Field.RecordsNumberingConfig.SequenceWidth", + "Body": "Records Numbering Config / Sequence Width. Zero-padded sequence width for definitions using department defaults. The Records editor validates the supported width.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-recordsnumberingconfig-sequencewidth", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "field.RecordsNumberingConfig.IncludeYear", + "Locale": "en", + "TitleKey": "Field.RecordsNumberingConfig.IncludeYear", + "Body": "Records Numbering Config / Include Year. Include the year between the record prefix and sequence number.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-recordsnumberingconfig-includeyear", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "field.RecordsNumberingConfig.PerGroupSequence", + "Locale": "en", + "TitleKey": "Field.RecordsNumberingConfig.PerGroupSequence", + "Body": "Records Numbering Config / Per Group Sequence. Use separate station/group sequences. This changes numbering, not record visibility.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-recordsnumberingconfig-pergroupsequence", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "field.RecordsSearchConfig.IndexNarrative", + "Locale": "en", + "TitleKey": "Field.RecordsSearchConfig.IndexNarrative", + "Body": "Records Search Config / Index Narrative. Include unprotected narrative in search. Advanced Data Protection enrollment withdraws narrative indexing; this preference cannot override protection.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-recordssearchconfig-indexnarrative", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "field.RecordsSearchConfig.IncludeLegacyHistory", + "Locale": "en", + "TitleKey": "Field.RecordsSearchConfig.IncludeLegacyHistory", + "Body": "Records Search Config / Include Legacy History. Include supported legacy personnel and unit logs in the Records search scope. Source permissions still apply.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-recordssearchconfig-includelegacyhistory", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "field.RecordsRetentionPolicy.DepartmentDefaultYears", + "Locale": "en", + "TitleKey": "Field.RecordsRetentionPolicy.DepartmentDefaultYears", + "Body": "Records Retention Policy / Department Default Years. Department retention for standard record classes. Zero means permanent; protected classes, prior policy and holds can retain records longer.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-recordsretentionpolicy-departmentdefaultyears", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "field.RecordsRetentionPolicy.Overrides", + "Locale": "en", + "TitleKey": "Field.RecordsRetentionPolicy.Overrides", + "Body": "Records Retention Policy / Overrides. Definition-specific prospective retention overrides. Use the Records workflow and its confirmations; Admin Assist never purges records.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-recordsretentionpolicy-overrides", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "field.RecordsRetentionPolicy.LastChangedByUserId", + "Locale": "en", + "TitleKey": "Field.RecordsRetentionPolicy.LastChangedByUserId", + "Body": "Records Retention Policy / Last Changed By User Id. Recorded policy-change actor. It is not an editable retention rule and is not exposed as raw identity in this reference.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-recordsretentionpolicy-lastchangedbyuserid", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "field.RecordsRetentionPolicy.LastChangedOn", + "Locale": "en", + "TitleKey": "Field.RecordsRetentionPolicy.LastChangedOn", + "Body": "Records Retention Policy / Last Changed On. Policy effective timestamp used with prior policy versions. It is not the record's retention expiry date.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-recordsretentionpolicy-lastchangedon", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "field.RecordsRetentionPolicy.History", + "Locale": "en", + "TitleKey": "Field.RecordsRetentionPolicy.History", + "Body": "Records Retention Policy / History. Prior policy versions retained by the owning lifecycle so current changes do not silently rewrite historical retention.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-recordsretentionpolicy-history", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "field.RecordsRetentionOverride.DefinitionKey", + "Locale": "en", + "TitleKey": "Field.RecordsRetentionOverride.DefinitionKey", + "Body": "Records Retention Override / Definition Key. Stable record-definition key to which this override applies. Display names do not identify a retention policy.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-recordsretentionoverride-definitionkey", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "field.RecordsRetentionOverride.RetentionYears", + "Locale": "en", + "TitleKey": "Field.RecordsRetentionOverride.RetentionYears", + "Body": "Records Retention Override / Retention Years. Years retained under this definition override; zero means permanent. Holds and prospective policy resolution still apply.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-recordsretentionoverride-retentionyears", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "field.RecordsRetentionOverride.AppliesFrom", + "Locale": "en", + "TitleKey": "Field.RecordsRetentionOverride.AppliesFrom", + "Body": "Records Retention Override / Applies From. Prospective boundary for revisions eligible for the override, evaluated by the Records lifecycle.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-recordsretentionoverride-appliesfrom", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "field.RecordsRetentionPolicyVersion.EffectiveOn", + "Locale": "en", + "TitleKey": "Field.RecordsRetentionPolicyVersion.EffectiveOn", + "Body": "Records Retention Policy Version / Effective On. Effective timestamp of a stored historical retention policy version.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-recordsretentionpolicyversion-effectiveon", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "field.RecordsRetentionPolicyVersion.Policy", + "Locale": "en", + "TitleKey": "Field.RecordsRetentionPolicyVersion.Policy", + "Body": "Records Retention Policy Version / Policy. Historical policy snapshot used to resolve retention for older revisions. Never edit this as an ordinary preference.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-recordsretentionpolicyversion-policy", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "field.RecordsDisclosureConfig.StatutoryClockDays", + "Locale": "en", + "TitleKey": "Field.RecordsDisclosureConfig.StatutoryClockDays", + "Body": "Records Disclosure Config / Statutory Clock Days. Configured disclosure review clock. Verify local obligations with the responsible owner; the default does not establish a legal deadline.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-recordsdisclosureconfig-statutoryclockdays", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "field.RecordsDisclosureConfig.DefaultRedactionProfile", + "Locale": "en", + "TitleKey": "Field.RecordsDisclosureConfig.DefaultRedactionProfile", + "Body": "Records Disclosure Config / Default Redaction Profile. Default redaction profile in the owning disclosure workflow. Review the actual proposed release before approval.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-recordsdisclosureconfig-defaultredactionprofile", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "field.RecordsDisclosureConfig.ReleaseApproverUserId", + "Locale": "en", + "TitleKey": "Field.RecordsDisclosureConfig.ReleaseApproverUserId", + "Body": "Records Disclosure Config / Release Approver User Id. Authorized member responsible for disclosure release approval. Selecting an approver does not grant missing permissions or protected-data access.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-recordsdisclosureconfig-releaseapproveruserid", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "logs", + "Locale": "en", + "TitleKey": "Feature.logs", + "Body": "Logs Run, training, work and meeting logs Keep administrative follow-up and reporting evidence connected to the source record. Review an incident report and its outstanding corrections using the Records workflow. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "logs", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "new-log", + "Locale": "en", + "TitleKey": "Feature.new-log", + "Body": "New Log Create a run report, training log or work log Keep administrative follow-up and reporting evidence connected to the source record. Review an incident report and its outstanding corrections using the Records workflow. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "new-log", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "records", + "Locale": "en", + "TitleKey": "Feature.records", + "Body": "Records Records queue: run reports, training and operational records Keep administrative follow-up and reporting evidence connected to the source record. Review an incident report and its outstanding corrections using the Records workflow. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "records", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "records-dashboard", + "Locale": "en", + "TitleKey": "Feature.records-dashboard", + "Body": "Records Dashboard Records due, submissions and quality at a glance Keep administrative follow-up and reporting evidence connected to the source record. Review an incident report and its outstanding corrections using the Records workflow. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "records-dashboard", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "records-settings", + "Locale": "en", + "TitleKey": "Feature.records-settings", + "Body": "Records Settings Lifecycle, numbering, search, retention and visibility settings for Records Keep administrative follow-up and reporting evidence connected to the source record. Review an incident report and its outstanding corrections using the Records workflow. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "records-settings", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "reports", + "Locale": "en", + "TitleKey": "Feature.reports", + "Body": "Reports Generate reports from department data Keep administrative follow-up and reporting evidence connected to the source record. Review an incident report and its outstanding corrections using the Records workflow. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "reports", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "record-definitions", + "Locale": "en", + "TitleKey": "Feature.record-definitions", + "Body": "Record definitions and templates Configure record forms, lifecycle, numbering and review requirements. Fit reporting to approved local procedures while preserving built-in definitions. Review an incident report and its outstanding corrections using the Records workflow. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "record-definitions", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "incident-reports", + "Locale": "en", + "TitleKey": "Feature.incident-reports", + "Body": "Incident reports Prepare incident reports linked to authorized call evidence. Review source completeness before finalization or external submission. Review an incident report and its outstanding corrections using the Records workflow. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "incident-reports", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "record-occupancies", + "Locale": "en", + "TitleKey": "Feature.record-occupancies", + "Body": "Occupancies and preplans Maintain premises, contacts, hazards and review metadata. Keep site knowledge owned and current; recorded hazards do not certify safety. Review an incident report and its outstanding corrections using the Records workflow. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "record-occupancies", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "record-inspections", + "Locale": "en", + "TitleKey": "Feature.record-inspections", + "Body": "Inspections Schedule and record authorized inspection work and corrections. Track administrative follow-up against configured inspection procedures. Review an incident report and its outstanding corrections using the Records workflow. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "record-inspections", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "record-hydrants", + "Locale": "en", + "TitleKey": "Feature.record-hydrants", + "Body": "Hydrants and water sources Maintain hydrant and water-source records and their review history. Identify records needing verification without claiming current flow or availability. Review an incident report and its outstanding corrections using the Records workflow. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "record-hydrants", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "record-permits", + "Locale": "en", + "TitleKey": "Feature.record-permits", + "Body": "Permits Manage supported permit records and lifecycle. Assign review responsibility; a software state is not a hazardous-work safety clearance. Review an incident report and its outstanding corrections using the Records workflow. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "record-permits", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "record-investigations", + "Locale": "en", + "TitleKey": "Feature.record-investigations", + "Body": "Investigations Manage authorized investigation records and their controlled workflow. Keep case responsibility and access separate from general department visibility. Review an incident report and its outstanding corrections using the Records workflow. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "record-investigations", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "record-evidence", + "Locale": "en", + "TitleKey": "Feature.record-evidence", + "Body": "Evidence capture and provenance Capture supported source evidence from the owning record workflow. Preserve evidence context and access restrictions without copying it into setup guidance. Review an incident report and its outstanding corrections using the Records workflow. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "record-evidence", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "record-holds", + "Locale": "en", + "TitleKey": "Feature.record-holds", + "Body": "Legal holds Place and release holds through the authorized Records lifecycle. Retain affected evidence while a hold remains active. Review an incident report and its outstanding corrections using the Records workflow. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "record-holds", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "record-submissions", + "Locale": "en", + "TitleKey": "Feature.record-submissions", + "Body": "Reporting submissions Prepare and reconcile supported reporting submissions from an authorized incident report. Review destination requirements and actual submission outcomes. Review an incident report and its outstanding corrections using the Records workflow. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "record-submissions", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "record-analytics", + "Locale": "en", + "TitleKey": "Feature.record-analytics", + "Body": "Records analytics Review supported aggregate response, workload and readiness reports. Use measured source data with its scope and completeness limits. Review an incident report and its outstanding corrections using the Records workflow. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "record-analytics", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "record-saved-reports", + "Locale": "en", + "TitleKey": "Feature.record-saved-reports", + "Body": "Saved record reports Create reusable report selections over authorized record data. Make repeatable administrative reviews easier. Review an incident report and its outstanding corrections using the Records workflow. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "record-saved-reports", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "record-exports", + "Locale": "en", + "TitleKey": "Feature.record-exports", + "Body": "Record exports and templates Configure supported export templates and review their runs. Keep release destinations, protected access and output handling explicit. Review an incident report and its outstanding corrections using the Records workflow. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "record-exports", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "record-disclosures", + "Locale": "en", + "TitleKey": "Feature.record-disclosures", + "Body": "Disclosure requests Manage disclosure scope, redaction and release approval. Assign review ownership and verify the material approved for release. Review an incident report and its outstanding corrections using the Records workflow. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "record-disclosures", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + } + ] +} diff --git a/Core/Resgrid.AdminAssist/Catalog/security.yaml b/Core/Resgrid.AdminAssist/Catalog/security.yaml new file mode 100644 index 000000000..3281ba68a --- /dev/null +++ b/Core/Resgrid.AdminAssist/Catalog/security.yaml @@ -0,0 +1,7055 @@ +{ + "Version": "2026.09.24.1", + "Areas": [ + { + "Id": "security", + "LabelKey": "Area.security", + "PurposeKey": "AreaPurpose.security", + "Order": 11, + "Archetypes": [] + } + ], + "Settings": [ + { + "Id": "setting.TestEnabled", + "AreaId": "security", + "LabelKey": "Setting.TestEnabled", + "HelpKey": "SettingHelp.TestEnabled", + "Binding": "DepartmentSettingTypes.TestEnabled", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "Settings", + "Field": "TestEnabled" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.TestEnabled", + "DefaultValue": "false", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.UpdateTimestamp", + "AreaId": "security", + "LabelKey": "Setting.UpdateTimestamp", + "HelpKey": "SettingHelp.UpdateTimestamp", + "Binding": "DepartmentSettingTypes.UpdateTimestamp", + "ValueType": "internal", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "Settings", + "Field": "UpdateTimestamp" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.UpdateTimestamp", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.ModuleSettings", + "AreaId": "security", + "LabelKey": "Setting.ModuleSettings", + "HelpKey": "SettingHelp.ModuleSettings", + "Binding": "DepartmentSettingTypes.ModuleSettings", + "ValueType": "protobuf", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "ModuleSettings", + "Field": "ModuleSettings" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.ModuleSettings", + "DefaultValue": "all switches enabled, no label overrides", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.Require2FAForAdmins", + "AreaId": "security", + "LabelKey": "Setting.Require2FAForAdmins", + "HelpKey": "SettingHelp.Require2FAForAdmins", + "Binding": "DepartmentSettingTypes.Require2FAForAdmins", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index", + "Field": "Require2FAForAdmins" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.Require2FAForAdmins", + "DefaultValue": "0", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.ForceChatbotSecurityPin", + "AreaId": "security", + "LabelKey": "Setting.ForceChatbotSecurityPin", + "HelpKey": "SettingHelp.ForceChatbotSecurityPin", + "Binding": "DepartmentSettingTypes.ForceChatbotSecurityPin", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "Settings", + "Field": "ForceChatbotSecurityPin" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.ForceChatbotSecurityPin", + "DefaultValue": "false", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.RequirePasswordResetViaEmail", + "AreaId": "security", + "LabelKey": "Setting.RequirePasswordResetViaEmail", + "HelpKey": "SettingHelp.RequirePasswordResetViaEmail", + "Binding": "DepartmentSettingTypes.RequirePasswordResetViaEmail", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "Settings", + "Field": "RequirePasswordResetViaEmail" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.RequirePasswordResetViaEmail", + "DefaultValue": "false", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "setting.DepartmentOperatingProfile", + "AreaId": "security", + "LabelKey": "Setting.DepartmentOperatingProfile", + "HelpKey": "SettingHelp.DepartmentOperatingProfile", + "Binding": "DepartmentSettingTypes.DepartmentOperatingProfile", + "ValueType": "protobuf", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "OperatingProfile", + "Field": "DepartmentOperatingProfile" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.DepartmentOperatingProfile", + "DefaultValue": "unknown context, no selected archetypes", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "profile.Archetypes", + "AreaId": "security", + "LabelKey": "ProfileField.Archetypes", + "HelpKey": "ProfileFieldHelp.Archetypes", + "Binding": "DepartmentOperatingProfile.Archetypes", + "ValueType": "list", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "OperatingProfile", + "Field": "Archetypes" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.DepartmentOperatingProfile" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "profile.Archetypes", + "DefaultValue": "none", + "AllowedValues": "Validated by DepartmentOperatingProfile and the typed save boundary", + "EvidenceSource": "DepartmentSettingsService.GetOperatingProfileAsync", + "Availability": "available" + }, + { + "Id": "profile.WorkforceMix", + "AreaId": "security", + "LabelKey": "ProfileField.WorkforceMix", + "HelpKey": "ProfileFieldHelp.WorkforceMix", + "Binding": "DepartmentOperatingProfile.WorkforceMix", + "ValueType": "enum", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "OperatingProfile", + "Field": "WorkforceMix" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.DepartmentOperatingProfile" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "profile.WorkforceMix", + "DefaultValue": "unknown", + "AllowedValues": "Validated by DepartmentOperatingProfile and the typed save boundary", + "EvidenceSource": "DepartmentSettingsService.GetOperatingProfileAsync", + "Availability": "available" + }, + { + "Id": "profile.DeclaredMemberCount", + "AreaId": "security", + "LabelKey": "ProfileField.DeclaredMemberCount", + "HelpKey": "ProfileFieldHelp.DeclaredMemberCount", + "Binding": "DepartmentOperatingProfile.DeclaredMemberCount", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "OperatingProfile", + "Field": "DeclaredMemberCount" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.DepartmentOperatingProfile" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "profile.DeclaredMemberCount", + "DefaultValue": "unset", + "AllowedValues": "Validated by DepartmentOperatingProfile and the typed save boundary", + "EvidenceSource": "DepartmentSettingsService.GetOperatingProfileAsync", + "Availability": "available" + }, + { + "Id": "profile.AuthoritativeSystemReferences", + "AreaId": "security", + "LabelKey": "ProfileField.AuthoritativeSystemReferences", + "HelpKey": "ProfileFieldHelp.AuthoritativeSystemReferences", + "Binding": "DepartmentOperatingProfile.AuthoritativeSystemReferences", + "ValueType": "reference-list", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "OperatingProfile", + "Field": "AuthoritativeSystemReferences" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.DepartmentOperatingProfile" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "profile.AuthoritativeSystemReferences", + "DefaultValue": "empty", + "AllowedValues": "Validated by DepartmentOperatingProfile and the typed save boundary", + "EvidenceSource": "DepartmentSettingsService.GetOperatingProfileAsync", + "Availability": "available" + }, + { + "Id": "profile.DispatchModel", + "AreaId": "security", + "LabelKey": "ProfileField.DispatchModel", + "HelpKey": "ProfileFieldHelp.DispatchModel", + "Binding": "DepartmentOperatingProfile.DispatchModel", + "ValueType": "enum", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "OperatingProfile", + "Field": "DispatchModel" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.DepartmentOperatingProfile" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "profile.DispatchModel", + "DefaultValue": "unknown", + "AllowedValues": "Validated by DepartmentOperatingProfile and the typed save boundary", + "EvidenceSource": "DepartmentSettingsService.GetOperatingProfileAsync", + "Availability": "available" + }, + { + "Id": "profile.OperatingHours", + "AreaId": "security", + "LabelKey": "ProfileField.OperatingHours", + "HelpKey": "ProfileFieldHelp.OperatingHours", + "Binding": "DepartmentOperatingProfile.OperatingHours", + "ValueType": "enum", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "OperatingProfile", + "Field": "OperatingHours" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.DepartmentOperatingProfile" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "profile.OperatingHours", + "DefaultValue": "unknown", + "AllowedValues": "Validated by DepartmentOperatingProfile and the typed save boundary", + "EvidenceSource": "DepartmentSettingsService.GetOperatingProfileAsync", + "Availability": "available" + }, + { + "Id": "profile.SiteGroupReferences", + "AreaId": "security", + "LabelKey": "ProfileField.SiteGroupReferences", + "HelpKey": "ProfileFieldHelp.SiteGroupReferences", + "Binding": "DepartmentOperatingProfile.SiteGroupReferences", + "ValueType": "reference-list", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "OperatingProfile", + "Field": "SiteGroupReferences" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.DepartmentOperatingProfile" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "profile.SiteGroupReferences", + "DefaultValue": "empty", + "AllowedValues": "Validated by DepartmentOperatingProfile and the typed save boundary", + "EvidenceSource": "DepartmentSettingsService.GetOperatingProfileAsync", + "Availability": "available" + }, + { + "Id": "profile.MutualAid", + "AreaId": "security", + "LabelKey": "ProfileField.MutualAid", + "HelpKey": "ProfileFieldHelp.MutualAid", + "Binding": "DepartmentOperatingProfile.MutualAid", + "ValueType": "nullable-boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "OperatingProfile", + "Field": "MutualAid" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.DepartmentOperatingProfile" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "profile.MutualAid", + "DefaultValue": "unknown", + "AllowedValues": "Validated by DepartmentOperatingProfile and the typed save boundary", + "EvidenceSource": "DepartmentSettingsService.GetOperatingProfileAsync", + "Availability": "available" + }, + { + "Id": "profile.LanguageCodes", + "AreaId": "security", + "LabelKey": "ProfileField.LanguageCodes", + "HelpKey": "ProfileFieldHelp.LanguageCodes", + "Binding": "DepartmentOperatingProfile.LanguageCodes", + "ValueType": "list", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "OperatingProfile", + "Field": "LanguageCodes" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.DepartmentOperatingProfile" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "profile.LanguageCodes", + "DefaultValue": "empty", + "AllowedValues": "Validated by DepartmentOperatingProfile and the typed save boundary", + "EvidenceSource": "DepartmentSettingsService.GetOperatingProfileAsync", + "Availability": "available" + }, + { + "Id": "profile.AccessibilityNeeds", + "AreaId": "security", + "LabelKey": "ProfileField.AccessibilityNeeds", + "HelpKey": "ProfileFieldHelp.AccessibilityNeeds", + "Binding": "DepartmentOperatingProfile.AccessibilityNeeds", + "ValueType": "list", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "OperatingProfile", + "Field": "AccessibilityNeeds" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.DepartmentOperatingProfile" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "profile.AccessibilityNeeds", + "DefaultValue": "empty", + "AllowedValues": "Validated by DepartmentOperatingProfile and the typed save boundary", + "EvidenceSource": "DepartmentSettingsService.GetOperatingProfileAsync", + "Availability": "available" + }, + { + "Id": "profile.StaffingPolicyReferences", + "AreaId": "security", + "LabelKey": "ProfileField.StaffingPolicyReferences", + "HelpKey": "ProfileFieldHelp.StaffingPolicyReferences", + "Binding": "DepartmentOperatingProfile.StaffingPolicyReferences", + "ValueType": "reference-list", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "OperatingProfile", + "Field": "StaffingPolicyReferences" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.DepartmentOperatingProfile" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "profile.StaffingPolicyReferences", + "DefaultValue": "empty", + "AllowedValues": "Validated by DepartmentOperatingProfile and the typed save boundary", + "EvidenceSource": "DepartmentSettingsService.GetOperatingProfileAsync", + "Availability": "available" + }, + { + "Id": "profile.QualificationPolicyReferences", + "AreaId": "security", + "LabelKey": "ProfileField.QualificationPolicyReferences", + "HelpKey": "ProfileFieldHelp.QualificationPolicyReferences", + "Binding": "DepartmentOperatingProfile.QualificationPolicyReferences", + "ValueType": "reference-list", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "OperatingProfile", + "Field": "QualificationPolicyReferences" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.DepartmentOperatingProfile" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "profile.QualificationPolicyReferences", + "DefaultValue": "empty", + "AllowedValues": "Validated by DepartmentOperatingProfile and the typed save boundary", + "EvidenceSource": "DepartmentSettingsService.GetOperatingProfileAsync", + "Availability": "available" + }, + { + "Id": "profile.ContinuityProcedureReferences", + "AreaId": "security", + "LabelKey": "ProfileField.ContinuityProcedureReferences", + "HelpKey": "ProfileFieldHelp.ContinuityProcedureReferences", + "Binding": "DepartmentOperatingProfile.ContinuityProcedureReferences", + "ValueType": "reference-list", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "OperatingProfile", + "Field": "ContinuityProcedureReferences" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.DepartmentOperatingProfile" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "profile.ContinuityProcedureReferences", + "DefaultValue": "empty", + "AllowedValues": "Validated by DepartmentOperatingProfile and the typed save boundary", + "EvidenceSource": "DepartmentSettingsService.GetOperatingProfileAsync", + "Availability": "available" + }, + { + "Id": "profile.SeasonStartMonthDay", + "AreaId": "security", + "LabelKey": "ProfileField.SeasonStartMonthDay", + "HelpKey": "ProfileFieldHelp.SeasonStartMonthDay", + "Binding": "DepartmentOperatingProfile.SeasonStartMonthDay", + "ValueType": "date-part", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "OperatingProfile", + "Field": "SeasonStartMonthDay" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.DepartmentOperatingProfile" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "profile.SeasonStartMonthDay", + "DefaultValue": "unset", + "AllowedValues": "Validated by DepartmentOperatingProfile and the typed save boundary", + "EvidenceSource": "DepartmentSettingsService.GetOperatingProfileAsync", + "Availability": "available" + }, + { + "Id": "profile.SeasonEndMonthDay", + "AreaId": "security", + "LabelKey": "ProfileField.SeasonEndMonthDay", + "HelpKey": "ProfileFieldHelp.SeasonEndMonthDay", + "Binding": "DepartmentOperatingProfile.SeasonEndMonthDay", + "ValueType": "date-part", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "OperatingProfile", + "Field": "SeasonEndMonthDay" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.DepartmentOperatingProfile" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "profile.SeasonEndMonthDay", + "DefaultValue": "unset", + "AllowedValues": "Validated by DepartmentOperatingProfile and the typed save boundary", + "EvidenceSource": "DepartmentSettingsService.GetOperatingProfileAsync", + "Availability": "available" + }, + { + "Id": "profile.ReviewedOnUtc", + "AreaId": "security", + "LabelKey": "ProfileField.ReviewedOnUtc", + "HelpKey": "ProfileFieldHelp.ReviewedOnUtc", + "Binding": "DepartmentOperatingProfile.ReviewedOnUtc", + "ValueType": "server-metadata", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "OperatingProfile", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.DepartmentOperatingProfile" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "profile.ReviewedOnUtc", + "DefaultValue": "unset", + "AllowedValues": "Validated by DepartmentOperatingProfile and the typed save boundary", + "EvidenceSource": "DepartmentSettingsService.GetOperatingProfileAsync", + "Availability": "read-only" + }, + { + "Id": "profile.Revision", + "AreaId": "security", + "LabelKey": "ProfileField.Revision", + "HelpKey": "ProfileFieldHelp.Revision", + "Binding": "DepartmentOperatingProfile.Revision", + "ValueType": "server-metadata", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "OperatingProfile", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.DepartmentOperatingProfile" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "profile.Revision", + "DefaultValue": "0", + "AllowedValues": "Validated by DepartmentOperatingProfile and the typed save boundary", + "EvidenceSource": "DepartmentSettingsService.GetOperatingProfileAsync", + "Availability": "read-only" + }, + { + "Id": "profile.ExpectedEmailPollIntervalMinutes", + "AreaId": "security", + "LabelKey": "ProfileField.ExpectedEmailPollIntervalMinutes", + "HelpKey": "ProfileFieldHelp.ExpectedEmailPollIntervalMinutes", + "Binding": "DepartmentOperatingProfile.ExpectedEmailPollIntervalMinutes", + "ValueType": "nullable-integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "OperatingProfile", + "Field": "ExpectedEmailPollIntervalMinutes" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.DepartmentOperatingProfile" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "profile.ExpectedEmailPollIntervalMinutes", + "DefaultValue": "unset", + "AllowedValues": "Validated by DepartmentOperatingProfile and the typed save boundary", + "EvidenceSource": "DepartmentSettingsService.GetOperatingProfileAsync", + "Availability": "available" + }, + { + "Id": "permission.CreateCall", + "AreaId": "security", + "LabelKey": "Permission.CreateCall", + "HelpKey": "PermissionHelp.CreateCall", + "Binding": "PermissionTypes.CreateCall", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index", + "Field": "CreateCall" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.CreateCall", + "DefaultValue": "No permission row: everyone; no group lock", + "AllowedValues": "Actions 0–3, selected department role IDs for action 2, and group lock for resource visibility gates only", + "EvidenceSource": "PermissionsService.IsUserAllowed / ResourceVisibilityPermission", + "Availability": "available" + }, + { + "Id": "permission.CreateNote", + "AreaId": "security", + "LabelKey": "Permission.CreateNote", + "HelpKey": "PermissionHelp.CreateNote", + "Binding": "PermissionTypes.CreateNote", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index", + "Field": "CreateNote" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.CreateNote", + "DefaultValue": "No permission row: everyone; no group lock", + "AllowedValues": "Actions 0–3, selected department role IDs for action 2, and group lock for resource visibility gates only", + "EvidenceSource": "PermissionsService.IsUserAllowed / ResourceVisibilityPermission", + "Availability": "available" + }, + { + "Id": "permission.ViewPersonalInfo", + "AreaId": "security", + "LabelKey": "Permission.ViewPersonalInfo", + "HelpKey": "PermissionHelp.ViewPersonalInfo", + "Binding": "PermissionTypes.ViewPersonalInfo", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index", + "Field": "ViewPersonalInfo" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.ViewPersonalInfo", + "DefaultValue": "No permission row: everyone; no group lock", + "AllowedValues": "Actions 0–3, selected department role IDs for action 2, and group lock for resource visibility gates only", + "EvidenceSource": "PermissionsService.IsUserAllowed / ResourceVisibilityPermission", + "Availability": "available" + }, + { + "Id": "permission.ViewGroupUsers", + "AreaId": "security", + "LabelKey": "Permission.ViewGroupUsers", + "HelpKey": "PermissionHelp.ViewGroupUsers", + "Binding": "PermissionTypes.ViewGroupUsers", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index", + "Field": "ViewGroupsUsers" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.ViewGroupUsers", + "DefaultValue": "No permission row: everyone; no group lock", + "AllowedValues": "Actions 0–3, selected department role IDs for action 2, and group lock for resource visibility gates only", + "EvidenceSource": "PermissionsService.IsUserAllowed / ResourceVisibilityPermission", + "Availability": "available" + }, + { + "Id": "permission.ViewGroupUnits", + "AreaId": "security", + "LabelKey": "Permission.ViewGroupUnits", + "HelpKey": "PermissionHelp.ViewGroupUnits", + "Binding": "PermissionTypes.ViewGroupUnits", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index", + "Field": "ViewGroupsUnits" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.ViewGroupUnits", + "DefaultValue": "No permission row: everyone; no group lock", + "AllowedValues": "Actions 0–3, selected department role IDs for action 2, and group lock for resource visibility gates only", + "EvidenceSource": "PermissionsService.IsUserAllowed / ResourceVisibilityPermission", + "Availability": "available" + }, + { + "Id": "permission.CanSeePersonnelLocations", + "AreaId": "security", + "LabelKey": "Permission.CanSeePersonnelLocations", + "HelpKey": "PermissionHelp.CanSeePersonnelLocations", + "Binding": "PermissionTypes.CanSeePersonnelLocations", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index", + "Field": "ViewPersonnelLocation" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.CanSeePersonnelLocations", + "DefaultValue": "No permission row: everyone; no group lock", + "AllowedValues": "Actions 0–3, selected department role IDs for action 2, and group lock for resource visibility gates only", + "EvidenceSource": "PermissionsService.IsUserAllowed / ResourceVisibilityPermission", + "Availability": "available" + }, + { + "Id": "permission.CanSeeUnitLocations", + "AreaId": "security", + "LabelKey": "Permission.CanSeeUnitLocations", + "HelpKey": "PermissionHelp.CanSeeUnitLocations", + "Binding": "PermissionTypes.CanSeeUnitLocations", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index", + "Field": "ViewUnitLocation" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.CanSeeUnitLocations", + "DefaultValue": "No permission row: everyone; no group lock", + "AllowedValues": "Actions 0–3, selected department role IDs for action 2, and group lock for resource visibility gates only", + "EvidenceSource": "PermissionsService.IsUserAllowed / ResourceVisibilityPermission", + "Availability": "available" + }, + { + "Id": "permission.AddPersonnel", + "AreaId": "security", + "LabelKey": "Permission.AddPersonnel", + "HelpKey": "PermissionHelp.AddPersonnel", + "Binding": "PermissionTypes.AddPersonnel", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.AddPersonnel", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.AddPersonnel", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.RemovePersonnel", + "AreaId": "security", + "LabelKey": "Permission.RemovePersonnel", + "HelpKey": "PermissionHelp.RemovePersonnel", + "Binding": "PermissionTypes.RemovePersonnel", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.RemovePersonnel", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.RemovePersonnel", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.CreateTraining", + "AreaId": "security", + "LabelKey": "Permission.CreateTraining", + "HelpKey": "PermissionHelp.CreateTraining", + "Binding": "PermissionTypes.CreateTraining", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.CreateTraining", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.CreateTraining", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.CreateDocument", + "AreaId": "security", + "LabelKey": "Permission.CreateDocument", + "HelpKey": "PermissionHelp.CreateDocument", + "Binding": "PermissionTypes.CreateDocument", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.CreateDocument", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.CreateDocument", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.CreateCalendarEntry", + "AreaId": "security", + "LabelKey": "Permission.CreateCalendarEntry", + "HelpKey": "PermissionHelp.CreateCalendarEntry", + "Binding": "PermissionTypes.CreateCalendarEntry", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.CreateCalendarEntry", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.CreateCalendarEntry", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.CreateLog", + "AreaId": "security", + "LabelKey": "Permission.CreateLog", + "HelpKey": "PermissionHelp.CreateLog", + "Binding": "PermissionTypes.CreateLog", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.CreateLog", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.CreateLog", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.CreateShift", + "AreaId": "security", + "LabelKey": "Permission.CreateShift", + "HelpKey": "PermissionHelp.CreateShift", + "Binding": "PermissionTypes.CreateShift", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.CreateShift", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.CreateShift", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.AdjustInventory", + "AreaId": "security", + "LabelKey": "Permission.AdjustInventory", + "HelpKey": "PermissionHelp.AdjustInventory", + "Binding": "PermissionTypes.AdjustInventory", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.AdjustInventory", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.AdjustInventory", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.CreateMessage", + "AreaId": "security", + "LabelKey": "Permission.CreateMessage", + "HelpKey": "PermissionHelp.CreateMessage", + "Binding": "PermissionTypes.CreateMessage", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.CreateMessage", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.CreateMessage", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.DeleteCall", + "AreaId": "security", + "LabelKey": "Permission.DeleteCall", + "HelpKey": "PermissionHelp.DeleteCall", + "Binding": "PermissionTypes.DeleteCall", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.DeleteCall", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.DeleteCall", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.CloseCall", + "AreaId": "security", + "LabelKey": "Permission.CloseCall", + "HelpKey": "PermissionHelp.CloseCall", + "Binding": "PermissionTypes.CloseCall", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.CloseCall", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.CloseCall", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.AddCallData", + "AreaId": "security", + "LabelKey": "Permission.AddCallData", + "HelpKey": "PermissionHelp.AddCallData", + "Binding": "PermissionTypes.AddCallData", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.AddCallData", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.AddCallData", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.ContactEdit", + "AreaId": "security", + "LabelKey": "Permission.ContactEdit", + "HelpKey": "PermissionHelp.ContactEdit", + "Binding": "PermissionTypes.ContactEdit", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.ContactEdit", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.ContactEdit", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.ContactView", + "AreaId": "security", + "LabelKey": "Permission.ContactView", + "HelpKey": "PermissionHelp.ContactView", + "Binding": "PermissionTypes.ContactView", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.ContactView", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.ContactView", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.ContactDelete", + "AreaId": "security", + "LabelKey": "Permission.ContactDelete", + "HelpKey": "PermissionHelp.ContactDelete", + "Binding": "PermissionTypes.ContactDelete", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.ContactDelete", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.ContactDelete", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.CreateWorkflow", + "AreaId": "security", + "LabelKey": "Permission.CreateWorkflow", + "HelpKey": "PermissionHelp.CreateWorkflow", + "Binding": "PermissionTypes.CreateWorkflow", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.CreateWorkflow", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.CreateWorkflow", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.ManageWorkflowCredentials", + "AreaId": "security", + "LabelKey": "Permission.ManageWorkflowCredentials", + "HelpKey": "PermissionHelp.ManageWorkflowCredentials", + "Binding": "PermissionTypes.ManageWorkflowCredentials", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.ManageWorkflowCredentials", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.ManageWorkflowCredentials", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.ViewWorkflowRuns", + "AreaId": "security", + "LabelKey": "Permission.ViewWorkflowRuns", + "HelpKey": "PermissionHelp.ViewWorkflowRuns", + "Binding": "PermissionTypes.ViewWorkflowRuns", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.ViewWorkflowRuns", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.ViewWorkflowRuns", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.ViewUdfFields", + "AreaId": "security", + "LabelKey": "Permission.ViewUdfFields", + "HelpKey": "PermissionHelp.ViewUdfFields", + "Binding": "PermissionTypes.ViewUdfFields", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.ViewUdfFields", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.ViewUdfFields", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.ManageRoutes", + "AreaId": "security", + "LabelKey": "Permission.ManageRoutes", + "HelpKey": "PermissionHelp.ManageRoutes", + "Binding": "PermissionTypes.ManageRoutes", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.ManageRoutes", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.ManageRoutes", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.DeleteLog", + "AreaId": "security", + "LabelKey": "Permission.DeleteLog", + "HelpKey": "PermissionHelp.DeleteLog", + "Binding": "PermissionTypes.DeleteLog", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.DeleteLog", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.DeleteLog", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.UseCalendarSync", + "AreaId": "security", + "LabelKey": "Permission.UseCalendarSync", + "HelpKey": "PermissionHelp.UseCalendarSync", + "Binding": "PermissionTypes.UseCalendarSync", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.UseCalendarSync", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.UseCalendarSync", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.DispatchAppLogin", + "AreaId": "security", + "LabelKey": "Permission.DispatchAppLogin", + "HelpKey": "PermissionHelp.DispatchAppLogin", + "Binding": "PermissionTypes.DispatchAppLogin", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.DispatchAppLogin", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.DispatchAppLogin", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.CommandAppLogin", + "AreaId": "security", + "LabelKey": "Permission.CommandAppLogin", + "HelpKey": "PermissionHelp.CommandAppLogin", + "Binding": "PermissionTypes.CommandAppLogin", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.CommandAppLogin", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.CommandAppLogin", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.ManageDepartmentDataProtection", + "AreaId": "security", + "LabelKey": "Permission.ManageDepartmentDataProtection", + "HelpKey": "PermissionHelp.ManageDepartmentDataProtection", + "Binding": "PermissionTypes.ManageDepartmentDataProtection", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.ManageDepartmentDataProtection", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.ManageDepartmentDataProtection", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.ViewProtectedCallData", + "AreaId": "security", + "LabelKey": "Permission.ViewProtectedCallData", + "HelpKey": "PermissionHelp.ViewProtectedCallData", + "Binding": "PermissionTypes.ViewProtectedCallData", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.ViewProtectedCallData", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.ViewProtectedCallData", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.EditProtectedCallData", + "AreaId": "security", + "LabelKey": "Permission.EditProtectedCallData", + "HelpKey": "PermissionHelp.EditProtectedCallData", + "Binding": "PermissionTypes.EditProtectedCallData", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.EditProtectedCallData", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.EditProtectedCallData", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.ViewProtectedPersonnelData", + "AreaId": "security", + "LabelKey": "Permission.ViewProtectedPersonnelData", + "HelpKey": "PermissionHelp.ViewProtectedPersonnelData", + "Binding": "PermissionTypes.ViewProtectedPersonnelData", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.ViewProtectedPersonnelData", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.ViewProtectedPersonnelData", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.ViewProtectedContactData", + "AreaId": "security", + "LabelKey": "Permission.ViewProtectedContactData", + "HelpKey": "PermissionHelp.ViewProtectedContactData", + "Binding": "PermissionTypes.ViewProtectedContactData", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.ViewProtectedContactData", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.ViewProtectedContactData", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.ViewProtectedOperationalData", + "AreaId": "security", + "LabelKey": "Permission.ViewProtectedOperationalData", + "HelpKey": "PermissionHelp.ViewProtectedOperationalData", + "Binding": "PermissionTypes.ViewProtectedOperationalData", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.ViewProtectedOperationalData", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.ViewProtectedOperationalData", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.ExportProtectedData", + "AreaId": "security", + "LabelKey": "Permission.ExportProtectedData", + "HelpKey": "PermissionHelp.ExportProtectedData", + "Binding": "PermissionTypes.ExportProtectedData", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.ExportProtectedData", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.ExportProtectedData", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.ConfigureProtectedDataEgress", + "AreaId": "security", + "LabelKey": "Permission.ConfigureProtectedDataEgress", + "HelpKey": "PermissionHelp.ConfigureProtectedDataEgress", + "Binding": "PermissionTypes.ConfigureProtectedDataEgress", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.ConfigureProtectedDataEgress", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.ConfigureProtectedDataEgress", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.BreakGlassProtectedData", + "AreaId": "security", + "LabelKey": "Permission.BreakGlassProtectedData", + "HelpKey": "PermissionHelp.BreakGlassProtectedData", + "Binding": "PermissionTypes.BreakGlassProtectedData", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.BreakGlassProtectedData", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.BreakGlassProtectedData", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.CreateRecord", + "AreaId": "security", + "LabelKey": "Permission.CreateRecord", + "HelpKey": "PermissionHelp.CreateRecord", + "Binding": "PermissionTypes.CreateRecord", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.CreateRecord", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.CreateRecord", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.DeleteRecord", + "AreaId": "security", + "LabelKey": "Permission.DeleteRecord", + "HelpKey": "PermissionHelp.DeleteRecord", + "Binding": "PermissionTypes.DeleteRecord", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.DeleteRecord", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.DeleteRecord", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.ReviewRecords", + "AreaId": "security", + "LabelKey": "Permission.ReviewRecords", + "HelpKey": "PermissionHelp.ReviewRecords", + "Binding": "PermissionTypes.ReviewRecords", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.ReviewRecords", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.ReviewRecords", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.ApproveRecords", + "AreaId": "security", + "LabelKey": "Permission.ApproveRecords", + "HelpKey": "PermissionHelp.ApproveRecords", + "Binding": "PermissionTypes.ApproveRecords", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.ApproveRecords", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.ApproveRecords", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.FinalizeRecords", + "AreaId": "security", + "LabelKey": "Permission.FinalizeRecords", + "HelpKey": "PermissionHelp.FinalizeRecords", + "Binding": "PermissionTypes.FinalizeRecords", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.FinalizeRecords", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.FinalizeRecords", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.AmendRecords", + "AreaId": "security", + "LabelKey": "Permission.AmendRecords", + "HelpKey": "PermissionHelp.AmendRecords", + "Binding": "PermissionTypes.AmendRecords", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.AmendRecords", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.AmendRecords", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.SubmitRecords", + "AreaId": "security", + "LabelKey": "Permission.SubmitRecords", + "HelpKey": "PermissionHelp.SubmitRecords", + "Binding": "PermissionTypes.SubmitRecords", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.SubmitRecords", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.SubmitRecords", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.ExportRecords", + "AreaId": "security", + "LabelKey": "Permission.ExportRecords", + "HelpKey": "PermissionHelp.ExportRecords", + "Binding": "PermissionTypes.ExportRecords", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.ExportRecords", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.ExportRecords", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.ShareRecordsExternally", + "AreaId": "security", + "LabelKey": "Permission.ShareRecordsExternally", + "HelpKey": "PermissionHelp.ShareRecordsExternally", + "Binding": "PermissionTypes.ShareRecordsExternally", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.ShareRecordsExternally", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.ShareRecordsExternally", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.ViewRestrictedRecords", + "AreaId": "security", + "LabelKey": "Permission.ViewRestrictedRecords", + "HelpKey": "PermissionHelp.ViewRestrictedRecords", + "Binding": "PermissionTypes.ViewRestrictedRecords", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.ViewRestrictedRecords", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.ViewRestrictedRecords", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.ViewLegacyRecords", + "AreaId": "security", + "LabelKey": "Permission.ViewLegacyRecords", + "HelpKey": "PermissionHelp.ViewLegacyRecords", + "Binding": "PermissionTypes.ViewLegacyRecords", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.ViewLegacyRecords", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.ViewLegacyRecords", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.ViewGroupRecords", + "AreaId": "security", + "LabelKey": "Permission.ViewGroupRecords", + "HelpKey": "PermissionHelp.ViewGroupRecords", + "Binding": "PermissionTypes.ViewGroupRecords", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.ViewGroupRecords", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.ViewGroupRecords", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.ManageRecordDefinitions", + "AreaId": "security", + "LabelKey": "Permission.ManageRecordDefinitions", + "HelpKey": "PermissionHelp.ManageRecordDefinitions", + "Binding": "PermissionTypes.ManageRecordDefinitions", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.ManageRecordDefinitions", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.ManageRecordDefinitions", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.PublishRecordDefinitions", + "AreaId": "security", + "LabelKey": "Permission.PublishRecordDefinitions", + "HelpKey": "PermissionHelp.PublishRecordDefinitions", + "Binding": "PermissionTypes.PublishRecordDefinitions", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.PublishRecordDefinitions", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.PublishRecordDefinitions", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.ManageRecordReports", + "AreaId": "security", + "LabelKey": "Permission.ManageRecordReports", + "HelpKey": "PermissionHelp.ManageRecordReports", + "Binding": "PermissionTypes.ManageRecordReports", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.ManageRecordReports", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.ManageRecordReports", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.ManageRecordDisclosures", + "AreaId": "security", + "LabelKey": "Permission.ManageRecordDisclosures", + "HelpKey": "PermissionHelp.ManageRecordDisclosures", + "Binding": "PermissionTypes.ManageRecordDisclosures", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.ManageRecordDisclosures", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.ManageRecordDisclosures", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.ManageRecordLegalHold", + "AreaId": "security", + "LabelKey": "Permission.ManageRecordLegalHold", + "HelpKey": "PermissionHelp.ManageRecordLegalHold", + "Binding": "PermissionTypes.ManageRecordLegalHold", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.ManageRecordLegalHold", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.ManageRecordLegalHold", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.ReassignRecordDrafts", + "AreaId": "security", + "LabelKey": "Permission.ReassignRecordDrafts", + "HelpKey": "PermissionHelp.ReassignRecordDrafts", + "Binding": "PermissionTypes.ReassignRecordDrafts", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.ReassignRecordDrafts", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.ReassignRecordDrafts", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.RecordsPreventionAdmin", + "AreaId": "security", + "LabelKey": "Permission.RecordsPreventionAdmin", + "HelpKey": "PermissionHelp.RecordsPreventionAdmin", + "Binding": "PermissionTypes.RecordsPreventionAdmin", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.RecordsPreventionAdmin", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.RecordsPreventionAdmin", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.ManageChecklists", + "AreaId": "security", + "LabelKey": "Permission.ManageChecklists", + "HelpKey": "PermissionHelp.ManageChecklists", + "Binding": "PermissionTypes.ManageChecklists", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.ManageChecklists", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.ManageChecklists", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.ViewChecklistResults", + "AreaId": "security", + "LabelKey": "Permission.ViewChecklistResults", + "HelpKey": "PermissionHelp.ViewChecklistResults", + "Binding": "PermissionTypes.ViewChecklistResults", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.ViewChecklistResults", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.ViewChecklistResults", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.ManageWorkOrders", + "AreaId": "security", + "LabelKey": "Permission.ManageWorkOrders", + "HelpKey": "PermissionHelp.ManageWorkOrders", + "Binding": "PermissionTypes.ManageWorkOrders", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.ManageWorkOrders", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.ManageWorkOrders", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.ViewAllWorkOrders", + "AreaId": "security", + "LabelKey": "Permission.ViewAllWorkOrders", + "HelpKey": "PermissionHelp.ViewAllWorkOrders", + "Binding": "PermissionTypes.ViewAllWorkOrders", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.ViewAllWorkOrders", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.ViewAllWorkOrders", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.TransferInventory", + "AreaId": "security", + "LabelKey": "Permission.TransferInventory", + "HelpKey": "PermissionHelp.TransferInventory", + "Binding": "PermissionTypes.TransferInventory", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.TransferInventory", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.TransferInventory", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.IssueInventory", + "AreaId": "security", + "LabelKey": "Permission.IssueInventory", + "HelpKey": "PermissionHelp.IssueInventory", + "Binding": "PermissionTypes.IssueInventory", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.IssueInventory", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.IssueInventory", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.ManageControlledSubstances", + "AreaId": "security", + "LabelKey": "Permission.ManageControlledSubstances", + "HelpKey": "PermissionHelp.ManageControlledSubstances", + "Binding": "PermissionTypes.ManageControlledSubstances", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.ManageControlledSubstances", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.ManageControlledSubstances", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.ManageInvoicing", + "AreaId": "security", + "LabelKey": "Permission.ManageInvoicing", + "HelpKey": "PermissionHelp.ManageInvoicing", + "Binding": "PermissionTypes.ManageInvoicing", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.ManageInvoicing", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.ManageInvoicing", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.ViewInvoicing", + "AreaId": "security", + "LabelKey": "Permission.ViewInvoicing", + "HelpKey": "PermissionHelp.ViewInvoicing", + "Binding": "PermissionTypes.ViewInvoicing", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.ViewInvoicing", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.ViewInvoicing", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.ManageCertifications", + "AreaId": "security", + "LabelKey": "Permission.ManageCertifications", + "HelpKey": "PermissionHelp.ManageCertifications", + "Binding": "PermissionTypes.ManageCertifications", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.ManageCertifications", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.ManageCertifications", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.ViewCertifications", + "AreaId": "security", + "LabelKey": "Permission.ViewCertifications", + "HelpKey": "PermissionHelp.ViewCertifications", + "Binding": "PermissionTypes.ViewCertifications", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.ViewCertifications", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.ViewCertifications", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.ManageCertificationSetup", + "AreaId": "security", + "LabelKey": "Permission.ManageCertificationSetup", + "HelpKey": "PermissionHelp.ManageCertificationSetup", + "Binding": "PermissionTypes.ManageCertificationSetup", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.ManageCertificationSetup", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.ManageCertificationSetup", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.ManageBids", + "AreaId": "security", + "LabelKey": "Permission.ManageBids", + "HelpKey": "PermissionHelp.ManageBids", + "Binding": "PermissionTypes.ManageBids", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.ManageBids", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.ManageBids", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.ManageContracts", + "AreaId": "security", + "LabelKey": "Permission.ManageContracts", + "HelpKey": "PermissionHelp.ManageContracts", + "Binding": "PermissionTypes.ManageContracts", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.ManageContracts", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.ManageContracts", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.ManageDeployments", + "AreaId": "security", + "LabelKey": "Permission.ManageDeployments", + "HelpKey": "PermissionHelp.ManageDeployments", + "Binding": "PermissionTypes.ManageDeployments", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.ManageDeployments", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.ManageDeployments", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.ApproveTimeReports", + "AreaId": "security", + "LabelKey": "Permission.ApproveTimeReports", + "HelpKey": "PermissionHelp.ApproveTimeReports", + "Binding": "PermissionTypes.ApproveTimeReports", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.ApproveTimeReports", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.ApproveTimeReports", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.ManageMutualAidReimbursement", + "AreaId": "security", + "LabelKey": "Permission.ManageMutualAidReimbursement", + "HelpKey": "PermissionHelp.ManageMutualAidReimbursement", + "Binding": "PermissionTypes.ManageMutualAidReimbursement", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.ManageMutualAidReimbursement", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.ManageMutualAidReimbursement", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.ViewInternalCosts", + "AreaId": "security", + "LabelKey": "Permission.ViewInternalCosts", + "HelpKey": "PermissionHelp.ViewInternalCosts", + "Binding": "PermissionTypes.ViewInternalCosts", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.ViewInternalCosts", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.ViewInternalCosts", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.ManageWorkforceCompensation", + "AreaId": "security", + "LabelKey": "Permission.ManageWorkforceCompensation", + "HelpKey": "PermissionHelp.ManageWorkforceCompensation", + "Binding": "PermissionTypes.ManageWorkforceCompensation", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.ManageWorkforceCompensation", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.ManageWorkforceCompensation", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.ViewWorkforceCompensation", + "AreaId": "security", + "LabelKey": "Permission.ViewWorkforceCompensation", + "HelpKey": "PermissionHelp.ViewWorkforceCompensation", + "Binding": "PermissionTypes.ViewWorkforceCompensation", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.ViewWorkforceCompensation", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.ViewWorkforceCompensation", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.ManagePayDataReporting", + "AreaId": "security", + "LabelKey": "Permission.ManagePayDataReporting", + "HelpKey": "PermissionHelp.ManagePayDataReporting", + "Binding": "PermissionTypes.ManagePayDataReporting", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.ManagePayDataReporting", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.ManagePayDataReporting", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "permission.ExportPayDataReporting", + "AreaId": "security", + "LabelKey": "Permission.ExportPayDataReporting", + "HelpKey": "PermissionHelp.ExportPayDataReporting", + "Binding": "PermissionTypes.ExportPayDataReporting", + "ValueType": "permission", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "High", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.PermissionTiming", + "ReversibilityKey": "Impact.PermissionReversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "permission.ExportPayDataReporting", + "DefaultValue": "Awaiting consumer review; do not infer access from an absent row", + "AllowedValues": "Use the choices validated by the owning Security screen", + "EvidenceSource": "Permission inventory; runtime consumer review pending", + "Availability": "review-required", + "ReviewGap": "Review no-row default, action values, role/group scope, dependent grants, source screens and web/API/mobile enforcement for PermissionTypes.ExportPayDataReporting", + "ReviewGapExpiresOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "table.Department.Name", + "AreaId": "security", + "LabelKey": "TableField.Department.Name", + "HelpKey": "TableHelp.Department.Name", + "Binding": "Department.Name", + "ValueType": "text", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "Profile", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.Department.Name", + "DefaultValue": "required", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "Department", + "Availability": "reference-only" + }, + { + "Id": "table.Department.DepartmentType", + "AreaId": "security", + "LabelKey": "TableField.Department.DepartmentType", + "HelpKey": "TableHelp.Department.DepartmentType", + "Binding": "Department.DepartmentType", + "ValueType": "text", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "Profile", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.Department.DepartmentType", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "Department", + "Availability": "reference-only" + }, + { + "Id": "table.Department.TimeZone", + "AreaId": "security", + "LabelKey": "TableField.Department.TimeZone", + "HelpKey": "TableHelp.Department.TimeZone", + "Binding": "Department.TimeZone", + "ValueType": "timezone", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "Settings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.Department.TimeZone", + "DefaultValue": "owning setup default", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "Department", + "Availability": "reference-only" + }, + { + "Id": "table.Department.Use24HourTime", + "AreaId": "security", + "LabelKey": "TableField.Department.Use24HourTime", + "HelpKey": "TableHelp.Department.Use24HourTime", + "Binding": "Department.Use24HourTime", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "Settings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.Department.Use24HourTime", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "Department", + "Availability": "reference-only" + }, + { + "Id": "table.Department.ManagingUserId", + "AreaId": "security", + "LabelKey": "TableField.Department.ManagingUserId", + "HelpKey": "TableHelp.Department.ManagingUserId", + "Binding": "Department.ManagingUserId", + "ValueType": "reference", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "Settings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.Department.ManagingUserId", + "DefaultValue": "required", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "Department", + "Availability": "reference-only" + }, + { + "Id": "table.Department.Code", + "AreaId": "security", + "LabelKey": "TableField.Department.Code", + "HelpKey": "TableHelp.Department.Code", + "Binding": "Department.Code", + "ValueType": "restricted", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "Settings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.Department.Code", + "DefaultValue": "owning setup default", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "Department", + "Availability": "reference-only" + }, + { + "Id": "table.Department.ApiKey", + "AreaId": "security", + "LabelKey": "TableField.Department.ApiKey", + "HelpKey": "TableHelp.Department.ApiKey", + "Binding": "Department.ApiKey", + "ValueType": "secret", + "Classification": "Restricted", + "Secret": true, + "Location": { + "Controller": "Department", + "Action": "Settings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.Department.ApiKey", + "DefaultValue": "generated", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "Department", + "Availability": "reference-only" + }, + { + "Id": "table.Department.PublicApiKey", + "AreaId": "security", + "LabelKey": "TableField.Department.PublicApiKey", + "HelpKey": "TableHelp.Department.PublicApiKey", + "Binding": "Department.PublicApiKey", + "ValueType": "secret", + "Classification": "Restricted", + "Secret": true, + "Location": { + "Controller": "Department", + "Action": "Settings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.Department.PublicApiKey", + "DefaultValue": "generated", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "Department", + "Availability": "reference-only" + }, + { + "Id": "table.Department.SharedSecret", + "AreaId": "security", + "LabelKey": "TableField.Department.SharedSecret", + "HelpKey": "TableHelp.Department.SharedSecret", + "Binding": "Department.SharedSecret", + "ValueType": "secret", + "Classification": "Restricted", + "Secret": true, + "Location": { + "Controller": "Department", + "Action": "Settings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.Department.SharedSecret", + "DefaultValue": "generated", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "Department", + "Availability": "reference-only" + }, + { + "Id": "table.Department.LinkCode", + "AreaId": "security", + "LabelKey": "TableField.Department.LinkCode", + "HelpKey": "TableHelp.Department.LinkCode", + "Binding": "Department.LinkCode", + "ValueType": "secret", + "Classification": "Restricted", + "Secret": true, + "Location": { + "Controller": "Department", + "Action": "Settings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.Department.LinkCode", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "Department", + "Availability": "reference-only" + }, + { + "Id": "table.Department.ShowWelcome", + "AreaId": "security", + "LabelKey": "TableField.Department.ShowWelcome", + "HelpKey": "TableHelp.Department.ShowWelcome", + "Binding": "Department.ShowWelcome", + "ValueType": "server-metadata", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "Settings", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.Department.ShowWelcome", + "DefaultValue": "legacy", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "Department", + "Availability": "read-only" + }, + { + "Id": "table.DepartmentSecurityPolicy.RequireMfa", + "AreaId": "security", + "LabelKey": "TableField.DepartmentSecurityPolicy.RequireMfa", + "HelpKey": "TableHelp.DepartmentSecurityPolicy.RequireMfa", + "Binding": "DepartmentSecurityPolicy.RequireMfa", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "SecurityPolicy", + "Field": "RequireMfa" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentSecurityPolicy.RequireMfa", + "DefaultValue": "false", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentSecurityPolicy", + "Availability": "available" + }, + { + "Id": "table.DepartmentSecurityPolicy.RequireSso", + "AreaId": "security", + "LabelKey": "TableField.DepartmentSecurityPolicy.RequireSso", + "HelpKey": "TableHelp.DepartmentSecurityPolicy.RequireSso", + "Binding": "DepartmentSecurityPolicy.RequireSso", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "SecurityPolicy", + "Field": "RequireSso" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentSecurityPolicy.RequireSso", + "DefaultValue": "false", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentSecurityPolicy", + "Availability": "available" + }, + { + "Id": "table.DepartmentSecurityPolicy.SessionTimeoutMinutes", + "AreaId": "security", + "LabelKey": "TableField.DepartmentSecurityPolicy.SessionTimeoutMinutes", + "HelpKey": "TableHelp.DepartmentSecurityPolicy.SessionTimeoutMinutes", + "Binding": "DepartmentSecurityPolicy.SessionTimeoutMinutes", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "SecurityPolicy", + "Field": "SessionTimeoutMinutes" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentSecurityPolicy.SessionTimeoutMinutes", + "DefaultValue": "0", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentSecurityPolicy", + "Availability": "available" + }, + { + "Id": "table.DepartmentSecurityPolicy.MaxConcurrentSessions", + "AreaId": "security", + "LabelKey": "TableField.DepartmentSecurityPolicy.MaxConcurrentSessions", + "HelpKey": "TableHelp.DepartmentSecurityPolicy.MaxConcurrentSessions", + "Binding": "DepartmentSecurityPolicy.MaxConcurrentSessions", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "SecurityPolicy", + "Field": "MaxConcurrentSessions" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentSecurityPolicy.MaxConcurrentSessions", + "DefaultValue": "0", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentSecurityPolicy", + "Availability": "available" + }, + { + "Id": "table.DepartmentSecurityPolicy.AllowedIpRanges", + "AreaId": "security", + "LabelKey": "TableField.DepartmentSecurityPolicy.AllowedIpRanges", + "HelpKey": "TableHelp.DepartmentSecurityPolicy.AllowedIpRanges", + "Binding": "DepartmentSecurityPolicy.AllowedIpRanges", + "ValueType": "restricted-list", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "SecurityPolicy", + "Field": "AllowedIpRanges" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentSecurityPolicy.AllowedIpRanges", + "DefaultValue": "empty", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentSecurityPolicy", + "Availability": "available" + }, + { + "Id": "table.DepartmentSecurityPolicy.PasswordExpirationDays", + "AreaId": "security", + "LabelKey": "TableField.DepartmentSecurityPolicy.PasswordExpirationDays", + "HelpKey": "TableHelp.DepartmentSecurityPolicy.PasswordExpirationDays", + "Binding": "DepartmentSecurityPolicy.PasswordExpirationDays", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "SecurityPolicy", + "Field": "PasswordExpirationDays" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentSecurityPolicy.PasswordExpirationDays", + "DefaultValue": "0", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentSecurityPolicy", + "Availability": "available" + }, + { + "Id": "table.DepartmentSecurityPolicy.MinPasswordLength", + "AreaId": "security", + "LabelKey": "TableField.DepartmentSecurityPolicy.MinPasswordLength", + "HelpKey": "TableHelp.DepartmentSecurityPolicy.MinPasswordLength", + "Binding": "DepartmentSecurityPolicy.MinPasswordLength", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "SecurityPolicy", + "Field": "MinPasswordLength" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentSecurityPolicy.MinPasswordLength", + "DefaultValue": "editor minimum 8", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentSecurityPolicy", + "Availability": "available" + }, + { + "Id": "table.DepartmentSecurityPolicy.RequirePasswordComplexity", + "AreaId": "security", + "LabelKey": "TableField.DepartmentSecurityPolicy.RequirePasswordComplexity", + "HelpKey": "TableHelp.DepartmentSecurityPolicy.RequirePasswordComplexity", + "Binding": "DepartmentSecurityPolicy.RequirePasswordComplexity", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "SecurityPolicy", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentSecurityPolicy.RequirePasswordComplexity", + "DefaultValue": "false", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentSecurityPolicy", + "Availability": "reference-only" + }, + { + "Id": "table.DepartmentSecurityPolicy.DataClassificationLevel", + "AreaId": "security", + "LabelKey": "TableField.DepartmentSecurityPolicy.DataClassificationLevel", + "HelpKey": "TableHelp.DepartmentSecurityPolicy.DataClassificationLevel", + "Binding": "DepartmentSecurityPolicy.DataClassificationLevel", + "ValueType": "enum", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "SecurityPolicy", + "Field": "DataClassificationLevel" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentSecurityPolicy.DataClassificationLevel", + "DefaultValue": "0", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentSecurityPolicy", + "Availability": "available" + }, + { + "Id": "table.DepartmentSsoConfig.SsoProviderType", + "AreaId": "security", + "LabelKey": "TableField.DepartmentSsoConfig.SsoProviderType", + "HelpKey": "TableHelp.DepartmentSsoConfig.SsoProviderType", + "Binding": "DepartmentSsoConfig.SsoProviderType", + "ValueType": "enum", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Sso", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentSsoConfig.SsoProviderType", + "DefaultValue": "required", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentSsoConfig", + "Availability": "reference-only" + }, + { + "Id": "table.DepartmentSsoConfig.IsEnabled", + "AreaId": "security", + "LabelKey": "TableField.DepartmentSsoConfig.IsEnabled", + "HelpKey": "TableHelp.DepartmentSsoConfig.IsEnabled", + "Binding": "DepartmentSsoConfig.IsEnabled", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Sso", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentSsoConfig.IsEnabled", + "DefaultValue": "false", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentSsoConfig", + "Availability": "reference-only" + }, + { + "Id": "table.DepartmentSsoConfig.ClientId", + "AreaId": "security", + "LabelKey": "TableField.DepartmentSsoConfig.ClientId", + "HelpKey": "TableHelp.DepartmentSsoConfig.ClientId", + "Binding": "DepartmentSsoConfig.ClientId", + "ValueType": "restricted", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Sso", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentSsoConfig.ClientId", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentSsoConfig", + "Availability": "reference-only" + }, + { + "Id": "table.DepartmentSsoConfig.EncryptedClientSecret", + "AreaId": "security", + "LabelKey": "TableField.DepartmentSsoConfig.EncryptedClientSecret", + "HelpKey": "TableHelp.DepartmentSsoConfig.EncryptedClientSecret", + "Binding": "DepartmentSsoConfig.EncryptedClientSecret", + "ValueType": "secret", + "Classification": "Restricted", + "Secret": true, + "Location": { + "Controller": "Security", + "Action": "Sso", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentSsoConfig.EncryptedClientSecret", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentSsoConfig", + "Availability": "reference-only" + }, + { + "Id": "table.DepartmentSsoConfig.Authority", + "AreaId": "security", + "LabelKey": "TableField.DepartmentSsoConfig.Authority", + "HelpKey": "TableHelp.DepartmentSsoConfig.Authority", + "Binding": "DepartmentSsoConfig.Authority", + "ValueType": "restricted", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Sso", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentSsoConfig.Authority", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentSsoConfig", + "Availability": "reference-only" + }, + { + "Id": "table.DepartmentSsoConfig.MetadataUrl", + "AreaId": "security", + "LabelKey": "TableField.DepartmentSsoConfig.MetadataUrl", + "HelpKey": "TableHelp.DepartmentSsoConfig.MetadataUrl", + "Binding": "DepartmentSsoConfig.MetadataUrl", + "ValueType": "restricted", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Sso", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentSsoConfig.MetadataUrl", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentSsoConfig", + "Availability": "reference-only" + }, + { + "Id": "table.DepartmentSsoConfig.EntityId", + "AreaId": "security", + "LabelKey": "TableField.DepartmentSsoConfig.EntityId", + "HelpKey": "TableHelp.DepartmentSsoConfig.EntityId", + "Binding": "DepartmentSsoConfig.EntityId", + "ValueType": "restricted", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Sso", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentSsoConfig.EntityId", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentSsoConfig", + "Availability": "reference-only" + }, + { + "Id": "table.DepartmentSsoConfig.AssertionConsumerServiceUrl", + "AreaId": "security", + "LabelKey": "TableField.DepartmentSsoConfig.AssertionConsumerServiceUrl", + "HelpKey": "TableHelp.DepartmentSsoConfig.AssertionConsumerServiceUrl", + "Binding": "DepartmentSsoConfig.AssertionConsumerServiceUrl", + "ValueType": "restricted", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Sso", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentSsoConfig.AssertionConsumerServiceUrl", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentSsoConfig", + "Availability": "reference-only" + }, + { + "Id": "table.DepartmentSsoConfig.EncryptedIdpCertificate", + "AreaId": "security", + "LabelKey": "TableField.DepartmentSsoConfig.EncryptedIdpCertificate", + "HelpKey": "TableHelp.DepartmentSsoConfig.EncryptedIdpCertificate", + "Binding": "DepartmentSsoConfig.EncryptedIdpCertificate", + "ValueType": "secret", + "Classification": "Restricted", + "Secret": true, + "Location": { + "Controller": "Security", + "Action": "Sso", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentSsoConfig.EncryptedIdpCertificate", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentSsoConfig", + "Availability": "reference-only" + }, + { + "Id": "table.DepartmentSsoConfig.EncryptedSigningCertificate", + "AreaId": "security", + "LabelKey": "TableField.DepartmentSsoConfig.EncryptedSigningCertificate", + "HelpKey": "TableHelp.DepartmentSsoConfig.EncryptedSigningCertificate", + "Binding": "DepartmentSsoConfig.EncryptedSigningCertificate", + "ValueType": "secret", + "Classification": "Restricted", + "Secret": true, + "Location": { + "Controller": "Security", + "Action": "Sso", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentSsoConfig.EncryptedSigningCertificate", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentSsoConfig", + "Availability": "reference-only" + }, + { + "Id": "table.DepartmentSsoConfig.AttributeMappingJson", + "AreaId": "security", + "LabelKey": "TableField.DepartmentSsoConfig.AttributeMappingJson", + "HelpKey": "TableHelp.DepartmentSsoConfig.AttributeMappingJson", + "Binding": "DepartmentSsoConfig.AttributeMappingJson", + "ValueType": "structured", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Sso", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentSsoConfig.AttributeMappingJson", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentSsoConfig", + "Availability": "reference-only" + }, + { + "Id": "table.DepartmentSsoConfig.AllowLocalLogin", + "AreaId": "security", + "LabelKey": "TableField.DepartmentSsoConfig.AllowLocalLogin", + "HelpKey": "TableHelp.DepartmentSsoConfig.AllowLocalLogin", + "Binding": "DepartmentSsoConfig.AllowLocalLogin", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Sso", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentSsoConfig.AllowLocalLogin", + "DefaultValue": "true", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentSsoConfig", + "Availability": "reference-only" + }, + { + "Id": "table.DepartmentSsoConfig.AutoProvisionUsers", + "AreaId": "security", + "LabelKey": "TableField.DepartmentSsoConfig.AutoProvisionUsers", + "HelpKey": "TableHelp.DepartmentSsoConfig.AutoProvisionUsers", + "Binding": "DepartmentSsoConfig.AutoProvisionUsers", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Sso", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentSsoConfig.AutoProvisionUsers", + "DefaultValue": "false", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentSsoConfig", + "Availability": "reference-only" + }, + { + "Id": "table.DepartmentSsoConfig.DefaultRankId", + "AreaId": "security", + "LabelKey": "TableField.DepartmentSsoConfig.DefaultRankId", + "HelpKey": "TableHelp.DepartmentSsoConfig.DefaultRankId", + "Binding": "DepartmentSsoConfig.DefaultRankId", + "ValueType": "reference", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Sso", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentSsoConfig.DefaultRankId", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentSsoConfig", + "Availability": "reference-only" + }, + { + "Id": "table.DepartmentSsoConfig.ScimEnabled", + "AreaId": "security", + "LabelKey": "TableField.DepartmentSsoConfig.ScimEnabled", + "HelpKey": "TableHelp.DepartmentSsoConfig.ScimEnabled", + "Binding": "DepartmentSsoConfig.ScimEnabled", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Sso", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentSsoConfig.ScimEnabled", + "DefaultValue": "false", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentSsoConfig", + "Availability": "reference-only" + }, + { + "Id": "table.DepartmentSsoConfig.EncryptedScimBearerToken", + "AreaId": "security", + "LabelKey": "TableField.DepartmentSsoConfig.EncryptedScimBearerToken", + "HelpKey": "TableHelp.DepartmentSsoConfig.EncryptedScimBearerToken", + "Binding": "DepartmentSsoConfig.EncryptedScimBearerToken", + "ValueType": "secret", + "Classification": "Restricted", + "Secret": true, + "Location": { + "Controller": "Security", + "Action": "Sso", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentSsoConfig.EncryptedScimBearerToken", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentSsoConfig", + "Availability": "reference-only" + } + ], + "Capabilities": [ + { + "Id": "two-factor", + "AreaId": "security", + "LabelKey": "Feature.two-factor", + "PurposeKey": "FeaturePurpose.two-factor", + "ValueKey": "AreaValue.security", + "ExampleKey": "AreaExample.security", + "AdoptionKey": "FeatureAdoption.two-factor", + "ReleaseStatus": "available", + "Location": { + "Controller": "TwoFactor", + "Action": "Index" + }, + "Requirements": [], + "SettingIds": [], + "RuleIds": [] + }, + { + "Id": "invites", + "AreaId": "security", + "LabelKey": "Feature.invites", + "PurposeKey": "FeaturePurpose.invites", + "ValueKey": "AreaValue.security", + "ExampleKey": "AreaExample.security", + "AdoptionKey": "FeatureAdoption.invites", + "ReleaseStatus": "available", + "Location": { + "Controller": "Department", + "Action": "Invites" + }, + "Requirements": [], + "SettingIds": [], + "RuleIds": [] + }, + { + "Id": "department-settings", + "AreaId": "security", + "LabelKey": "Feature.department-settings", + "PurposeKey": "FeaturePurpose.department-settings", + "ValueKey": "AreaValue.security", + "ExampleKey": "AreaExample.security", + "AdoptionKey": "FeatureAdoption.department-settings", + "ReleaseStatus": "available", + "Location": { + "Controller": "Department", + "Action": "Settings" + }, + "Requirements": [], + "SettingIds": [ + "field.DepartmentSuppressStaffingInfo.EnableSupressStaffing", + "field.DepartmentSuppressStaffingInfo.StaffingLevelsToSupress", + "field.PersonnelListStatusOrder.StatusId", + "field.PersonnelListStatusOrder.Weight", + "field.PersonnelListStatusOrderSetting.Orders", + "setting.BigBoardHideUnavailable", + "setting.BigBoardMapCenterAddress", + "setting.BigBoardMapCenterGpsCoordinates", + "setting.BigBoardMapZoomLevel", + "setting.BigBoardPageRefresh", + "setting.CallsSortOrder", + "setting.DisabledAutoAvailable", + "setting.EnableModernNotifications", + "setting.ForceChatbotSecurityPin", + "setting.PersonnelListStatusSortOrder", + "setting.PersonnelSortOrder", + "setting.RequirePasswordResetViaEmail", + "setting.StaffingSuppressStaffingLevels", + "setting.TestEnabled", + "setting.UnitsSortOrder", + "setting.UpdateTimestamp", + "table.Department.ApiKey", + "table.Department.Code", + "table.Department.LinkCode", + "table.Department.ManagingUserId", + "table.Department.PublicApiKey", + "table.Department.SharedSecret", + "table.Department.ShowWelcome", + "table.Department.TimeZone", + "table.Department.Use24HourTime" + ], + "RuleIds": [] + }, + { + "Id": "call-settings", + "AreaId": "security", + "LabelKey": "Feature.call-settings", + "PurposeKey": "FeaturePurpose.call-settings", + "ValueKey": "AreaValue.security", + "ExampleKey": "AreaExample.security", + "AdoptionKey": "FeatureAdoption.call-settings", + "ReleaseStatus": "available", + "Location": { + "Controller": "Department", + "Action": "CallSettings" + }, + "Requirements": [], + "SettingIds": [ + "setting.InternalDispatchEmail", + "setting.RssFeedKeyForActiveCalls", + "table.DepartmentCallEmail.ErrorMessage", + "table.DepartmentCallEmail.FormatType", + "table.DepartmentCallEmail.Hostname", + "table.DepartmentCallEmail.IsFailure", + "table.DepartmentCallEmail.LastCheck", + "table.DepartmentCallEmail.Password", + "table.DepartmentCallEmail.Port", + "table.DepartmentCallEmail.UseSsl", + "table.DepartmentCallEmail.Username" + ], + "RuleIds": [ + "email-import-failures", + "import-heartbeat" + ] + }, + { + "Id": "dispatch-settings", + "AreaId": "security", + "LabelKey": "Feature.dispatch-settings", + "PurposeKey": "FeaturePurpose.dispatch-settings", + "ValueKey": "AreaValue.security", + "ExampleKey": "AreaExample.security", + "AdoptionKey": "FeatureAdoption.dispatch-settings", + "ReleaseStatus": "available", + "Location": { + "Controller": "Department", + "Action": "DispatchSettings" + }, + "Requirements": [], + "SettingIds": [ + "field.DispatchRecommendationConfig.EtaShortlistSize", + "field.DispatchRecommendationConfig.IncludeStaleLocations", + "field.DispatchRecommendationConfig.MaxLocationAgeSeconds", + "field.DispatchRecommendationConfig.MaxRadiusMeters", + "field.DispatchRecommendationConfig.MoveUpRecommendationsEnabled", + "field.DispatchRecommendationConfig.PersonnelMaxLocationAgeSeconds", + "field.DispatchRecommendationConfig.RestPeriodMinutes", + "field.DispatchRecommendationConfig.UnitMinimumStaffingLevel", + "field.DispatchRecommendationConfig.UseRoutedEta", + "field.GroupDispatchScopeConfig.DepartmentWideRoleIds", + "field.GroupDispatchScopeConfig.Enabled", + "field.NewCallFieldPolicy.Rules", + "field.NewCallFieldRule.Key", + "field.NewCallFieldRule.Required", + "field.NewCallFieldRule.Visible", + "field.UnitTypeCallStatusOverride.DispatchStatus", + "field.UnitTypeCallStatusOverride.ReleaseStatus", + "field.UnitTypeCallStatusOverride.UnitTypeId", + "field.UnitTypeCallStatusOverrideSetting.Overrides", + "setting.AutoSetStatusForShiftDispatchPersonnel", + "setting.CheckInTimersAutoEnableForNewCalls", + "setting.DispatchRecommendationAutoDispatch", + "setting.DispatchRecommendationConfig", + "setting.DispatchRecommendationMode", + "setting.DispatchShiftInsteadOfGroup", + "setting.GroupDispatchScopeConfig", + "setting.NewCallFieldPolicy", + "setting.PersonnelOnUnitSetUnitStatus", + "setting.ShiftCallDispatchPersonnelStatusToSet", + "setting.ShiftCallReleasePersonnelStatusToSet", + "setting.UnitCallDispatchStatusToSet", + "setting.UnitCallReleaseStatusToSet", + "setting.UnitCallStatusOverridesByUnitType", + "setting.UnitDispatchAlsoDispatchToAssignedPersonnel", + "setting.UnitDispatchAlsoDispatchToGroup" + ], + "RuleIds": [ + "checkin-timers", + "shift-auto-without-dispatch", + "shift-coverage" + ] + }, + { + "Id": "data-protection", + "AreaId": "security", + "LabelKey": "Feature.data-protection", + "PurposeKey": "FeaturePurpose.data-protection", + "ValueKey": "AreaValue.security", + "ExampleKey": "AreaExample.security", + "AdoptionKey": "FeatureAdoption.data-protection", + "ReleaseStatus": "available", + "Location": { + "Controller": "DataProtection", + "Action": "Index" + }, + "Requirements": [ + { + "Kind": "addon", + "Id": "ADP" + } + ], + "SettingIds": [], + "RuleIds": [] + }, + { + "Id": "module-settings", + "AreaId": "security", + "LabelKey": "Feature.module-settings", + "PurposeKey": "FeaturePurpose.module-settings", + "ValueKey": "FeatureValue.module-settings", + "ExampleKey": "AreaExample.security", + "AdoptionKey": "FeatureAdoption.module-settings", + "ReleaseStatus": "available", + "Location": { + "Controller": "Department", + "Action": "ModuleSettings" + }, + "Requirements": [], + "SettingIds": [ + "module.BusinessOperationsDisabled", + "module.CalendarDisabled", + "module.CalendarNameOverride", + "module.ChecklistsDisabled", + "module.DocumentsDisabled", + "module.DocumentsNameOverride", + "module.InventoryDisabled", + "module.InventoryNameOverride", + "module.LogsDisabled", + "module.LogsNameOverride", + "module.MaintenanceDisabled", + "module.MaintenanceNameOverride", + "module.MappingDisabled", + "module.MappingNameOverride", + "module.MessagingDisabled", + "module.MessagingNameOverride", + "module.NotesDisabled", + "module.NotesNameOverride", + "module.ReportsDisabled", + "module.ReportsNameOverride", + "module.ShiftsDisabled", + "module.ShiftsNameOverride", + "module.TrainingDisabled", + "module.TrainingNameOverride", + "setting.ModuleSettings" + ], + "RuleIds": [] + }, + { + "Id": "permissions", + "AreaId": "security", + "LabelKey": "Feature.permissions", + "PurposeKey": "FeaturePurpose.permissions", + "ValueKey": "FeatureValue.permissions", + "ExampleKey": "AreaExample.security", + "AdoptionKey": "FeatureAdoption.permissions", + "ReleaseStatus": "available", + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "Requirements": [], + "SettingIds": [ + "permission.AddCallData", + "permission.AddPersonnel", + "permission.AdjustInventory", + "permission.AmendRecords", + "permission.ApproveRecords", + "permission.ApproveTimeReports", + "permission.BreakGlassProtectedData", + "permission.CanSeePersonnelLocations", + "permission.CanSeeUnitLocations", + "permission.CloseCall", + "permission.CommandAppLogin", + "permission.ConfigureProtectedDataEgress", + "permission.ContactDelete", + "permission.ContactEdit", + "permission.ContactView", + "permission.CreateCalendarEntry", + "permission.CreateCall", + "permission.CreateDocument", + "permission.CreateLog", + "permission.CreateMessage", + "permission.CreateNote", + "permission.CreateRecord", + "permission.CreateShift", + "permission.CreateTraining", + "permission.CreateWorkflow", + "permission.DeleteCall", + "permission.DeleteLog", + "permission.DeleteRecord", + "permission.DispatchAppLogin", + "permission.EditProtectedCallData", + "permission.ExportPayDataReporting", + "permission.ExportProtectedData", + "permission.ExportRecords", + "permission.FinalizeRecords", + "permission.IssueInventory", + "permission.ManageBids", + "permission.ManageCertificationSetup", + "permission.ManageCertifications", + "permission.ManageChecklists", + "permission.ManageContracts", + "permission.ManageControlledSubstances", + "permission.ManageDepartmentDataProtection", + "permission.ManageDeployments", + "permission.ManageInvoicing", + "permission.ManageMutualAidReimbursement", + "permission.ManagePayDataReporting", + "permission.ManageRecordDefinitions", + "permission.ManageRecordDisclosures", + "permission.ManageRecordLegalHold", + "permission.ManageRecordReports", + "permission.ManageRoutes", + "permission.ManageWorkOrders", + "permission.ManageWorkflowCredentials", + "permission.ManageWorkforceCompensation", + "permission.PublishRecordDefinitions", + "permission.ReassignRecordDrafts", + "permission.RecordsPreventionAdmin", + "permission.RemovePersonnel", + "permission.ReviewRecords", + "permission.ShareRecordsExternally", + "permission.SubmitRecords", + "permission.TransferInventory", + "permission.UseCalendarSync", + "permission.ViewAllWorkOrders", + "permission.ViewCertifications", + "permission.ViewChecklistResults", + "permission.ViewGroupRecords", + "permission.ViewGroupUnits", + "permission.ViewGroupUsers", + "permission.ViewInternalCosts", + "permission.ViewInvoicing", + "permission.ViewLegacyRecords", + "permission.ViewPersonalInfo", + "permission.ViewProtectedCallData", + "permission.ViewProtectedContactData", + "permission.ViewProtectedOperationalData", + "permission.ViewProtectedPersonnelData", + "permission.ViewRestrictedRecords", + "permission.ViewUdfFields", + "permission.ViewWorkflowRuns", + "permission.ViewWorkforceCompensation", + "setting.Require2FAForAdmins" + ], + "RuleIds": [ + "admin-enrollment", + "admin-mfa", + "admin-succession", + "password-recovery" + ] + }, + { + "Id": "sso", + "AreaId": "security", + "LabelKey": "Feature.sso", + "PurposeKey": "FeaturePurpose.sso", + "ValueKey": "FeatureValue.sso", + "ExampleKey": "AreaExample.security", + "AdoptionKey": "FeatureAdoption.sso", + "ReleaseStatus": "available", + "Location": { + "Controller": "Security", + "Action": "Sso" + }, + "Requirements": [], + "SettingIds": [ + "table.DepartmentSsoConfig.AllowLocalLogin", + "table.DepartmentSsoConfig.AssertionConsumerServiceUrl", + "table.DepartmentSsoConfig.AttributeMappingJson", + "table.DepartmentSsoConfig.Authority", + "table.DepartmentSsoConfig.AutoProvisionUsers", + "table.DepartmentSsoConfig.ClientId", + "table.DepartmentSsoConfig.DefaultRankId", + "table.DepartmentSsoConfig.EncryptedClientSecret", + "table.DepartmentSsoConfig.EncryptedIdpCertificate", + "table.DepartmentSsoConfig.EncryptedScimBearerToken", + "table.DepartmentSsoConfig.EncryptedSigningCertificate", + "table.DepartmentSsoConfig.EntityId", + "table.DepartmentSsoConfig.IsEnabled", + "table.DepartmentSsoConfig.MetadataUrl", + "table.DepartmentSsoConfig.ScimEnabled", + "table.DepartmentSsoConfig.SsoProviderType" + ], + "RuleIds": [] + }, + { + "Id": "security-policy", + "AreaId": "security", + "LabelKey": "Feature.security-policy", + "PurposeKey": "FeaturePurpose.security-policy", + "ValueKey": "FeatureValue.security-policy", + "ExampleKey": "AreaExample.security", + "AdoptionKey": "FeatureAdoption.security-policy", + "ReleaseStatus": "available", + "Location": { + "Controller": "Security", + "Action": "SecurityPolicy" + }, + "Requirements": [], + "SettingIds": [ + "table.DepartmentSecurityPolicy.AllowedIpRanges", + "table.DepartmentSecurityPolicy.DataClassificationLevel", + "table.DepartmentSecurityPolicy.MaxConcurrentSessions", + "table.DepartmentSecurityPolicy.MinPasswordLength", + "table.DepartmentSecurityPolicy.PasswordExpirationDays", + "table.DepartmentSecurityPolicy.RequireMfa", + "table.DepartmentSecurityPolicy.RequirePasswordComplexity", + "table.DepartmentSecurityPolicy.RequireSso", + "table.DepartmentSecurityPolicy.SessionTimeoutMinutes" + ], + "RuleIds": [] + }, + { + "Id": "audit-history", + "AreaId": "security", + "LabelKey": "Feature.audit-history", + "PurposeKey": "FeaturePurpose.audit-history", + "ValueKey": "FeatureValue.audit-history", + "ExampleKey": "AreaExample.security", + "AdoptionKey": "FeatureAdoption.audit-history", + "ReleaseStatus": "available", + "Location": { + "Controller": "Security", + "Action": "Audits" + }, + "Requirements": [], + "SettingIds": [], + "RuleIds": [] + }, + { + "Id": "account-sessions", + "AreaId": "security", + "LabelKey": "Feature.account-sessions", + "PurposeKey": "FeaturePurpose.account-sessions", + "ValueKey": "FeatureValue.account-sessions", + "ExampleKey": "AreaExample.security", + "AdoptionKey": "FeatureAdoption.account-sessions", + "ReleaseStatus": "available", + "Location": { + "Controller": "AccountSecurity", + "Action": "Sessions" + }, + "Requirements": [], + "SettingIds": [], + "RuleIds": [] + }, + { + "Id": "setup-help", + "AreaId": "security", + "LabelKey": "Feature.setup-help", + "PurposeKey": "FeaturePurpose.setup-help", + "ValueKey": "FeatureValue.setup-help", + "ExampleKey": "AreaExample.security", + "AdoptionKey": "FeatureAdoption.setup-help", + "ReleaseStatus": "available", + "Location": { + "Controller": "Department", + "Action": "SetupWizard" + }, + "Requirements": [], + "SettingIds": [], + "RuleIds": [] + } + ], + "Rules": [ + { + "Id": "admin-mfa", + "AreaId": "security", + "Severity": "Critical", + "TitleKey": "Rule.admin-mfa", + "ExplanationKey": "RuleWhy.admin-mfa", + "NextActionKey": "RuleNext.admin-mfa", + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "AppliesWhen": [], + "FailsWhen": [ + { + "EvidenceId": "Require2FAForAdmins", + "Comparison": "Equal", + "Number": 0 + } + ] + }, + { + "Id": "admin-enrollment", + "AreaId": "security", + "Severity": "Critical", + "TitleKey": "Rule.admin-enrollment", + "ExplanationKey": "RuleWhy.admin-enrollment", + "NextActionKey": "RuleNext.admin-enrollment", + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "AppliesWhen": [], + "FailsWhen": [ + { + "EvidenceId": "adminsWithoutMfa", + "Comparison": "Greater", + "Number": 0 + } + ] + }, + { + "Id": "admin-succession", + "AreaId": "security", + "Severity": "Warning", + "TitleKey": "Rule.admin-succession", + "ExplanationKey": "RuleWhy.admin-succession", + "NextActionKey": "RuleNext.admin-succession", + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "AppliesWhen": [], + "FailsWhen": [ + { + "EvidenceId": "activeAdminCount", + "Comparison": "Less", + "Number": 2 + } + ] + }, + { + "Id": "password-recovery", + "AreaId": "security", + "Severity": "Warning", + "TitleKey": "Rule.password-recovery", + "ExplanationKey": "RuleWhy.password-recovery", + "NextActionKey": "RuleNext.password-recovery", + "Location": { + "Controller": "Security", + "Action": "Index" + }, + "AppliesWhen": [], + "FailsWhen": [ + { + "EvidenceId": "RequirePasswordResetViaEmail", + "Comparison": "IsFalse", + "Number": null + } + ] + } + ], + "Packs": [], + "Articles": [ + { + "Id": "setting.TestEnabled", + "Locale": "en", + "TitleKey": "Setting.TestEnabled", + "Body": "Test Enabled. Department testing behavior. Do not treat enabling a test setting as proof of production readiness.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-testenabled", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.UpdateTimestamp", + "Locale": "en", + "TitleKey": "Setting.UpdateTimestamp", + "Body": "Update Timestamp. Internal configuration timestamp. Read-only metadata, not an administrator-editable preference.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-updatetimestamp", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.ModuleSettings", + "Locale": "en", + "TitleKey": "Setting.ModuleSettings", + "Body": "Module Settings. Controls module navigation and supported feature gates. Hiding a module does not delete its records or prove independent dispatch settings are disabled.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-modulesettings", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.Require2FAForAdmins", + "Locale": "en", + "TitleKey": "Setting.Require2FAForAdmins", + "Body": "Require2 FAFor Admins. Administrator MFA requirement mode. Verify actual factors and recovery for each administrator before enforcement changes.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-require2faforadmins", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.ForceChatbotSecurityPin", + "Locale": "en", + "TitleKey": "Setting.ForceChatbotSecurityPin", + "Body": "Force Chatbot Security Pin. Require the member security PIN for sensitive supported chatbot and SMS actions, including members who have not opted in.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-forcechatbotsecuritypin", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.RequirePasswordResetViaEmail", + "Locale": "en", + "TitleKey": "Setting.RequirePasswordResetViaEmail", + "Body": "Require Password Reset Via Email. Use single-use recovery links for administrator-initiated member password resets, instead of selecting a replacement password.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-requirepasswordresetviaemail", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.DepartmentOperatingProfile", + "Locale": "en", + "TitleKey": "Setting.DepartmentOperatingProfile", + "Body": "Department Operating Profile. Declared organization, workforce, dispatch, seasonal, language and policy context. Recommendations never infer licenses or clinical scope from this profile.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-departmentoperatingprofile", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "profile.Archetypes", + "Locale": "en", + "TitleKey": "ProfileField.Archetypes", + "Body": "Operating sectors. Choose all sectors served by the department. A pack suggests administrative reviews; it does not establish qualification or clinical scope.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "profile-archetypes", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "profile.WorkforceMix", + "Locale": "en", + "TitleKey": "ProfileField.WorkforceMix", + "Body": "Workforce mix. Describe the workforce so setup guidance can reflect career, volunteer, contract or combined operations.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "profile-workforcemix", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "profile.DeclaredMemberCount", + "Locale": "en", + "TitleKey": "ProfileField.DeclaredMemberCount", + "Body": "Declared member count. Optional planning estimate; actual membership and subscription counts come from their owning sources.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "profile-declaredmembercount", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "profile.AuthoritativeSystemReferences", + "Locale": "en", + "TitleKey": "ProfileField.AuthoritativeSystemReferences", + "Body": "Authoritative system references. Short declared source-system labels. Saving this list does not verify a connection, an import or the freshness of external data.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "profile-authoritativesystemreferences", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "profile.DispatchModel", + "Locale": "en", + "TitleKey": "ProfileField.DispatchModel", + "Body": "Dispatch model. Record whether dispatch is central, self-directed, external or a combination. Verify each routing path through its owning settings.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "profile-dispatchmodel", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "profile.OperatingHours", + "Locale": "en", + "TitleKey": "ProfileField.OperatingHours", + "Body": "Operating hours. Describe continuous, scheduled, on-call or seasonal operation. This is context and does not create shifts or guarantee coverage.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "profile-operatinghours", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "profile.SiteGroupReferences", + "Locale": "en", + "TitleKey": "ProfileField.SiteGroupReferences", + "Body": "Sites and groups. Reference existing numeric group IDs in this department. This does not grant group access or change dispatch scope.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "profile-sitegroupreferences", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "profile.MutualAid", + "Locale": "en", + "TitleKey": "ProfileField.MutualAid", + "Body": "Mutual aid participation. Declare whether mutual aid is part of operations. Agreements, access and response eligibility require separate review.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "profile-mutualaid", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "profile.LanguageCodes", + "Locale": "en", + "TitleKey": "ProfileField.LanguageCodes", + "Body": "Working languages. Languages used by the team. This preference does not prove interpreter availability or translate operational records.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "profile-languagecodes", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "profile.AccessibilityNeeds", + "Locale": "en", + "TitleKey": "ProfileField.AccessibilityNeeds", + "Body": "Accessibility preferences. Record screen-reader, caption, large-text or plain-language needs for setup planning. Verify client behavior with the people who use it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "profile-accessibilityneeds", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "profile.StaffingPolicyReferences", + "Locale": "en", + "TitleKey": "ProfileField.StaffingPolicyReferences", + "Body": "Staffing policy documents. Reference existing department document IDs for approved staffing policies. The save checks existence and expiry, not policy approval or adequacy.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "profile-staffingpolicyreferences", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "profile.QualificationPolicyReferences", + "Locale": "en", + "TitleKey": "ProfileField.QualificationPolicyReferences", + "Body": "Qualification policy documents. Reference existing department document IDs for approved qualification policies. Do not infer a license, scope of practice or deployment qualification from a role label.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "profile-qualificationpolicyreferences", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "profile.ContinuityProcedureReferences", + "Locale": "en", + "TitleKey": "ProfileField.ContinuityProcedureReferences", + "Body": "Continuity procedure documents. Reference existing department document IDs for approved continuity procedures. Test fallback communications and ownership through the approved procedure.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "profile-continuityprocedurereferences", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "profile.SeasonStartMonthDay", + "Locale": "en", + "TitleKey": "ProfileField.SeasonStartMonthDay", + "Body": "Season start. Start of a declared seasonal period, in MM-DD format. Provide both start and end; this does not enable or disable resources on those dates.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "profile-seasonstartmonthday", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "profile.SeasonEndMonthDay", + "Locale": "en", + "TitleKey": "ProfileField.SeasonEndMonthDay", + "Body": "Season end. End of a declared seasonal period, in MM-DD format. A period can cross a calendar year; verify actual rosters separately.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "profile-seasonendmonthday", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "profile.ReviewedOnUtc", + "Locale": "en", + "TitleKey": "ProfileField.ReviewedOnUtc", + "Body": "Profile review time. Server-recorded time of the most recent validated profile save; it does not certify operational readiness.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "profile-reviewedonutc", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "profile.Revision", + "Locale": "en", + "TitleKey": "ProfileField.Revision", + "Body": "Profile revision. Server revision used to reject concurrent overwrites. Reload when another administrator has saved a newer profile.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "profile-revision", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "profile.ExpectedEmailPollIntervalMinutes", + "Locale": "en", + "TitleKey": "ProfileField.ExpectedEmailPollIntervalMinutes", + "Body": "Expected email polling interval (minutes). Optional maximum time between recorded mailbox polls, from 1 to 10080 minutes. Blank means no declared polling expectation. This checks configured email connectors, not how often calls arrive; it does not measure SMS, CAD push or API health. Review the host polling schedule before setting it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "profile-expectedemailpollintervalminutes", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.CreateCall", + "Locale": "en", + "TitleKey": "Permission.CreateCall", + "Body": "Create calls. Allows starting a call. The department-level action uses each member’s actual administrator state and personnel roles; later dispatch validation remains authoritative.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-createcall", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.CreateNote", + "Locale": "en", + "TitleKey": "Permission.CreateNote", + "Body": "Create notes. Allows creating a department note. The action does not grant access to protected note contents or another department.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-createnote", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.ViewPersonalInfo", + "Locale": "en", + "TitleKey": "Permission.ViewPersonalInfo", + "Body": "View personal information. Controls the personal-information permission gate. Protected fields still require their own current authorization and data protection grant.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-viewpersonalinfo", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.ViewGroupUsers", + "Locale": "en", + "TitleKey": "Permission.ViewGroupUsers", + "Body": "View personnel. Controls visibility of personnel targets. A group lock can narrow target scope; administrators of a target group or its ancestors use the owning visibility policy.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-viewgroupusers", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.ViewGroupUnits", + "Locale": "en", + "TitleKey": "Permission.ViewGroupUnits", + "Body": "View units. Controls visibility of unit targets and their assigned stations. Ungrouped viewers and unassigned units do not share a group.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-viewgroupunits", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.CanSeePersonnelLocations", + "Locale": "en", + "TitleKey": "Permission.CanSeePersonnelLocations", + "Body": "View personnel locations. Controls the location visibility gate for each viewer and person. The preview counts access pairs, not current map markers, tracking consent or GPS availability.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-canseepersonnellocations", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.CanSeeUnitLocations", + "Locale": "en", + "TitleKey": "Permission.CanSeeUnitLocations", + "Body": "View unit locations. Controls the location visibility gate for each viewer and unit. A group lock refers to the unit’s station and the owning group hierarchy, not any station a viewer can otherwise see.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-canseeunitlocations", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.AddPersonnel", + "Locale": "en", + "TitleKey": "Permission.AddPersonnel", + "Body": "Add Personnel. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-addpersonnel", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.RemovePersonnel", + "Locale": "en", + "TitleKey": "Permission.RemovePersonnel", + "Body": "Remove Personnel. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-removepersonnel", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.CreateTraining", + "Locale": "en", + "TitleKey": "Permission.CreateTraining", + "Body": "Create Training. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-createtraining", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.CreateDocument", + "Locale": "en", + "TitleKey": "Permission.CreateDocument", + "Body": "Create Document. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-createdocument", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.CreateCalendarEntry", + "Locale": "en", + "TitleKey": "Permission.CreateCalendarEntry", + "Body": "Create Calendar Entry. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-createcalendarentry", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.CreateLog", + "Locale": "en", + "TitleKey": "Permission.CreateLog", + "Body": "Create Log. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-createlog", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.CreateShift", + "Locale": "en", + "TitleKey": "Permission.CreateShift", + "Body": "Create Shift. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-createshift", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.AdjustInventory", + "Locale": "en", + "TitleKey": "Permission.AdjustInventory", + "Body": "Adjust Inventory. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-adjustinventory", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.CreateMessage", + "Locale": "en", + "TitleKey": "Permission.CreateMessage", + "Body": "Create Message. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-createmessage", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.DeleteCall", + "Locale": "en", + "TitleKey": "Permission.DeleteCall", + "Body": "Delete Call. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-deletecall", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.CloseCall", + "Locale": "en", + "TitleKey": "Permission.CloseCall", + "Body": "Close Call. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-closecall", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.AddCallData", + "Locale": "en", + "TitleKey": "Permission.AddCallData", + "Body": "Add Call Data. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-addcalldata", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.ContactEdit", + "Locale": "en", + "TitleKey": "Permission.ContactEdit", + "Body": "Contact Edit. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-contactedit", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.ContactView", + "Locale": "en", + "TitleKey": "Permission.ContactView", + "Body": "Contact View. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-contactview", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.ContactDelete", + "Locale": "en", + "TitleKey": "Permission.ContactDelete", + "Body": "Contact Delete. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-contactdelete", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.CreateWorkflow", + "Locale": "en", + "TitleKey": "Permission.CreateWorkflow", + "Body": "Create Workflow. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-createworkflow", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.ManageWorkflowCredentials", + "Locale": "en", + "TitleKey": "Permission.ManageWorkflowCredentials", + "Body": "Manage Workflow Credentials. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-manageworkflowcredentials", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.ViewWorkflowRuns", + "Locale": "en", + "TitleKey": "Permission.ViewWorkflowRuns", + "Body": "View Workflow Runs. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-viewworkflowruns", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.ViewUdfFields", + "Locale": "en", + "TitleKey": "Permission.ViewUdfFields", + "Body": "View custom field Fields. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-viewudffields", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.ManageRoutes", + "Locale": "en", + "TitleKey": "Permission.ManageRoutes", + "Body": "Manage Routes. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-manageroutes", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.DeleteLog", + "Locale": "en", + "TitleKey": "Permission.DeleteLog", + "Body": "Delete Log. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-deletelog", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.UseCalendarSync", + "Locale": "en", + "TitleKey": "Permission.UseCalendarSync", + "Body": "Use Calendar Sync. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-usecalendarsync", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.DispatchAppLogin", + "Locale": "en", + "TitleKey": "Permission.DispatchAppLogin", + "Body": "Dispatch App Login. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-dispatchapplogin", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.CommandAppLogin", + "Locale": "en", + "TitleKey": "Permission.CommandAppLogin", + "Body": "Command App Login. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-commandapplogin", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.ManageDepartmentDataProtection", + "Locale": "en", + "TitleKey": "Permission.ManageDepartmentDataProtection", + "Body": "Manage Department Data Protection. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-managedepartmentdataprotection", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.ViewProtectedCallData", + "Locale": "en", + "TitleKey": "Permission.ViewProtectedCallData", + "Body": "View Protected Call Data. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-viewprotectedcalldata", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.EditProtectedCallData", + "Locale": "en", + "TitleKey": "Permission.EditProtectedCallData", + "Body": "Edit Protected Call Data. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-editprotectedcalldata", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.ViewProtectedPersonnelData", + "Locale": "en", + "TitleKey": "Permission.ViewProtectedPersonnelData", + "Body": "View Protected Personnel Data. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-viewprotectedpersonneldata", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.ViewProtectedContactData", + "Locale": "en", + "TitleKey": "Permission.ViewProtectedContactData", + "Body": "View Protected Contact Data. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-viewprotectedcontactdata", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.ViewProtectedOperationalData", + "Locale": "en", + "TitleKey": "Permission.ViewProtectedOperationalData", + "Body": "View Protected Operational Data. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-viewprotectedoperationaldata", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.ExportProtectedData", + "Locale": "en", + "TitleKey": "Permission.ExportProtectedData", + "Body": "Export Protected Data. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-exportprotecteddata", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.ConfigureProtectedDataEgress", + "Locale": "en", + "TitleKey": "Permission.ConfigureProtectedDataEgress", + "Body": "Configure Protected Data Egress. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-configureprotecteddataegress", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.BreakGlassProtectedData", + "Locale": "en", + "TitleKey": "Permission.BreakGlassProtectedData", + "Body": "Break Glass Protected Data. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-breakglassprotecteddata", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.CreateRecord", + "Locale": "en", + "TitleKey": "Permission.CreateRecord", + "Body": "Create Record. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-createrecord", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.DeleteRecord", + "Locale": "en", + "TitleKey": "Permission.DeleteRecord", + "Body": "Delete Record. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-deleterecord", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.ReviewRecords", + "Locale": "en", + "TitleKey": "Permission.ReviewRecords", + "Body": "Review Records. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-reviewrecords", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.ApproveRecords", + "Locale": "en", + "TitleKey": "Permission.ApproveRecords", + "Body": "Approve Records. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-approverecords", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.FinalizeRecords", + "Locale": "en", + "TitleKey": "Permission.FinalizeRecords", + "Body": "Finalize Records. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-finalizerecords", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.AmendRecords", + "Locale": "en", + "TitleKey": "Permission.AmendRecords", + "Body": "Amend Records. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-amendrecords", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.SubmitRecords", + "Locale": "en", + "TitleKey": "Permission.SubmitRecords", + "Body": "Submit Records. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-submitrecords", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.ExportRecords", + "Locale": "en", + "TitleKey": "Permission.ExportRecords", + "Body": "Export Records. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-exportrecords", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.ShareRecordsExternally", + "Locale": "en", + "TitleKey": "Permission.ShareRecordsExternally", + "Body": "Share Records Externally. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-sharerecordsexternally", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.ViewRestrictedRecords", + "Locale": "en", + "TitleKey": "Permission.ViewRestrictedRecords", + "Body": "View Restricted Records. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-viewrestrictedrecords", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.ViewLegacyRecords", + "Locale": "en", + "TitleKey": "Permission.ViewLegacyRecords", + "Body": "View Legacy Records. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-viewlegacyrecords", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.ViewGroupRecords", + "Locale": "en", + "TitleKey": "Permission.ViewGroupRecords", + "Body": "View Group Records. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-viewgrouprecords", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.ManageRecordDefinitions", + "Locale": "en", + "TitleKey": "Permission.ManageRecordDefinitions", + "Body": "Manage Record Definitions. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-managerecorddefinitions", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.PublishRecordDefinitions", + "Locale": "en", + "TitleKey": "Permission.PublishRecordDefinitions", + "Body": "Publish Record Definitions. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-publishrecorddefinitions", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.ManageRecordReports", + "Locale": "en", + "TitleKey": "Permission.ManageRecordReports", + "Body": "Manage Record Reports. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-managerecordreports", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.ManageRecordDisclosures", + "Locale": "en", + "TitleKey": "Permission.ManageRecordDisclosures", + "Body": "Manage Record Disclosures. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-managerecorddisclosures", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.ManageRecordLegalHold", + "Locale": "en", + "TitleKey": "Permission.ManageRecordLegalHold", + "Body": "Manage Record Legal Hold. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-managerecordlegalhold", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.ReassignRecordDrafts", + "Locale": "en", + "TitleKey": "Permission.ReassignRecordDrafts", + "Body": "Reassign Record Drafts. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-reassignrecorddrafts", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.RecordsPreventionAdmin", + "Locale": "en", + "TitleKey": "Permission.RecordsPreventionAdmin", + "Body": "Records Prevention Admin. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-recordspreventionadmin", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.ManageChecklists", + "Locale": "en", + "TitleKey": "Permission.ManageChecklists", + "Body": "Manage Checklists. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-managechecklists", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.ViewChecklistResults", + "Locale": "en", + "TitleKey": "Permission.ViewChecklistResults", + "Body": "View Checklist Results. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-viewchecklistresults", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.ManageWorkOrders", + "Locale": "en", + "TitleKey": "Permission.ManageWorkOrders", + "Body": "Manage Work Orders. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-manageworkorders", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.ViewAllWorkOrders", + "Locale": "en", + "TitleKey": "Permission.ViewAllWorkOrders", + "Body": "View All Work Orders. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-viewallworkorders", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.TransferInventory", + "Locale": "en", + "TitleKey": "Permission.TransferInventory", + "Body": "Transfer Inventory. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-transferinventory", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.IssueInventory", + "Locale": "en", + "TitleKey": "Permission.IssueInventory", + "Body": "Issue Inventory. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-issueinventory", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.ManageControlledSubstances", + "Locale": "en", + "TitleKey": "Permission.ManageControlledSubstances", + "Body": "Manage Controlled Substances. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-managecontrolledsubstances", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.ManageInvoicing", + "Locale": "en", + "TitleKey": "Permission.ManageInvoicing", + "Body": "Manage Invoicing. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-manageinvoicing", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.ViewInvoicing", + "Locale": "en", + "TitleKey": "Permission.ViewInvoicing", + "Body": "View Invoicing. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-viewinvoicing", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.ManageCertifications", + "Locale": "en", + "TitleKey": "Permission.ManageCertifications", + "Body": "Manage Certifications. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-managecertifications", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.ViewCertifications", + "Locale": "en", + "TitleKey": "Permission.ViewCertifications", + "Body": "View Certifications. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-viewcertifications", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.ManageCertificationSetup", + "Locale": "en", + "TitleKey": "Permission.ManageCertificationSetup", + "Body": "Manage Certification Setup. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-managecertificationsetup", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.ManageBids", + "Locale": "en", + "TitleKey": "Permission.ManageBids", + "Body": "Manage Bids. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-managebids", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.ManageContracts", + "Locale": "en", + "TitleKey": "Permission.ManageContracts", + "Body": "Manage Contracts. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-managecontracts", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.ManageDeployments", + "Locale": "en", + "TitleKey": "Permission.ManageDeployments", + "Body": "Manage Deployments. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-managedeployments", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.ApproveTimeReports", + "Locale": "en", + "TitleKey": "Permission.ApproveTimeReports", + "Body": "Approve Time Reports. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-approvetimereports", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.ManageMutualAidReimbursement", + "Locale": "en", + "TitleKey": "Permission.ManageMutualAidReimbursement", + "Body": "Manage Mutual Aid Reimbursement. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-managemutualaidreimbursement", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.ViewInternalCosts", + "Locale": "en", + "TitleKey": "Permission.ViewInternalCosts", + "Body": "View Internal Costs. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-viewinternalcosts", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.ManageWorkforceCompensation", + "Locale": "en", + "TitleKey": "Permission.ManageWorkforceCompensation", + "Body": "Manage Workforce Compensation. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-manageworkforcecompensation", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.ViewWorkforceCompensation", + "Locale": "en", + "TitleKey": "Permission.ViewWorkforceCompensation", + "Body": "View Workforce Compensation. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-viewworkforcecompensation", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.ManagePayDataReporting", + "Locale": "en", + "TitleKey": "Permission.ManagePayDataReporting", + "Body": "Manage Pay Data Reporting. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-managepaydatareporting", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permission.ExportPayDataReporting", + "Locale": "en", + "TitleKey": "Permission.ExportPayDataReporting", + "Body": "Export Pay Data Reporting. Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permission-exportpaydatareporting", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.Department.Name", + "Locale": "en", + "TitleKey": "TableField.Department.Name", + "Body": "Department / Name. Department display name. Review member-facing labels and documents that snapshot the name; changing it does not rename historical artifacts.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-department-name", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.Department.DepartmentType", + "Locale": "en", + "TitleKey": "TableField.Department.DepartmentType", + "Body": "Department / Department Type. Legacy organization-type label. The operating profile provides multiple sectors; neither establishes clinical scope or response qualifications.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-department-departmenttype", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.Department.TimeZone", + "Locale": "en", + "TitleKey": "TableField.Department.TimeZone", + "Body": "Department / Time Zone. Department time zone used by scheduling and local-date reports. Review overnight shifts and daylight-saving transitions before changing it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-department-timezone", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.Department.Use24HourTime", + "Locale": "en", + "TitleKey": "TableField.Department.Use24HourTime", + "Body": "Department / Use24 Hour Time. Preferred time display. This does not change stored timestamps or the department time zone.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-department-use24hourtime", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.Department.ManagingUserId", + "Locale": "en", + "TitleKey": "TableField.Department.ManagingUserId", + "Body": "Department / Managing User Id. Managing-member identity. Subscription and high-risk account operations may require this member; change ownership through its explicit owning workflow.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-department-managinguserid", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.Department.Code", + "Locale": "en", + "TitleKey": "TableField.Department.Code", + "Body": "Department / Code. Department code used by supported identification and access flows. Review those consumers before changing it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-department-code", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.Department.ApiKey", + "Locale": "en", + "TitleKey": "TableField.Department.ApiKey", + "Body": "Department / Api Key. Department API credential. Never place it in setup notes or model input; manage and rotate it through the authorized workflow.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-department-apikey", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.Department.PublicApiKey", + "Locale": "en", + "TitleKey": "TableField.Department.PublicApiKey", + "Body": "Department / Public Api Key. Credential used by supported public API consumers. Public in the field name does not make credential disclosure safe.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-department-publicapikey", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.Department.SharedSecret", + "Locale": "en", + "TitleKey": "TableField.Department.SharedSecret", + "Body": "Department / Shared Secret. Department integration secret. Rotation can affect integrations and needs coordinated verification.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-department-sharedsecret", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.Department.LinkCode", + "Locale": "en", + "TitleKey": "TableField.Department.LinkCode", + "Body": "Department / Link Code. Department linking code. Review who can use a link and rotate it using the owning workflow.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-department-linkcode", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.Department.ShowWelcome", + "Locale": "en", + "TitleKey": "TableField.Department.ShowWelcome", + "Body": "Department / Show Welcome. Legacy welcome flag. Restored setup dismissal is per administrator and does not use this flag as evidence of setup completion.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-department-showwelcome", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentSecurityPolicy.RequireMfa", + "Locale": "en", + "TitleKey": "TableField.DepartmentSecurityPolicy.RequireMfa", + "Body": "Department Security Policy / Require Mfa. Department-wide MFA policy, distinct from the administrator-only setting. Review enrollment, supported factors and recovery before enforcement.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentsecuritypolicy-requiremfa", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentSecurityPolicy.RequireSso", + "Locale": "en", + "TitleKey": "TableField.DepartmentSecurityPolicy.RequireSso", + "Body": "Department Security Policy / Require Sso. Requires the supported SSO policy gate. Verify the active identity provider and local recovery arrangements before removing a sign-in path.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentsecuritypolicy-requiresso", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentSecurityPolicy.SessionTimeoutMinutes", + "Locale": "en", + "TitleKey": "TableField.DepartmentSecurityPolicy.SessionTimeoutMinutes", + "Body": "Department Security Policy / Session Timeout Minutes. Idle timeout in minutes; zero defers to host behavior. Verify policy-managed sessions and each supported client rather than assuming immediate global logout.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentsecuritypolicy-sessiontimeoutminutes", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentSecurityPolicy.MaxConcurrentSessions", + "Locale": "en", + "TitleKey": "TableField.DepartmentSecurityPolicy.MaxConcurrentSessions", + "Body": "Department Security Policy / Max Concurrent Sessions. Maximum policy-managed concurrent sessions per user; zero means unlimited for this setting. Existing sessions and enforcement rollout must be checked separately.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentsecuritypolicy-maxconcurrentsessions", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentSecurityPolicy.AllowedIpRanges", + "Locale": "en", + "TitleKey": "TableField.DepartmentSecurityPolicy.AllowedIpRanges", + "Body": "Department Security Policy / Allowed Ip Ranges. Allowed login network ranges. Empty adds no range restriction. Review legitimate responder networks and recovery access before narrowing it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentsecuritypolicy-allowedipranges", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentSecurityPolicy.PasswordExpirationDays", + "Locale": "en", + "TitleKey": "TableField.DepartmentSecurityPolicy.PasswordExpirationDays", + "Body": "Department Security Policy / Password Expiration Days. Password age in days; zero disables this policy's age limit. Verify local-password and SSO behavior separately.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentsecuritypolicy-passwordexpirationdays", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentSecurityPolicy.MinPasswordLength", + "Locale": "en", + "TitleKey": "TableField.DepartmentSecurityPolicy.MinPasswordLength", + "Body": "Department Security Policy / Min Password Length. Minimum length offered by the policy editor. Actual password validators and supported credential-change paths remain authoritative.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentsecuritypolicy-minpasswordlength", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentSecurityPolicy.RequirePasswordComplexity", + "Locale": "en", + "TitleKey": "TableField.DepartmentSecurityPolicy.RequirePasswordComplexity", + "Body": "Department Security Policy / Require Password Complexity. Stored complexity preference. Consumer and editor coverage must be verified before relying on this field; it is not proof that an existing password meets a rule.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentsecuritypolicy-requirepasswordcomplexity", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentSecurityPolicy.DataClassificationLevel", + "Locale": "en", + "TitleKey": "TableField.DepartmentSecurityPolicy.DataClassificationLevel", + "Body": "Department Security Policy / Data Classification Level. Department classification label. Selecting it does not enroll ADP, establish compliance or automatically classify every record correctly.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentsecuritypolicy-dataclassificationlevel", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentSsoConfig.SsoProviderType", + "Locale": "en", + "TitleKey": "TableField.DepartmentSsoConfig.SsoProviderType", + "Body": "Department Sso Config / Sso Provider Type. Selects the identity protocol and provider configuration. OIDC and SAML have different metadata, certificates and callback requirements.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentssoconfig-ssoprovidertype", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentSsoConfig.IsEnabled", + "Locale": "en", + "TitleKey": "TableField.DepartmentSsoConfig.IsEnabled", + "Body": "Department Sso Config / Is Enabled. Makes this provider configuration available to the supported SSO flow. A saved enabled flag is not a successful sign-in test.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentssoconfig-isenabled", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentSsoConfig.ClientId", + "Locale": "en", + "TitleKey": "TableField.DepartmentSsoConfig.ClientId", + "Body": "Department Sso Config / Client Id. Client registration identifier from the identity provider. Keep tenant and audience registration consistent with the owning SSO flow.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentssoconfig-clientid", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentSsoConfig.EncryptedClientSecret", + "Locale": "en", + "TitleKey": "TableField.DepartmentSsoConfig.EncryptedClientSecret", + "Body": "Department Sso Config / Encrypted Client Secret. Encrypted provider client secret. The secret is entered and rotated on the authorized provider editor; Admin Assist only describes its purpose.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentssoconfig-encryptedclientsecret", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentSsoConfig.Authority", + "Locale": "en", + "TitleKey": "TableField.DepartmentSsoConfig.Authority", + "Body": "Department Sso Config / Authority. OIDC issuer or authority. Verify the intended tenant, discovery metadata and callback registration before enabling it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentssoconfig-authority", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentSsoConfig.MetadataUrl", + "Locale": "en", + "TitleKey": "TableField.DepartmentSsoConfig.MetadataUrl", + "Body": "Department Sso Config / Metadata Url. SAML provider metadata location. Review the trusted provider and its current signing material through the SSO editor.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentssoconfig-metadataurl", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentSsoConfig.EntityId", + "Locale": "en", + "TitleKey": "TableField.DepartmentSsoConfig.EntityId", + "Body": "Department Sso Config / Entity Id. SAML service-provider identifier registered at the identity provider. It must agree with the configured integration.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentssoconfig-entityid", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentSsoConfig.AssertionConsumerServiceUrl", + "Locale": "en", + "TitleKey": "TableField.DepartmentSsoConfig.AssertionConsumerServiceUrl", + "Body": "Department Sso Config / Assertion Consumer Service Url. Stored SAML callback configuration. Use the endpoint supplied by the owning SSO setup and verify the provider registration.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentssoconfig-assertionconsumerserviceurl", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentSsoConfig.EncryptedIdpCertificate", + "Locale": "en", + "TitleKey": "TableField.DepartmentSsoConfig.EncryptedIdpCertificate", + "Body": "Department Sso Config / Encrypted Idp Certificate. Encrypted provider verification certificate material. Review expiry and rotation through SSO; this description is not certificate validation.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentssoconfig-encryptedidpcertificate", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentSsoConfig.EncryptedSigningCertificate", + "Locale": "en", + "TitleKey": "TableField.DepartmentSsoConfig.EncryptedSigningCertificate", + "Body": "Department Sso Config / Encrypted Signing Certificate. Encrypted signing certificate and private-key material. Do not copy it into notes, logs or assistant messages.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentssoconfig-encryptedsigningcertificate", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentSsoConfig.AttributeMappingJson", + "Locale": "en", + "TitleKey": "TableField.DepartmentSsoConfig.AttributeMappingJson", + "Body": "Department Sso Config / Attribute Mapping Json. Maps provider attributes to supported member fields. Validate identity matching and missing attributes with an approved test account.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentssoconfig-attributemappingjson", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentSsoConfig.AllowLocalLogin", + "Locale": "en", + "TitleKey": "TableField.DepartmentSsoConfig.AllowLocalLogin", + "Body": "Department Sso Config / Allow Local Login. Allows a local-password path alongside this provider where policy permits. Review the department-wide SSO requirement and recovery plan together.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentssoconfig-allowlocallogin", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentSsoConfig.AutoProvisionUsers", + "Locale": "en", + "TitleKey": "TableField.DepartmentSsoConfig.AutoProvisionUsers", + "Body": "Department Sso Config / Auto Provision Users. Allows the supported sign-in flow to create members. Review identity matching, capacity and default assignments before enabling it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentssoconfig-autoprovisionusers", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentSsoConfig.DefaultRankId", + "Locale": "en", + "TitleKey": "TableField.DepartmentSsoConfig.DefaultRankId", + "Body": "Department Sso Config / Default Rank Id. Department rank assigned by supported automatic provisioning. It does not grant permissions or prove qualifications.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentssoconfig-defaultrankid", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentSsoConfig.ScimEnabled", + "Locale": "en", + "TitleKey": "TableField.DepartmentSsoConfig.ScimEnabled", + "Body": "Department Sso Config / Scim Enabled. Enables supported SCIM provisioning for this configuration. Review identity lifecycle, deactivation and token access separately from interactive sign-in.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentssoconfig-scimenabled", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentSsoConfig.EncryptedScimBearerToken", + "Locale": "en", + "TitleKey": "TableField.DepartmentSsoConfig.EncryptedScimBearerToken", + "Body": "Department Sso Config / Encrypted Scim Bearer Token. Encrypted credential for inbound SCIM requests. Rotate through the explicit token workflow and update the provider before retiring an old credential.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentssoconfig-encryptedscimbearertoken", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "two-factor", + "Locale": "en", + "TitleKey": "Feature.two-factor", + "Body": "Two-Factor Authentication Set up or manage two-factor sign-in Keep administrative access recoverable and limited to authorized people. Verify an additional administrator and review MFA and recovery procedures. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "two-factor", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "invites", + "Locale": "en", + "TitleKey": "Feature.invites", + "Body": "Manage Invites Send email invites so people create their own accounts Keep administrative access recoverable and limited to authorized people. Verify an additional administrator and review MFA and recovery procedures. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "invites", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "department-settings", + "Locale": "en", + "TitleKey": "Feature.department-settings", + "Body": "Department Settings Department profile, address, API keys and module settings Keep administrative access recoverable and limited to authorized people. Verify an additional administrator and review MFA and recovery procedures. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "department-settings", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "call-settings", + "Locale": "en", + "TitleKey": "Feature.call-settings", + "Body": "Call Settings Call types, priorities, email import and dispatch settings Keep administrative access recoverable and limited to authorized people. Verify an additional administrator and review MFA and recovery procedures. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "call-settings", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "dispatch-settings", + "Locale": "en", + "TitleKey": "Feature.dispatch-settings", + "Body": "Dispatch Settings Dispatch behaviour and notification settings Keep administrative access recoverable and limited to authorized people. Verify an additional administrator and review MFA and recovery procedures. Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "dispatch-settings", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "data-protection", + "Locale": "en", + "TitleKey": "Feature.data-protection", + "Body": "Data Protection Advanced Data Protection enrollment and policies Keep administrative access recoverable and limited to authorized people. Verify an additional administrator and review MFA and recovery procedures. Purchase and enrollment are separate. Review MFA, key recovery and effects on search, exports, integrations and notifications before enrollment.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "data-protection", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "module-settings", + "Locale": "en", + "TitleKey": "Feature.module-settings", + "Body": "Module settings Choose which optional modules appear and operate for the department. Reduce navigation clutter while understanding retained data and add-on prerequisites. Verify an additional administrator and review MFA and recovery procedures. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "module-settings", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "permissions", + "Locale": "en", + "TitleKey": "Feature.permissions", + "Body": "Permissions Configure action permissions, roles and supported group restrictions. Review who can perform sensitive work and which resources they may see. Verify an additional administrator and review MFA and recovery procedures. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "permissions", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "sso", + "Locale": "en", + "TitleKey": "Feature.sso", + "Body": "Single sign-on and provisioning Configure supported identity providers and provisioning. Review identity mapping, local fallback and recovery before enforcing SSO. Verify an additional administrator and review MFA and recovery procedures. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "sso", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "security-policy", + "Locale": "en", + "TitleKey": "Feature.security-policy", + "Body": "Security and session policy Configure supported MFA, password, session and network restrictions. Review lockout and recovery risks with an enrolled administrator. Verify an additional administrator and review MFA and recovery procedures. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "security-policy", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "audit-history", + "Locale": "en", + "TitleKey": "Feature.audit-history", + "Body": "Audit history Review recorded administrative changes and available source details. Identify what changed and who should verify the effect. Verify an additional administrator and review MFA and recovery procedures. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "audit-history", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "account-sessions", + "Locale": "en", + "TitleKey": "Feature.account-sessions", + "Body": "Account sessions and recovery Review the current member's sessions and account security controls. Keep personal account recovery distinct from department-wide settings. Verify an additional administrator and review MFA and recovery procedures. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "account-sessions", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setup-help", + "Locale": "en", + "TitleKey": "Feature.setup-help", + "Body": "Setup help and support Resume orientation and review current configuration evidence. Keep learning, configuration and verification as separate steps. Verify an additional administrator and review MFA and recovery procedures. Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setup-help", + "PackVersion": "2026.09.24.1", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + } + ] +} diff --git a/Core/Resgrid.AdminAssist/ConfigurationCatalog.cs b/Core/Resgrid.AdminAssist/ConfigurationCatalog.cs new file mode 100644 index 000000000..7669f9883 --- /dev/null +++ b/Core/Resgrid.AdminAssist/ConfigurationCatalog.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using Resgrid.Model.AdminAssist; + +namespace Resgrid.AdminAssist +{ + /// + /// Embedded, versioned YAML 1.2 documents authored in its JSON subset. Restricting the format rejects YAML + /// tags, aliases and executable bindings; the catalog is data, never a reflection invocation language. + /// + public sealed class ConfigurationCatalog : IAdminAssistCatalog + { + public string Version { get; } + public IReadOnlyList Settings { get; } + public IReadOnlyList Areas { get; } + public IReadOnlyList Capabilities { get; } + public IReadOnlyList Packs { get; } + public IReadOnlyList Rules { get; } + public IReadOnlyList Articles { get; } + + public ConfigurationCatalog() : this(ReadEmbedded()) { } + + public ConfigurationCatalog(IEnumerable documents) + { + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow }; + options.Converters.Add(new JsonStringEnumConverter()); + var packs = documents.Select(document => JsonSerializer.Deserialize(document, options) + ?? throw new InvalidDataException("Empty Admin Assist catalog document.")).ToList(); + if (packs.Count == 0 || packs.Select(p => p.Version).Distinct().Count() != 1 || string.IsNullOrWhiteSpace(packs[0].Version)) + throw new InvalidDataException("Admin Assist catalog versions must agree."); + Version = packs[0].Version; + Areas = Freeze(packs.SelectMany(p => p.Areas).OrderBy(a => a.Order).Select(a => a with { Archetypes = Freeze(a.Archetypes) })); + Settings = Freeze(packs.SelectMany(p => p.Settings).Select(s => s with + { Requires = Freeze(s.Requires), Affects = Freeze(s.Affects), Conflicts = Freeze(s.Conflicts) })); + Capabilities = Freeze(packs.SelectMany(p => p.Capabilities).Select(c => c with + { Requirements = Freeze(c.Requirements), SettingIds = Freeze(c.SettingIds), RuleIds = Freeze(c.RuleIds), + Setup = c.Setup == null ? null : c.Setup with { RuleIds = Freeze(c.Setup.RuleIds) } })); + Packs = Freeze(packs.SelectMany(p => p.Packs).Select(p => p with + { AreaIds = Freeze(p.AreaIds), RuleIds = Freeze(p.RuleIds), PrerequisiteKeys = Freeze(p.PrerequisiteKeys) })); + Rules = Freeze(packs.SelectMany(p => p.Rules).Select(r => r with + { AppliesWhen = Freeze(r.AppliesWhen), FailsWhen = Freeze(r.FailsWhen) })); + Articles = Freeze(packs.SelectMany(p => p.Articles)); + Validate(); + } + + private void Validate() + { + var areas = Unique(Areas.Select(a => a.Id)); + var settings = Unique(Settings.Select(s => s.Id)); + Unique(Capabilities.Select(c => c.Id)); + Unique(Packs.Select(p => p.Id)); + var rules = Unique(Rules.Select(r => r.Id)); + Unique(Articles.Select(a => a.Locale + "." + a.Id)); + foreach (var setting in Settings) + { + Require(areas.Contains(setting.AreaId), "Setting area missing: " + setting.Id); + Require(setting.Impact != null && !string.IsNullOrWhiteSpace(setting.HelpKey), "Setting guidance missing: " + setting.Id); + Require(setting.Requires.Concat(setting.Affects).Concat(setting.Conflicts).All(settings.Contains), "Setting dependency missing: " + setting.Id); + ValidateLocation(setting.Location); + } + foreach (var capability in Capabilities) + { + Require(areas.Contains(capability.AreaId), "Capability area missing: " + capability.Id); + Require(capability.SettingIds.All(settings.Contains) && capability.RuleIds.All(rules.Contains), "Capability reference missing: " + capability.Id); + Require(new[] { "available", "preview", "planned", "retired" }.Contains(capability.ReleaseStatus), "Invalid release state."); + if (capability.Setup != null) + Require(!string.IsNullOrWhiteSpace(capability.Setup.EvidenceId) && capability.Setup.Minimum > 0 && + !string.IsNullOrWhiteSpace(capability.Setup.GuidanceKey) && capability.Setup.RuleIds.All(rules.Contains), "Invalid setup evidence mapping: " + capability.Id); + ValidateLocation(capability.Location); + } + foreach (var rule in Rules) + { + Require(areas.Contains(rule.AreaId) && rule.FailsWhen.Count > 0, "Rule needs an area and predicate: " + rule.Id); + ValidateLocation(rule.Location); + } + foreach (var pack in Packs) + Require(pack.AreaIds.All(areas.Contains) && pack.RuleIds.All(rules.Contains), "Operating pack reference missing: " + pack.Id); + } + + private static HashSet Unique(IEnumerable ids) + { + var set = new HashSet(StringComparer.Ordinal); + foreach (var id in ids) + Require(id != null && Regex.IsMatch(id, "^[a-zA-Z][a-zA-Z0-9._-]*$") && set.Add(id), "Invalid or duplicate catalog id: " + id); + return set; + } + private static void ValidateLocation(CatalogLocation location) + { + Require(location != null && Regex.IsMatch(location.Controller, "^[A-Za-z][A-Za-z0-9]*$") && + Regex.IsMatch(location.Action, "^[A-Za-z][A-Za-z0-9]*$"), "Catalog destinations must be local MVC actions."); + } + private static void Require(bool condition, string message) + { + if (!condition) throw new InvalidDataException(message); + } + private static ReadOnlyCollection Freeze(IEnumerable items) => Array.AsReadOnly(items.ToArray()); + private static IEnumerable ReadEmbedded() + { + var assembly = typeof(ConfigurationCatalog).Assembly; + foreach (var name in assembly.GetManifestResourceNames().Where(n => n.EndsWith(".yaml", StringComparison.Ordinal)).OrderBy(n => n)) + { + using var stream = assembly.GetManifestResourceStream(name) ?? throw new InvalidDataException(name); + using var reader = new StreamReader(stream); + yield return reader.ReadToEnd(); + } + } + private sealed class CatalogDocument + { + public string Version { get; set; } = ""; + public List Settings { get; set; } = []; + public List Areas { get; set; } = []; + public List Capabilities { get; set; } = []; + public List Packs { get; set; } = []; + public List Rules { get; set; } = []; + public List Articles { get; set; } = []; + } + } +} diff --git a/Core/Resgrid.AdminAssist/ConfigurationImpactEvaluator.cs b/Core/Resgrid.AdminAssist/ConfigurationImpactEvaluator.cs new file mode 100644 index 000000000..6d3bdce3f --- /dev/null +++ b/Core/Resgrid.AdminAssist/ConfigurationImpactEvaluator.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using Resgrid.Model.AdminAssist; + +namespace Resgrid.AdminAssist +{ + /// Side-effect-free scalar overlays and health-rule deltas. Unmeasured operational effects remain explicit. + public sealed class ConfigurationImpactEvaluator(IAdminAssistCatalog catalog) + { + // Deliberate allowlist, not arbitrary reflection or client-supplied evidence. + public static readonly IReadOnlyList BooleanSettings = Array.AsReadOnly(new[] { + "DispatchShiftInsteadOfGroup", "AutoSetStatusForShiftDispatchPersonnel", "DisabledAutoAvailable", "EnableTextToCall", "EnableTextCommand", + "MappingUseMapboxOverride", "CheckInTimersAutoEnableForNewCalls", "WeatherAlertsEnabled", "RequirePasswordResetViaEmail", + "MappingPersonnelAllowStatusWithNoLocationToOverwrite", "MappingUnitAllowStatusWithNoLocationToOverwrite" + }); + public static readonly IReadOnlyList NumberSettings = Array.AsReadOnly(new[] { + "Require2FAForAdmins", "MappingPersonnelLocationTTL", "MappingUnitLocationTTL" + }); + public static bool Supports(string settingId) => settingId != null && settingId.StartsWith("setting.", StringComparison.Ordinal) && + BooleanSettings.Concat(NumberSettings).Contains(settingId.Substring(8), StringComparer.Ordinal); + + public ConfigurationImpactReport Evaluate(ConfigurationSnapshot snapshot, ConfigurationImpactRequest request, DateTime now, TimeSpan maximumAge) + { + if (request == null || !Supports(request.SettingId)) throw new ArgumentException("This setting has no scalar preview."); + if (!snapshot.Consistent || snapshot.Revision != request.ExpectedRevision) throw new AdminAssistConcurrencyException(); + var setting = catalog.Settings.Single(s => s.Id == request.SettingId); + var id = request.SettingId.Substring(8); + var original = snapshot.Find(id); + if (BooleanSettings.Contains(id)) + { + if (!request.Boolean.HasValue || request.Number.HasValue) throw new ArgumentException("A Boolean proposal is required."); + } + else if (request.Boolean.HasValue || !request.Number.HasValue || request.Number < 0 || request.Number > (id == "Require2FAForAdmins" ? 2 : 525600) || decimal.Truncate(request.Number.Value) != request.Number) + throw new ArgumentException("The proposed value is outside the supported range."); + + var values = snapshot.Evidence.ToDictionary(p => p.Key, p => p.Value, StringComparer.Ordinal); + // A proposal is known; its current value and dependencies can still be unknown. + values[id] = original with { State = EvidenceState.Known, AsOfUtc = now, Source = "InMemoryProposal", Boolean = request.Boolean, Number = request.Number, ReasonCode = null }; + var metrics = new List(); + var limits = new List { "Impact.NoMutation", "Impact.Unquantified", "Impact.Window" }; + if (id is "EnableTextToCall" or "EnableTextCommand") limits.Add("Impact.TextProviderLimits"); + if (id == "MappingUseMapboxOverride" && request.Boolean == false) + { + // The existing editor deletes these values when the override is disabled. Model that composed effect. + foreach (var presence in new[] { "mapTokenPresent", "mapStylePresent" }) + values[presence] = snapshot.Find(presence) with { State = EvidenceState.Known, Boolean = false, AsOfUtc = now, Source = "InMemoryProposal" }; + limits.Add("Impact.MapCredentialsRemoved"); + } + metrics.Add(new ConfigurationImpactMetric("Impact.StoredValue", original.IsFresh(now, maximumAge) ? EvidenceState.Known : original.State == EvidenceState.Redacted ? EvidenceState.Redacted : EvidenceState.Unknown, + original.IsFresh(now, maximumAge) ? original.Number ?? (original.Boolean.HasValue ? original.Boolean.Value ? 1m : 0m : null) : null, + request.Number ?? (request.Boolean == true ? 1 : 0), original.IsFresh(now, maximumAge) ? null : "EvidenceNotObserved")); + if (id == "Require2FAForAdmins") + { + var unenrolled = snapshot.Find("adminsWithoutMfa"); + var groupUnenrolled = snapshot.Find("groupOnlyAdminsWithoutMfa"); + decimal? Enrollment(decimal? scope) + { + if (!scope.HasValue || scope < 0 || scope > 2) return null; + if (scope == 0) return 0; + if (!unenrolled.IsFresh(now, maximumAge) || !unenrolled.Number.HasValue) return null; + if (scope == 1) return unenrolled.Number; + return groupUnenrolled.IsFresh(now, maximumAge) && groupUnenrolled.Number.HasValue ? unenrolled.Number + groupUnenrolled.Number : null; + } + var before = original.IsFresh(now, maximumAge) ? Enrollment(original.Number) : null; + var after = Enrollment(request.Number); + metrics.Add(new ConfigurationImpactMetric("Impact.AdminsRequiringEnrollment", before.HasValue && after.HasValue ? EvidenceState.Known : EvidenceState.Unknown, before, after)); + limits.Add("Impact.MfaRecoveryUnknown"); + } + var proposed = snapshot with { Evidence = new ReadOnlyDictionary(values) }; + var changes = new List(); + foreach (var definition in catalog.Rules) + { + var rule = new ConfigurationRule(definition); + var before = rule.Evaluate(snapshot, now, maximumAge).Result; + var after = rule.Evaluate(proposed, now, maximumAge).Result; + if (before != after) changes.Add(new(definition.Id, definition.TitleKey, before, after)); + } + return new(setting.Id, snapshot.Revision, snapshot.AsOfUtc, "scalar-impact-v1", setting.Impact, + metrics.AsReadOnly(), changes.AsReadOnly(), limits.AsReadOnly(), setting.Location.Url); + } + } +} diff --git a/Core/Resgrid.AdminAssist/ConfigurationRule.cs b/Core/Resgrid.AdminAssist/ConfigurationRule.cs new file mode 100644 index 000000000..fff1a51c0 --- /dev/null +++ b/Core/Resgrid.AdminAssist/ConfigurationRule.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Resgrid.Model.AdminAssist; + +namespace Resgrid.AdminAssist +{ + /// Evaluates only observed, fresh scalar metadata. Incomplete evidence cannot become a pass. + public sealed class ConfigurationRule(ConfigurationRuleDefinition definition) : IConfigurationRule + { + public ConfigurationRuleDefinition Definition { get; } = definition; + + public ConfigurationFinding Evaluate(ConfigurationSnapshot snapshot, DateTime nowUtc, TimeSpan maximumAge) + { + var ids = Definition.AppliesWhen.Concat(Definition.FailsWhen).Select(c => c.EvidenceId).Distinct().ToArray(); + RuleResult result; + bool? applies = null; + string? reason = null; + if (!snapshot.Consistent) + { + result = RuleResult.Unknown; + reason = "SnapshotChanged"; + } + else + { + applies = MatchAll(Definition.AppliesWhen, snapshot, nowUtc, maximumAge); + if (applies == false) result = RuleResult.NotApplicable; + else if (applies == null) { result = RuleResult.Unknown; reason = "ApplicabilityUnknown"; } + else + { + var fails = MatchAll(Definition.FailsWhen, snapshot, nowUtc, maximumAge); + result = fails == null ? RuleResult.Unknown : fails.Value ? RuleResult.Fail : RuleResult.Pass; + if (fails == null) reason = "EvidenceIncompleteOrStale"; + } + } + return new ConfigurationFinding(Definition.Id, Definition.AreaId, Definition.Severity, result, + Definition.TitleKey, Definition.ExplanationKey, Definition.NextActionKey, Definition.Location.Url, + ids, snapshot.Revision, nowUtc, reason, Definition.Severity == FindingSeverity.Critical && + (result == RuleResult.Fail || result == RuleResult.Unknown && applies == true && Definition.AppliesWhen.Count > 0)); + } + + private static bool? MatchAll(IReadOnlyList conditions, ConfigurationSnapshot snapshot, + DateTime now, TimeSpan age) + { + var unknown = false; + var anyFalse = false; + foreach (var condition in conditions) + { + var evidence = snapshot.Find(condition.EvidenceId); + if (!evidence.IsFresh(now, age)) { unknown = true; continue; } + bool? match = condition.Comparison switch + { + EvidenceComparison.IsTrue => evidence.Boolean, + EvidenceComparison.IsFalse => evidence.Boolean.HasValue ? !evidence.Boolean.Value : null, + EvidenceComparison.Equal when condition.Code != null => evidence.Code == null ? null : evidence.Code == condition.Code, + EvidenceComparison.NotEqual when condition.Code != null => evidence.Code == null ? null : evidence.Code != condition.Code, + _ when !evidence.Number.HasValue || !condition.Number.HasValue => null, + EvidenceComparison.Equal => evidence.Number == condition.Number, + EvidenceComparison.NotEqual => evidence.Number != condition.Number, + EvidenceComparison.Greater => evidence.Number > condition.Number, + EvidenceComparison.GreaterOrEqual => evidence.Number >= condition.Number, + EvidenceComparison.Less => evidence.Number < condition.Number, + EvidenceComparison.LessOrEqual => evidence.Number <= condition.Number, + _ => null + }; + if (match == false) anyFalse = true; + if (!match.HasValue) unknown = true; + } + return unknown ? null : !anyFalse; + } + } +} diff --git a/Core/Resgrid.AdminAssist/FindingLifecycle.cs b/Core/Resgrid.AdminAssist/FindingLifecycle.cs new file mode 100644 index 000000000..61e709b35 --- /dev/null +++ b/Core/Resgrid.AdminAssist/FindingLifecycle.cs @@ -0,0 +1,45 @@ +using System; +using Resgrid.Model; +using Resgrid.Model.AdminAssist; + +namespace Resgrid.AdminAssist +{ + public static class FindingLifecycle + { + /// Unknown evidence cannot resolve a finding; acknowledgement and exceptions never change rule results. + public static WorkflowTriggerEventType? Observe(AdminAssistFindingRow row, ConfigurationFinding finding, DateTime now) + { + var previous = (RuleResult)row.Result; + var first = row.Revision == 0; + WorkflowTriggerEventType? transition = null; + if (finding.Result == RuleResult.Fail && (row.Episode == 0 || previous == RuleResult.Pass || row.ReviewStatus == (int)FindingReviewStatus.Resolved)) + { + transition = row.Episode == 0 ? WorkflowTriggerEventType.AdminAssistFindingOpened : WorkflowTriggerEventType.AdminAssistFindingReopened; + row.Episode++; + row.ReviewStatus = (int)(row.OwnerId == null ? FindingReviewStatus.Unassigned : FindingReviewStatus.Assigned); + row.ExceptionUntil = null; + } + else if (!first && finding.Result == RuleResult.Pass && row.ReviewStatus != (int)FindingReviewStatus.Resolved && row.Episode > 0) + { + transition = WorkflowTriggerEventType.AdminAssistFindingResolved; + row.ReviewStatus = (int)FindingReviewStatus.Resolved; + row.ExceptionUntil = null; + } + // Keep an expired exception pending while evidence is unknown: it is no longer an active + // exception, but its next verified failure must still produce exactly one reopened episode. + if (row.ReviewStatus == (int)FindingReviewStatus.AcceptedException && row.ExceptionUntil <= now && finding.Result == RuleResult.Fail) + { + row.ReviewStatus = (int)(row.OwnerId == null ? FindingReviewStatus.Unassigned : FindingReviewStatus.Assigned); + row.ExceptionUntil = null; + if (finding.Result == RuleResult.Fail) { transition = WorkflowTriggerEventType.AdminAssistFindingReopened; row.Episode++; } + } + row.Result = (int)finding.Result; + row.Severity = (int)finding.Severity; + row.SnapshotRevision = finding.SnapshotRevision; + if (first) row.FirstObservedOn = now; + row.LastObservedOn = now; + row.Revision++; + return transition; + } + } +} diff --git a/Core/Resgrid.AdminAssist/Resgrid.AdminAssist.csproj b/Core/Resgrid.AdminAssist/Resgrid.AdminAssist.csproj new file mode 100644 index 000000000..a03afe74c --- /dev/null +++ b/Core/Resgrid.AdminAssist/Resgrid.AdminAssist.csproj @@ -0,0 +1,13 @@ + + + net9.0 + Debug;Release;Docker + enable + + + + + + + + diff --git a/Core/Resgrid.Chatbot/Handlers/UnitsActionHandler.cs b/Core/Resgrid.Chatbot/Handlers/UnitsActionHandler.cs index 7e3ad306d..43bb47feb 100644 --- a/Core/Resgrid.Chatbot/Handlers/UnitsActionHandler.cs +++ b/Core/Resgrid.Chatbot/Handlers/UnitsActionHandler.cs @@ -33,9 +33,9 @@ public async Task HandleAsync(ChatbotMessage message, ChatbotIn var culture = session.Culture; try { - // Layer 2 for a list: every active member may list their department's units, so the - // permission is decided once per request against session.DepartmentId. A per-row - // CanUserViewUnitAsync answers the same membership question with two lookups per unit. + // Layer 2 for a list: membership is decided once per request against session.DepartmentId; + // View Units is then applied per row from the cached visibility matrix, as the v4 unit lists do, + // rather than CanUserViewUnitAsync's database lookups per unit. if (!await _authorizationService.IsUserValidWithinLimitsAsync(session.UserId, session.DepartmentId)) return new ChatbotResponse { Text = ChatbotResources.Get("Units_NoPermission", culture), Processed = false }; @@ -58,6 +58,9 @@ public async Task HandleAsync(ChatbotMessage message, ChatbotIn if (unit == null || unit.DepartmentId != session.DepartmentId) continue; + if (!await _authorizationService.CanUserViewUnitViaMatrixAsync(unit.UnitId, session.UserId, session.DepartmentId)) + continue; + var status = await _customStateService.GetCustomUnitStateAsync(unitState); var statusText = status?.ButtonText ?? ChatbotResources.Get("Personnel_Unknown", culture); sb.AppendLine(ChatbotResources.Get("Units_Line", culture, unit.Name, statusText)); diff --git a/Core/Resgrid.Chatbot/Handlers/UnitsAvailableActionHandler.cs b/Core/Resgrid.Chatbot/Handlers/UnitsAvailableActionHandler.cs index f5cbdd948..37bdea618 100644 --- a/Core/Resgrid.Chatbot/Handlers/UnitsAvailableActionHandler.cs +++ b/Core/Resgrid.Chatbot/Handlers/UnitsAvailableActionHandler.cs @@ -43,10 +43,10 @@ public async Task HandleAsync(ChatbotMessage message, ChatbotIn var culture = session.Culture; try { - // Layer 2 for a list: every active member may list their department's units, so the - // permission is decided once per request against session.DepartmentId. A per-row - // CanUserViewUnitAsync answers the same membership question with two lookups per unit — - // over the whole department here, since availability is classified before the cap. + // Layer 2 for a list: membership is decided once per request against session.DepartmentId; + // View Units is then applied per row from the cached visibility matrix, as the v4 unit lists do, + // rather than CanUserViewUnitAsync's database lookups per unit (over the whole department here, + // since availability is classified before the cap). if (!await _authorizationService.IsUserValidWithinLimitsAsync(session.UserId, session.DepartmentId)) return new ChatbotResponse { Text = ChatbotResources.Get("Units_NoPermission", culture), Processed = false }; @@ -62,6 +62,9 @@ public async Task HandleAsync(ChatbotMessage message, ChatbotIn foreach (var unitState in unitStatuses.Where(u => u?.Unit != null && u.Unit.DepartmentId == session.DepartmentId) .OrderBy(u => u.Unit.Name)) { + if (!await _authorizationService.CanUserViewUnitViaMatrixAsync(unitState.Unit.UnitId, session.UserId, session.DepartmentId)) + continue; + var availability = await _platformReportingService.ClassifyUnitAvailabilityAsync(session.DepartmentId, unitState.State); if (availability != AvailabilityClass.Available) continue; diff --git a/Core/Resgrid.Config/AdminAssistConfig.cs b/Core/Resgrid.Config/AdminAssistConfig.cs new file mode 100644 index 000000000..79e12cd8d --- /dev/null +++ b/Core/Resgrid.Config/AdminAssistConfig.cs @@ -0,0 +1,18 @@ +namespace Resgrid.Config +{ + /// Host-owned bounds for deterministic administrative assistance. No inference settings. + public static class AdminAssistConfig + { + public static int SnapshotTimeoutSeconds = 20; + public static int MaxEvidenceRows = 2000; + public static int EvidenceFreshnessSeconds = 60; + public static int MaxHistoryPageSize = 100; + public static int TraceRetentionDays = 90; + public static int AggregateRetentionDays = 365; + public static int PersonalLearningRetentionDays = 365; + public static bool CaptureDispatchTraces = false; + public static bool DrainDispatchTraceQueue = false; + public static string TraceQueueName = "adminassisttraces-v1"; + public static bool SendAdminDigests = false; + } +} diff --git a/Core/Resgrid.Config/McpConfig.cs b/Core/Resgrid.Config/McpConfig.cs index b09efd086..4493f8064 100644 --- a/Core/Resgrid.Config/McpConfig.cs +++ b/Core/Resgrid.Config/McpConfig.cs @@ -40,6 +40,18 @@ public static class McpConfig /// Enable stdio transport (for backwards compatibility) /// public static bool EnableStdioTransport = false; + + /// + /// Tool calls allowed per minute for each access token (each signed-in session) + /// + public static int ToolCallsPerMinute = 100; + + /// + /// Tool calls allowed per minute from each client address for calls made without an access token, which in + /// practice are authenticate and refresh_access_token. Kept low: the API sees every sign-in made through MCP as + /// coming from the MCP server, so this is the only per-caller limit on password attempts made through it. + /// + public static int UnauthenticatedCallsPerMinute = 10; } } diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.ar.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.ar.resx new file mode 100644 index 000000000..2ba62f431 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.ar.resx @@ -0,0 +1,363 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms + + + System.Resources.ResXResourceWriter, System.Windows.Forms + + + إضافة شخص + + + حماية البيانات المتقدمة + + + العروض + + + اللوحة الكبيرة + + + التقويم + + + أنواع الشهادات + + + الدردشة + + + قوائم التحقق + + + اختبارات الاتصال + + + جهات الاتصال + + + العقود + + + لوحة المعلومات + + + حماية البيانات + + + إعدادات القسم + + + أقسامك + + + إعدادات التوزيع + + + المستندات + + + النماذج + + + صندوق الوارد + + + تقارير الحوادث + + + المخزون + + + إدارة الدعوات + + + الفواتير + + + التوجيه المباشر + + + الخرائط + + + إعدادات الوحدات + + + جهة اتصال جديدة + + + انتشار جديد + + + مجموعة جديدة + + + ملاحظة جديدة + + + بروتوكول جديد + + + تدريب جديد + + + وحدة جديدة + + + سير عمل جديد + + + الرسائل المرسلة + + + الصيانة الوقائية + + + البروتوكولات + + + بطاقات الأسعار + + + حالات حفظ السجلات + + + عمليات التفتيش + + + التحقيقات + + + التصاريح + + + السجلات + + + إعدادات السجلات + + + المسارات + + + الورديات + + + التدريبات + + + المصادقة الثنائية + + + تأهيل الوحدة + + + مكالمة صوتية + + + تشغيلات سير العمل + + + سير العمل + + + القوى العاملة + + + الجميع + + + الصلاحية + + + البحث والإنقاذ + + + تعديل المخزون + + + اعتماد السجلات + + + إغلاق البلاغ + + + إنشاء سير عمل + + + حذف البلاغ + + + إنهاء السجلات + + + إدارة تعريفات السجلات + + + إدارة إفصاحات السجلات + + + إدارة تقارير السجلات + + + نشر تعريفات السجلات + + + مراجعة السجلات + + + لا + + + ساعات التشغيل + + + نعم + + + العقد + + + متطوع + + + ساعات التشغيل + + + بعد + + + المنطقة + + + قبل + + + إصدار الفهرس + + + افتح صفحة الإعداد المعنية + + + حرج + + + يحتاج إلى متابعة + + + مخطط + + + معاينة + + + الأدلة حتى + + + الموضوع + + + معلومات + + + معلومات + + + مهتم بهذه الميزة + + + متاح + + + تعرّف عليه لاحقًا + + + أفهم هذه الميزة + + + جارٍ تحميل إعداد القسم… + + + لا ينطبق + + + تم التحقق + + + طباعة + + + اقرأ النص المصدر + + + الأدلة المحمية غير متاحة + + + فحص الإعدادات إرشاد إداري. فهم ميزة أو حفظ إعداد لا يثبت الجاهزية التشغيلية أو تسليم الرسائل. + + + إعادة المحاولة + + + موعد المراجعة + + + جارٍ الحفظ… + + + ابحث عن المجالات والميزات + + + ابحث في الوثائق المرجعية + + + غير مُسند + + + غير متاح + + + غير معروف + + + استخدم الآن + + + تحقق مجددًا + + + تحذير + + + استكشف Resgrid والإضافات + + + فحوص الإعداد + + + سجل التغييرات + + + نظرة عامة + + + مرجع الإعدادات + + + تقرير الإعداد + + + معالج الإعداد + + + قائمة مهام المسؤول + + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.cs b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.cs new file mode 100644 index 000000000..43579b588 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.cs @@ -0,0 +1,4 @@ +namespace Resgrid.Localization.Areas.User.AdminAssist +{ + public sealed class AdminAssist { } +} diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.de.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.de.resx new file mode 100644 index 000000000..d97eba9d1 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.de.resx @@ -0,0 +1,309 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms + + + System.Resources.ResXResourceWriter, System.Windows.Forms + + + Erweiterter Datenschutz + + + Archivierte Einsätze + + + Angebote + + + Großanzeigetafel + + + Kalender + + + Einsätze + + + Checklisten + + + Kommunikationstests + + + Kontakte + + + Verträge + + + Übersicht + + + Datenschutz + + + Abteilungseinstellungen + + + Ihre Abteilungen + + + Dokumente + + + Formulare + + + Einsatzberichte + + + Inventar + + + Rechnungen + + + Protokolle + + + Kartierung + + + Moduleinstellungen + + + Neuer Einsatz + + + Neue Gruppe + + + Neues Protokoll + + + Neue Schulung + + + Notizen + + + Vorbeugende Wartung + + + Protokolle + + + Preislisten + + + Aufbewahrungssperren für Akten + + + Prüfungen + + + Ermittlungen + + + Genehmigungen + + + Akten-Einstellungen + + + Routen + + + Schichten + + + Einheiten + + + Sprachanruf + + + Belegschaft + + + Alle + + + Such- und Rettungsdienst + + + Inventar anpassen + + + Berichte genehmigen + + + Berichte abschließen + + + Berichtsdefinitionen verwalten + + + Berichtsoffenlegungen verwalten + + + Berichtsauswertungen verwalten + + + Berichtsdefinitionen veröffentlichen + + + Berichte prüfen + + + Nein + + + Betriebsstunden + + + Ja + + + Vertrag + + + Unbekannt + + + Ehrenamtliche(r) + + + Betriebsstunden + + + Nachher + + + Vorher + + + Katalogversion + + + Zuständige Einstellungsseite öffnen + + + Kritisch + + + Handlungsbedarf + + + Geplant + + + Vorschau + + + Nachweisstand + + + Zeitpunkt + + + Informationen + + + Interesse an dieser Funktion + + + Verfügbar + + + Später kennenlernen + + + Ich verstehe diese Funktion + + + Abteilungseinrichtung wird geladen… + + + Nicht zutreffend + + + Geprüft + + + Drucken + + + Originaltext lesen + + + Geschützte Nachweise nicht verfügbar + + + Die Konfigurationsprüfung bietet administrative Orientierung. Eine verstandene Funktion oder gespeicherte Einstellung belegt weder die Einsatzbereitschaft noch die Zustellung von Nachrichten. + + + Erneut versuchen + + + Prüfung fällig + + + Speichern… + + + Bereiche und Funktionen suchen + + + Referenzdokumentation durchsuchen + + + Nicht zugewiesen + + + Nicht verfügbar + + + Unbekannt + + + Jetzt verwenden + + + Erneut prüfen + + + Warnung + + + Resgrid und Erweiterungen entdecken + + + Konfigurationsprüfungen + + + Änderungsverlauf + + + Übersicht + + + Einstellungsreferenz + + + Einrichtungsbericht + + + Einrichtungsassistent + + + Aufgabenliste für Administratoren + + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.el.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.el.resx new file mode 100644 index 000000000..7e51d8479 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.el.resx @@ -0,0 +1,390 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms + + + System.Resources.ResXResourceWriter, System.Windows.Forms + + + Προσθήκη Ατόμου + + + Προηγμένη προστασία δεδομένων + + + Αρχειοθετημένες Κλήσεις + + + Προσφορές + + + Ημερολόγιο + + + Κλήσεις + + + Τύποι Πιστοποιήσεων + + + Συνομιλία + + + Λίστες ελέγχου + + + Δοκιμές Επικοινωνίας + + + Επαφές + + + Συμβάσεις + + + Προστασία δεδομένων + + + Ρυθμίσεις Τμήματος + + + Τα Τμήματά Σας + + + Ρυθμίσεις Αποστολής + + + Έγγραφα + + + Φόρμες + + + Εισερχόμενα + + + Αναφορές συμβάντων + + + Διαχείριση Προσκλήσεων + + + Τιμολόγια + + + Ζωντανή Δρομολόγηση + + + Καταγραφές + + + Χαρτογράφηση + + + Ρυθμίσεις ενοτήτων + + + Νέα Κλήση + + + Νέα Επαφή + + + Νέα αποστολή + + + Νέα Ομάδα + + + Νέα Καταγραφή + + + Νέα Σημείωση + + + Νέο Πρωτόκολλο + + + Νέα Εκπαίδευση + + + Νέα Μονάδα + + + Νέα Ροή Εργασίας + + + Σημειώσεις + + + Απεσταλμένα Μηνύματα + + + Προσωπικό + + + Προληπτική συντήρηση + + + Πρωτόκολλα + + + Τιμοκατάλογοι + + + Δεσμεύσεις διατήρησης αρχείων + + + Επιθεωρήσεις + + + Έρευνες + + + Άδειες + + + Ρυθμίσεις αρχείων + + + Αναφορές + + + Διαδρομές + + + Βάρδιες + + + Εκπαιδεύσεις + + + Έλεγχος Ταυτότητας Δύο Παραγόντων + + + Στελέχωση Μονάδας + + + Μονάδες + + + Φωνή + + + Εκτελέσεις Ροής Εργασίας + + + Εργατικό δυναμικό + + + Όλοι + + + Δικαίωμα + + + Έρευνα και διάσωση + + + Προσαρμογή Αποθέματος + + + Έγκριση αναφορών + + + Κλείσιμο Κλήσης + + + Σύνδεση στην Εφαρμογή Command + + + Δημιουργία Ροής Εργασίας + + + Διαγραφή Κλήσης + + + Διαγραφή Καταγραφής + + + Σύνδεση στην Εφαρμογή Dispatch + + + Οριστικοποίηση αναφορών + + + Διαχείριση ορισμών αναφορών + + + Διαχείριση γνωστοποιήσεων αναφορών + + + Διαχείριση συγκεντρωτικών αναφορών + + + Δημοσίευση ορισμών αναφορών + + + Έλεγχος αναφορών + + + Χρήση Συγχρονισμού Ημερολογίου + + + Όχι + + + Ώρες λειτουργίας + + + Ναι + + + Σύμβαση + + + Εθελοντής + + + Ώρες λειτουργίας + + + Ενεργοποίηση Σύγχρονων Ειδοποιήσεων + + + Μετά + + + Περιοχή + + + Πριν + + + Έκδοση καταλόγου + + + Άνοιγμα της αντίστοιχης σελίδας ρυθμίσεων + + + Κρίσιμο + + + Χρειάζεται προσοχή + + + Προγραμματισμένη + + + Προεπισκόπηση + + + Στοιχεία έως + + + Πότε + + + Πληροφορίες + + + Πληροφορίες + + + Με ενδιαφέρει αυτή η λειτουργία + + + Διαθέσιμο + + + Ενημέρωση αργότερα + + + Κατανοώ αυτή τη λειτουργία + + + Φόρτωση ρυθμίσεων τμήματος… + + + Δεν εφαρμόζεται + + + Επαληθεύτηκε + + + Εκτύπωση + + + Ανάγνωση του κειμένου πηγής + + + Τα προστατευμένα στοιχεία δεν είναι διαθέσιμα + + + Ο έλεγχος ρυθμίσεων παρέχει διοικητική καθοδήγηση. Η κατανόηση μιας λειτουργίας ή η αποθήκευση μιας ρύθμισης δεν αποδεικνύει επιχειρησιακή ετοιμότητα ή παράδοση μηνυμάτων. + + + Δοκιμάστε ξανά + + + Προθεσμία ελέγχου + + + Αποθήκευση… + + + Αναζήτηση τομέων και λειτουργιών + + + Αναζήτηση στην τεκμηρίωση αναφοράς + + + Μη ανατεθειμένο + + + Μη διαθέσιμο + + + Άγνωστο + + + Χρήση τώρα + + + Επαλήθευση ξανά + + + Προειδοποίηση + + + Εξερεύνηση του Resgrid και των πρόσθετων + + + Έλεγχοι ρυθμίσεων + + + Ιστορικό αλλαγών + + + Επισκόπηση + + + Οδηγός αναφοράς ρυθμίσεων + + + Αναφορά ρύθμισης + + + Οδηγός ρύθμισης + + + Λίστα εργασιών διαχειριστή + + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.en.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.en.resx new file mode 100644 index 000000000..3d852e3d8 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.en.resx @@ -0,0 +1,5409 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms + + + System.Resources.ResXResourceWriter, System.Windows.Forms + + + Automation and connectivity + + + Deployments and business + + + Calls and response + + + Communication + + + Home and applications + + + Inventory and readiness + + + Contacts and site knowledge + + + Units and location + + + Checklists and maintenance + + + People and organization + + + Plans, add-ons and AI + + + Records and reporting + + + Administration and security + + + Review a Workflow and its permissions before explicitly enabling it. + + + Compare an event standby invoice with its approved deployment documentation. + + + Review a test scenario before enabling CAD intake or changing dispatch routing. + + + Preview recipients and explicitly run a communication test through its own screen. + + + A dispatcher creates a call while responders and unit crews receive their assigned work. + + + Review an expiring supply lot and arrange restocking through the inventory screen. + + + Review a receiving-agency contact and an approved site plan before a planned event. + + + Review an apparatus location and its last update before relying on the map. + + + Record an equipment defect, assign repair and review return to service through the owning module. + + + Check that the next shift has the locally required qualified crew. + + + Review feature requirements with the managing member before using the normal billing screen. + + + Review an incident report and its outstanding corrections using the Records workflow. + + + Verify an additional administrator and review MFA and recovery procedures. + + + Configure Workflows, user-defined fields, APIs, imports and integrations. + + + Understand free Deployment Finance and optional Business Operations workflows. + + + Create and receive calls, choose recipients and follow response status. + + + Use messages, notifications, communication tests, chat and Push-to-Talk. + + + Learn how the web, Dispatch, Responder, Unit, Incident Command and Big Board fit your team. + + + Track stock, equipment, issue and return, purchasing and expiry. + + + Maintain contacts, preplans, forms, notes, documents, files and links. + + + Manage units, crews, maps, location tracking, routes and weather. + + + Use included checklists and optional Readiness Pro repair and maintenance workflows. + + + Organize members, stations, groups, roles, qualifications, shifts and training. + + + Compare included features, base-plan limits and paid add-ons. + + + Understand Records, legacy Logs, reviews, prevention, investigations and reporting. + + + Review department settings, permissions, MFA, SSO, audit and data protection. + + + Reduce repeated administrative work while retaining ownership and review of external effects. + + + Connect deployment records to invoicing, reimbursement or internal cost review when needed. + + + Make call intake and recipient selection consistent across shifts. + + + Choose a communication channel appropriate to the audience and task. + + + Give each role the right app and a clear starting point. + + + See recorded shortages and due dates before they become an administrative surprise. + + + Help authorized members find the current reference and responsible contact. + + + Make resource information easier to find and show when positions are stale. + + + Connect recorded equipment checks with owned corrective work when maintenance is enabled. + + + Keep access and responsibilities aligned with the people doing the work. + + + Choose optional capabilities by the work they support and their adoption requirements. + + + Keep administrative follow-up and reporting evidence connected to the source record. + + + Keep administrative access recoverable and limited to authorized people. + + + Account sessions and recovery + + + Add Person + + + Advanced Data Protection + + + Enhanced AI + + + Business Operations + + + Push-to-Talk + + + Readiness Pro + + + API and MCP integrations + + + Archived Calls + + + Audit history + + + Bids + + + Big Board + + + Billing Settings + + + Cal OES MARS + + + MARS Action Queue + + + MARS Annual Rates + + + MARS Reconciliation + + + Calendar + + + Call Settings + + + Call templates + + + Call types and priorities + + + Calls + + + Certification Dashboard + + + Certification Settings + + + Certification Types + + + Chat + + + Chatbot and Assistant integration settings + + + Check-in timers + + + Checklist compliance reports + + + Checklist schedules and due work + + + Checklist Templates + + + Checklists + + + Communication Tests + + + Compliance Documents + + + New Message + + + Contact Categories + + + Contacts + + + Contracts + + + Field Cost Runs + + + Custom map layers + + + Personnel and unit statuses + + + Dashboard + + + Data Protection + + + Department Settings + + + Your Departments + + + Deployment From External Order + + + Deployment Finance + + + Dispatch application + + + Dispatch Settings + + + Distribution lists + + + Documents + + + Email and CAD call intake + + + Files and attachments + + + Forms + + + Groups & Stations + + + Hardware location tracking + + + Import and migration planning + + + Inbox + + + Incident command and accountability + + + Incident reports + + + Indoor maps + + + Inventory + + + Issue Equipment + + + Inventory counts, expiry and alerts + + + Inventory purchasing + + + Inventory Status + + + Transfer Inventory + + + Manage Invites + + + Accounts Receivable Aging + + + Invoices + + + Live Routing + + + Logs + + + Maintenance policies and approvals + + + Maintenance history and reports + + + Map Layers + + + Mapping + + + Module settings + + + My Certifications + + + My Demographic Response + + + New Bid + + + New Calendar Event + + + New Call + + + New Checklist + + + New Contact + + + New Contract + + + New Deployment + + + Upload Document + + + New Group + + + New Invoice + + + New Log + + + New Note + + + New Protocol + + + New Training + + + New Unit + + + New Work Order + + + New Workflow + + + Notes + + + Notification rules + + + Online Payment Settings + + + Sent Messages + + + California Pay Data Reporting + + + Permissions + + + Personnel + + + Personnel roles + + + Points of Interest + + + Preventive maintenance + + + My Profile + + + Protected workflows + + + Protocols + + + Rate Cards + + + Rate Schedules + + + Records analytics + + + Record definitions and templates + + + Disclosure requests + + + Evidence capture and provenance + + + Record exports and templates + + + Legal holds + + + Hydrants and water sources + + + Inspections + + + Investigations + + + Occupancies and preplans + + + Permits + + + Saved record reports + + + Reporting submissions + + + Records + + + Records Dashboard + + + Records Settings + + + Reports + + + Resource Cost Profiles + + + Responder application + + + Routes + + + Run cards and recommendations + + + Security and session policy + + + Setup help and support + + + Shared links + + + Shifts + + + Single sign-on and provisioning + + + Base plans and capacity + + + Text call intake and commands + + + Trainings + + + Two-Factor Authentication + + + Unit application + + + Unit Staffing + + + Unit types + + + Units + + + User-defined fields + + + Voice + + + Weather zones and alerts + + + Work Orders + + + Workflow Runs + + + Workflows + + + Workforce + + + Annual Pay Facts + + + Compensation Profiles + + + Workshifts + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Buying does not enroll the department. Review MFA, recovery, migration and integration behavior; this is not a compliance certification. + + + Planned until product and runtime availability are verified. Deterministic Setup Wizard, Setup Report and Admin Assist do not require an AI purchase. + + + Certifications and Deployment Finance do not require this add-on. Pay-data reporting additionally needs ADP Enabled; online payments require provider setup. No reimbursement or savings are guaranteed. + + + Review seats, supported clients and channel setup. PTT does not provide phone dispatch alerts or certify radio replacement. + + + Checklists do not require this add-on. Maintenance needs its module, permissions and active entitlement. Historical evidence and hold release follow owning-module rules. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup. + + + Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup. + + + Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup. + + + Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup. + + + Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup. + + + Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Purchase and enrollment are separate. Review MFA, key recovery and effects on search, exports, integrations and notifications before enrollment. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup. + + + Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup. + + + Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Checklists do not require Readiness Pro. Maintenance requires the add-on, module access and reviewed repair and approval procedures. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Requires Business Operations and Advanced Data Protection in the Enabled state. Review regional applicability and protected workforce access. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup. + + + Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review supported clients, channel membership and current subscription quantities. Push-to-Talk is distinct from phone voice alerts and department radio requirements. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Checklists do not require Readiness Pro. Maintenance requires the add-on, module access and reviewed repair and approval procedures. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup. + + + Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup. + + + Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + An administrator enrolls the department and verifies its recovery arrangements. + + + Ask for an explanation of a configuration finding when the conversational feature is available. + + + Prepare an event standby invoice or review documented mutual-aid costs. + + + A SAR coordinator uses a team channel during a training exercise. + + + A failed apparatus check leads to an explicitly created maintenance work order. + + + Review the current member's sessions and account security controls. + + + Manually create a user account in the department + + + Additional protection and scoped access for cataloged sensitive department content. + + + Planned summaries, drafts, knowledge assistance and optional Admin Assist conversation. + + + Invoicing, payments, rates, contracts, bids, MARS reimbursement, workforce and field costing. + + + Voice channels for crew communication in supported Resgrid clients. + + + Maintenance, work orders, preventive tasks, corrective work, approvals and safety holds. + + + Use supported APIs and MCP with authorized credentials and permissions. + + + Closed and historical calls + + + Review recorded administrative changes and available source details. + + + Priced estimates for customer contacts and contracts + + + Shared display clients show the configured resource and call picture. + + + Legal name, remit-to address and tax registrations printed on invoices + + + CFAA cost recovery readiness: agency, F-5 resources, annual rates and agreements + + + F-42 and expense claims to prepare, validate and hand off to the MARS portal + + + Salary Survey, Attachment A, Administrative Rate, Rate Letter and Special Equipment snapshots + + + Observed MARS invoices, local approval and payment reconciliation + + + Events, meetings and trainings you can sign up for + + + Call types, priorities, email import and dispatch settings + + + Maintain reusable call information and note templates. + + + Maintain the department's call classifications and priorities. + + + View calls and dispatches + + + Expiring and expired certifications for people and units + + + Enforcement mode, grace period and expiry notifications + + + The department's certification catalog and template gallery + + + Real-time department chat + + + Review supported chatbot platforms, account linking, limits and operational actions. + + + Configure supported check-in targets and escalation behavior. + + + Review expected, completed, skipped and overdue checks. + + + Assign recurring checks and review due occurrences. + + + Start from a checklist template + + + Apparatus, station and readiness checklists + + + Prepare and explicitly run tests of configured channels. + + + Insurance, workers' comp, SAM, licences and bonds with expiry alerts + + + Send a message, poll or callback request + + + Manage contact categories + + + People and organizations outside the department + + + Service contracts, document requirements and compliance + + + Internal loaded cost and margin for bids, calls and deployments + + + Manage supported custom map content for the department. + + + Configure status names, base meanings and supported behavior. + + + Department home: status, staffing and activity + + + Advanced Data Protection enrollment and policies + + + Department profile, address, API keys and module settings + + + Switch between the departments you belong to + + + Create a deployment from an open RMS mutual-aid order + + + Deployments, rosters, daily time reports and expenses + + + Dispatchers use the dispatch interface to review calls and resources and explicitly select recipients. + + + Dispatch behaviour and notification settings + + + Maintain reusable recipient lists for supported communication flows. + + + Upload and share documents + + + Configure supported email and CAD import formats and source credentials. + + + Attach and maintain files through their owning documents and records. + + + Custom call and dispatch forms + + + Department groups and stations + + + Associate supported tracking devices with department resources. + + + Plan source ownership and review existing import tools before copying data. + + + Your messages inbox + + + Command tools organize incident roles and accountability for authorized commanders. + + + Prepare incident reports linked to authorized call evidence. + + + Organize indoor map references and zones where supported. + + + Inventory for stations and units + + + Issue or return equipment to personnel + + + Review stock counts, expiry and configured inventory alerts. + + + Manage suppliers, purchase orders and receipts. + + + On-hand, low-stock and expiring inventory + + + Move inventory between locations + + + Send email invites so people create their own accounts + + + Outstanding invoice balances by age + + + Customer invoices and payments + + + Routing and directions for active resources + + + Run, training, work and meeting logs + + + Configure supported work-order policies and approval requirements. + + + Review maintenance work, recorded holds and repair history. + + + Manage map layers + + + Large map with layers, personnel and units + + + Choose which optional modules appear and operate for the department. + + + Your own certification records + + + Your voluntary self-identification for California pay data reporting + + + Draft a bid for a customer + + + Create a calendar event + + + Create and dispatch a new call + + + Author a checklist definition + + + Add a person or organization contact + + + Create a service contract for a customer + + + Create a deployment finance wrapper + + + Upload a new document + + + Create a group or station + + + Draft an invoice for a customer + + + Create a run report, training log or work log + + + Post a department note + + + Create a dispatch protocol + + + Create a training with optional quiz + + + Add an apparatus, vehicle or team + + + Open a maintenance work order + + + Create an automation workflow + + + Department notes: small bits of shared information + + + Select events, recipients and conditions for administrative notifications. + + + Connect your Stripe account to collect invoice payments online + + + Messages you sent + + + CRD pay data report runs: snapshots, aggregation, validation, export and the portal worksheet + + + Configure action permissions, roles and supported group restrictions. + + + People in the department, status and staffing + + + Organize department-defined role assignments. + + + Manage map points of interest + + + Configure recurring maintenance work and due tasks. + + + View and edit your own profile, contact methods and notifications + + + Configure approved releases to supported external workflow destinations. + + + Dispatch protocols and procedures + + + Billing rates for units, personnel and fees + + + Contractor rate tables: certifications, crews, vehicles, equipment, premiums and policies + + + Review supported aggregate response, workload and readiness reports. + + + Configure record forms, lifecycle, numbering and review requirements. + + + Manage disclosure scope, redaction and release approval. + + + Capture supported source evidence from the owning record workflow. + + + Configure supported export templates and review their runs. + + + Place and release holds through the authorized Records lifecycle. + + + Maintain hydrant and water-source records and their review history. + + + Schedule and record authorized inspection work and corrections. + + + Manage authorized investigation records and their controlled workflow. + + + Maintain premises, contacts, hazards and review metadata. + + + Manage supported permit records and lifecycle. + + + Create reusable report selections over authorized record data. + + + Prepare and reconcile supported reporting submissions from an authorized incident report. + + + Records queue: run reports, training and operational records + + + Records due, submissions and quality at a glance + + + Lifecycle, numbering, search, retention and visibility settings for Records + + + Generate reports from department data + + + Depreciation, fuel, maintenance and fixed costs per unit or asset + + + Individual members use the supported Responder client for their account, availability, calls and notifications. + + + Manage configured routes and supported route assignments. + + + Configure matching rules, resource requirements and supported dispatch recommendations. + + + Configure supported MFA, password, session and network restrictions. + + + Resume orientation and review current configuration evidence. + + + Configure supported shared views and links. + + + Shift signups, recurring shifts and trades + + + Configure supported identity providers and provisioning. + + + Review current plan allowances and authorized subscription options. + + + Configure source numbers, parsing and supported inbound text actions. + + + Trainings, study guides and procedures + + + Set up or manage two-factor sign-in + + + A shared unit client represents an apparatus or team rather than an individual member. + + + Assign personnel to units + + + Configure apparatus and resource types and their supported status behavior. + + + Apparatus, vehicles and teams + + + Define additional fields for supported forms and records. + + + Voice channels and push-to-talk + + + Configure monitored zones and weather alert processing. + + + Maintenance and repair work orders + + + Workflow execution history + + + Automations triggered by department events + + + Employer identity, establishments, workers, employments and job assignments + + + W-2 earnings and hours per employment for pay data reporting, with CSV import + + + Employee, role-default and department-default compensation with pay and employer-cost components + + + Configure repeating workforce schedules supported by Workshifts. + + + Keep personal account recovery distinct from department-wide settings. + + + Control protected content disclosure and recovery through the department protection lifecycle. + + + Help explain approved evidence and prepare drafts for human review when released. + + + Connect administrative commercial work to the department activities it supports. + + + Coordinate field teams through configured voice channels. + + + Give equipment defects an owner and a recorded repair and return-to-service review. + + + Connect approved systems without placing credentials in setup examples. + + + Identify what changed and who should verify the effect. + + + Choose display refresh, location age and visibility appropriate to a station screen. + + + Reduce repeated entry while keeping dispatch decisions explicit. + + + Use consistent reporting and run-card matching terms. + + + Keep automated access and confirmations under administrator control. + + + Review lone-worker and response follow-up with approved local procedures. + + + Find missing recorded checks without treating a report as safety certification. + + + Make recorded equipment and procedure checks visible to their owners. + + + Collect observed test evidence; registration alone does not prove delivery. + + + Give responders useful reference layers with an identified owner and update process. + + + Use terminology familiar to the department while preserving automation meaning. + + + Give dispatch personnel a focused operating surface alongside web administration. + + + Keep audience ownership explicit and review recipients before sending. + + + Review parsing, routing and observed integration failures before activation. + + + Keep reference material with its owner, permissions and retention policy. + + + Review device ownership, stale-position behavior and fallback sources. + + + Avoid duplicate resources and verify resulting configuration in Setup Report. + + + Prepare command structures and access before an exercise or response. + + + Review source completeness before finalization or external submission. + + + Keep building references available to authorized users without inferring safe routes. + + + Identify inventory data and supplies that need owner follow-up. + + + Connect replenishment to recorded stock needs and authorized receipt. + + + Make authority and repair completion requirements explicit. + + + Support accountable return-to-service review through the owning workflow. + + + Reduce navigation clutter while understanding retained data and add-on prerequisites. + + + Reduce missed follow-up and review unnecessary message volume. + + + Review who can perform sensitive work and which resources they may see. + + + Describe responsibility consistently without assuming that a role proves qualification. + + + Plan equipment upkeep and track corrective responsibility. + + + Keep protected-data egress scoped, reviewed and auditable. + + + Use measured source data with its scope and completeness limits. + + + Fit reporting to approved local procedures while preserving built-in definitions. + + + Assign review ownership and verify the material approved for release. + + + Preserve evidence context and access restrictions without copying it into setup guidance. + + + Keep release destinations, protected access and output handling explicit. + + + Retain affected evidence while a hold remains active. + + + Identify records needing verification without claiming current flow or availability. + + + Track administrative follow-up against configured inspection procedures. + + + Keep case responsibility and access separate from general department visibility. + + + Keep site knowledge owned and current; recorded hazards do not certify safety. + + + Assign review responsibility; a software state is not a hazardous-work safety clearance. + + + Make repeatable administrative reviews easier. + + + Review destination requirements and actual submission outcomes. + + + Separate member device setup from department configuration and verify each member's channels. + + + Prepare recurring route information with current source ownership. + + + Review candidate coverage before enabling operational use. + + + Review lockout and recovery risks with an enrolled administrator. + + + Keep learning, configuration and verification as separate steps. + + + Review exposed data and link access before distribution. + + + Review identity mapping, local fallback and recovery before enforcing SSO. + + + Choose capacity for actual department needs without inferring prices or terms. + + + Make approved inbound sources and their operational effects explicit. + + + Review device identity, assigned crew and unit communications together. + + + Group comparable resources without assuming staffing or qualification. + + + Collect the information the department actually needs with appropriate classification. + + + Review alert coverage and automatic-message settings before use. + + + Review schedule ownership and local time separately from dispatch eligibility. + + + Department Suppress Staffing Info / Enable Supress Staffing + + + Department Suppress Staffing Info / Staffing Levels To Supress + + + Dispatch Recommendation Config / Eta Shortlist Size + + + Dispatch Recommendation Config / Include Stale Locations + + + Dispatch Recommendation Config / Max Location Age Seconds + + + Dispatch Recommendation Config / Max Radius Meters + + + Dispatch Recommendation Config / Move Up Recommendations Enabled + + + Dispatch Recommendation Config / Personnel Max Location Age Seconds + + + Dispatch Recommendation Config / Rest Period Minutes + + + Dispatch Recommendation Config / Unit Minimum Staffing Level + + + Dispatch Recommendation Config / Use Routed Eta + + + Group Dispatch Scope Config / Department Wide Role Ids + + + Group Dispatch Scope Config / Enabled + + + New Call Field Policy / Rules + + + New Call Field Rule / Key + + + New Call Field Rule / Required + + + New Call Field Rule / Visible + + + Personnel List Status Order / Status Id + + + Personnel List Status Order / Weight + + + Personnel List Status Order Setting / Orders + + + Records Disclosure Config / Default Redaction Profile + + + Records Disclosure Config / Release Approver User Id + + + Records Disclosure Config / Statutory Clock Days + + + Records Numbering Config / Include Year + + + Records Numbering Config / Number Assignment + + + Records Numbering Config / Per Group Sequence + + + Records Numbering Config / Reset Yearly + + + Records Numbering Config / Sequence Width + + + Records Retention Override / Applies From + + + Records Retention Override / Definition Key + + + Records Retention Override / Retention Years + + + Records Retention Policy / Department Default Years + + + Records Retention Policy / History + + + Records Retention Policy / Last Changed By User Id + + + Records Retention Policy / Last Changed On + + + Records Retention Policy / Overrides + + + Records Retention Policy Version / Effective On + + + Records Retention Policy Version / Policy + + + Records Search Config / Include Legacy History + + + Records Search Config / Index Narrative + + + Unit Status Threshold / Alert Seconds + + + Unit Status Threshold / Base Type + + + Unit Status Threshold / Warn Seconds + + + Unit Status Thresholds / Thresholds + + + Unit Type Call Status Override / Dispatch Status + + + Unit Type Call Status Override / Release Status + + + Unit Type Call Status Override / Unit Type Id + + + Unit Type Call Status Override Setting / Overrides + + + Enables the configured staffing-level suppression in supported notification consumers; review reachability before changing it. + + + Existing staffing levels to suppress. A suppressed level is not proof that another channel reaches the member. + + + Number of straight-line candidates per requirement sent for routed ETA when enabled. The owning validator caps provider work. + + + Permit positions beyond the configured age limit in closest-unit selection, marked stale. Review the risk of outdated positions. + + + Exclude older unit positions from closest-unit candidates; zero removes the age limit. IncludeStaleLocations changes this behavior. + + + Maximum candidate distance in meters; zero removes the radius cap. This is not a response-time or route-safety guarantee. + + + Run the station coverage move-up pass after selection. Recommendations remain subject to approved local dispatch procedures. + + + Maximum age of personnel positions for closest-unit candidate selection; zero removes the age limit. + + + Deprioritize recently dispatched resources for this duration; zero disables rotation. This does not infer fatigue or medical fitness. + + + Minimum configured unit staffing level for recommendation eligibility; zero disables the gate. Units without defined seats pass, and run cards may override it. + + + Re-rank shortlisted candidates using provider travel estimates. The operational path can make external provider calls; Admin Assist does not. + + + Existing roles allowed department-wide dispatch views while group scoping is enabled. This changes view scope, not licensing or operational qualification. + + + Limit supported dispatch views to the member's group subtree. Department administrators and configured department-wide roles keep department-wide access. + + + Per-field visibility and requiredness for optional call-creation fields. Unconfigured fields remain visible and optional. + + + One supported built-in call-field key. Name, nature, priority and type cannot be removed through this policy. + + + Whether a visible optional field must be supplied. Review imports and each supported client before making a field mandatory. + + + Whether the optional call field is shown on supported creation surfaces. Hidden fields cannot be required. + + + Existing personnel-status identifier whose list position is being configured. + + + Relative list order for this status. Confirm all configured statuses remain represented. + + + Ordered status weights for supported personnel lists. This does not set availability or dispatch priority. + + + Default redaction profile in the owning disclosure workflow. Review the actual proposed release before approval. + + + Authorized member responsible for disclosure release approval. Selecting an approver does not grant missing permissions or protected-data access. + + + Configured disclosure review clock. Verify local obligations with the responsible owner; the default does not establish a legal deadline. + + + Include the year between the record prefix and sequence number. + + + Choose when numbers are assigned for definitions using department defaults. Review existing references before changing numbering behavior. + + + Use separate station/group sequences. This changes numbering, not record visibility. + + + Restart record sequences each calendar year in the department time zone. + + + Zero-padded sequence width for definitions using department defaults. The Records editor validates the supported width. + + + Prospective boundary for revisions eligible for the override, evaluated by the Records lifecycle. + + + Stable record-definition key to which this override applies. Display names do not identify a retention policy. + + + Years retained under this definition override; zero means permanent. Holds and prospective policy resolution still apply. + + + Department retention for standard record classes. Zero means permanent; protected classes, prior policy and holds can retain records longer. + + + Prior policy versions retained by the owning lifecycle so current changes do not silently rewrite historical retention. + + + Recorded policy-change actor. It is not an editable retention rule and is not exposed as raw identity in this reference. + + + Policy effective timestamp used with prior policy versions. It is not the record's retention expiry date. + + + Definition-specific prospective retention overrides. Use the Records workflow and its confirmations; Admin Assist never purges records. + + + Effective timestamp of a stored historical retention policy version. + + + Historical policy snapshot used to resolve retention for older revisions. Never edit this as an ordinary preference. + + + Include supported legacy personnel and unit logs in the Records search scope. Source permissions still apply. + + + Include unprotected narrative in search. Advanced Data Protection enrollment withdraws narrative indexing; this preference cannot override protection. + + + Seconds in this status before a higher-priority highlight; zero disables it. Highlighting does not send a page or change status. + + + Base status meaning for a threshold, independent of custom status names and colors. + + + Seconds in this status before a warning highlight; zero disables it. An alert at or before this threshold makes the row alert-only. + + + Board highlighting thresholds grouped by base status meaning. An empty list disables highlighting. + + + Status applied to this unit type on dispatch; minus one leaves it unchanged. + + + Status applied to this unit type on release; minus one leaves it unchanged. + + + Existing unit type receiving this override. A unit type name is not a qualification or staffing clearance. + + + Per-unit-type dispatch and release status overrides. The owning consumer resolves these before department defaults. + + + Choose the areas your department uses + + + Configure and verify + + + Follow up on findings + + + Use Settings Reference and Change History + + + Start or resume setup + + + Understand optional add-ons + + + Administrators selected by this policy who lack MFA enrollment + + + Department members and authorized consumers of this setting. + + + Saved call ID + + + Capacity headroom + + + Enter proposed total personnel and unit counts, including existing resources. This does not add, remove or purchase anything. + + + Preview plan capacity + + + A preview needs no rollback. Any later resource or subscription change follows its owning workflow. + + + Positive headroom is remaining allowance; a negative value exceeds the observed base-plan limit. Entity plans share one allowance across personnel and units. Add-on seats, storage, provider quotas and prices are not included. + + + Billing observations can change. Existing plan enforcement remains authoritative when adding resources. No purchase, reservation, resource deletion or entitlement change is performed. + + + Personnel newly selected + + + Personnel route attempts, including direct duplicates + + + Route selection is not channel eligibility or delivery. Profiles, suppression, registrations, provider availability and other recipient gates can prevent a send. Review the authorized Calls and Communication Tests screens; this preview never dispatches or tests a channel. + + + Dispatched groups with empty-shift fallback + + + Use a saved call as a route scenario. Enter its numeric ID from the Calls screen and choose all three proposed routing options. Current settings and membership are read afresh; nothing is sent or saved. + + + Distinct personnel selected + + + Preview dispatch routing + + + Personnel no longer selected + + + Call scenarios checked + + + Compares direct, group, role and unit-crew/group routes through the broadcaster’s recipient resolver. Repeated direct dispatch rows remain separate attempts; expanded routes deduplicate. Unit device, printer and external routes are outside these personnel counts. This does not propose group-scope or permission changes. + + + The explicit UTC time selects resolved shifts, including approvals, trades and overnight windows. Call routes, group/role membership and unit crews are current observations, not reconstructed historical assignments. Inputs are reread to detect changes during the preview. + + + Effect + + + Disabling the map override on the owning screen removes its saved style and token. Re-enabling requires re-entering those values. + + + Complete authorized map evidence could not be read. Marker effects are unknown; an unavailable source is not a zero count. + + + TTL expires location pings; a saved status location can remain visible. A shorter TTL does not necessarily remove a marker. + + + Personnel or units checked + + + Locations and statuses are read during this request and evaluated at the report time. Counts can change with new pings; historical replay and other map consumers are not included. + + + Marker counts use the v4 map selection rules and your current location permissions. They do not predict another member’s access or change location permissions. + + + Markers newly visible + + + Markers no longer visible + + + Scope 1 includes department admins and the managing member; scope 2 also includes group admins without double-counting them. Hidden current members remain in scope. This is enrollment metadata; session enforcement, supported recovery factors, other MFA policies and SSO behavior require separate verification. The owning screen requires the managing member and the current admin to enroll before enforcement can be enabled. + + + Module + + + Stored rows in the module’s primary table + + + Hide this module + + + Preview the legacy module navigation switches. Enabling a switch does not grant permissions, enroll an add-on or complete feature setup. Calls, personnel and units remain core areas. + + + Memberships whose module navigation changes + + + Current memberships with the module navigation entry enabled + + + Preview a module switch + + + Stored rows behind a hidden module entry + + + Menu counts cover current department memberships, including hidden members. They describe the shared web navigation gate, not successful sign-in, each member’s visible records or mobile clients. Content counts cover only primary Messages, Shifts, Documents, CalendarItems, Notes and Trainings rows; they include retained or expired rows. Mapping and Reports aggregate other sources; Logs/Records and Inventory have multiple data models. Their content totals remain unknown until the corresponding adapters are available. The licensed maintenance, checklist and business switches need their own entitlement-aware preview. + + + A saved switch affects navigation after its owning settings cache and page refresh. Hiding a menu does not delete data, revoke an existing API permission or prove that a worker stops. Verify relevant consumers and scheduled work separately before relying on a switch as an operational shutdown. + + + This preview reads current evidence and changes values only in memory. Use the owning screen to make a change. + + + Host broadcast gate blocks this department (1 = yes) + + + Counts use the same ordinary notification preference/contact gate as the sender. A grandfathered verification value is allowed by that gate. Missing profiles and staffing outside this department widen the range. Email addresses, phone numbers, device tokens and staffing notes are not read. Address/device validity, provider availability, SMS segmentation/retries, chat/voice and IC-app delivery remain unverified. Zero to the displayed upper bound is an ordinary channel-handoff estimate, not a device-message count, delivery guarantee or SMS bill. + + + Future window (days) + + + Email preference/contact gate — maximum members + + + Email preference/contact gate — minimum members + + + Assumed events per member in the entire window + + + Compare staffing suppression for a department-wide scenario. Enter the same number of ordinary notification events per current active member over the next 1–30 days. This is a declared scenario, not an observed event forecast. + + + Historical events sampled (not used in this scenario) + + + Current active members evaluated, including hidden members + + + Members with unavailable channel preferences + + + Unblocked members with none of these three channel gates enabled + + + Preview notification volume + + + Push preference gate — maximum members + + + Push preference gate — minimum members + + + Members potentially passing at least one channel preference/contact gate — maximum + + + Members passing at least one channel preference/contact gate — minimum + + + Assumed ordinary events per member in the window + + + The sample contains current non-deleted, non-disabled members, including hidden members, and is bounded. Event counts are your assumption across the full future window; current membership, preferences and staffing are held constant. This does not resolve notification-rule audiences, plan-limited recipient expansion, event-specific conditions, Calendar/Training overrides or historical recipients. No event history was sampled. + + + SMS preference/contact gate — maximum members + + + SMS preference/contact gate — minimum members + + + Members whose suppression cannot be determined + + + Proposed: apply the currently configured staffing suppression list + + + Members blocked by a verified staffing or host gate + + + The hypothetical change only toggles the existing staffing suppression list in memory. Actual sends use their current membership and suppression caches plus fresh provider gates, so a later send can differ. Review the list on the owning screen and run an authorized Communication Test after a real change; this preview never sends, saves or changes staffing. + + + Possible ordinary channel handoffs in the window — upper bound + + + Possible ordinary channel handoffs in the window — lower bound + + + Declared future window in days from this evaluation + + + Proposed audience + + + Department administrators + + + Department and group administrators + + + Department administrators and selected roles + + + Everyone + + + Members allowed at least one evaluated action or target + + + Allowed member/action or member/target pairs + + + Newly allowed pairs + + + Choose a supported permission and its complete proposed policy. Current members, roles, groups and resource scopes are checked afresh. The preview does not save permissions or refresh access. + + + Restrict resource visibility to group scope + + + No longer allowed pairs + + + Preview a permission change + + + Restore the prior permission using Security. Information already viewed or actions already performed cannot be recalled. + + + Selected department roles + + + Current department members evaluated + + + Uses current memberships, including hidden members but excluding disabled and deleted memberships. Resource visibility uses the shared authorization decision, including target-group ancestors. Ungrouped people do not share a group. Counts measure the named permission gate, not complete end-to-end access: identity lockout, client claims, feature access, protected data, resource ownership and other gates still apply. More than 100,000 actor/target pairs, ambiguous memberships or unavailable source data produce unknown results. + + + Resource targets (or one department action) + + + Direct authorization reads, cached visibility matrices, claims and existing sessions can refresh at different times. Verify the affected web and mobile actions after saving; this preview neither refreshes sessions nor grants access. + + + Permission + + + Personnel headroom + + + Visible personnel markers for your current access + + + Total personnel + + + Preview a proposed value + + + Proposed: include assigned unit crews + + + Proposed: use on-duty shifts instead of group membership + + + Proposed: include unit station groups + + + Proposed value + + + Verified eligibility for deletion across all dependencies + + + Expired sample headers requiring further owning-lifecycle review + + + Default years for future standard-class revisions (0 = permanent) + + + Sample headers past their historical policy window + + + Sample covers both complete record populations (0 = no, 1 = yes) + + + Expired sample headers excluded by a known direct or preservation hold, casualty, rescue or exposure content + + + Compare a prospective Records retention default. Leave blank for the system class default; zero means permanent. Existing definition overrides and historical policies are preserved. This preview cannot delete data. + + + The owning Records policy preserves previous policy versions. A prospective default applies to future finalized revisions; older revisions keep the policy that applied when they became official. Definition overrides remain in force. Restricted classes remain permanent unless an explicit authorized override applies. This is policy behavior, not a legal retention recommendation. + + + Expired sample headers excluded because historical hold coverage is uncertain + + + The sample contains at most 250 unpurged headers of each parent record type in identifier order. Counts are not population estimates when the sample is incomplete. Open, amending, recently changed and unexpired headers are excluded. Known parent and preservation holds and permanent casualty/rescue/exposure content are excluded; possible historical period holds remain uncertain. Child analyses and their holds, revision integrity, disclosure productions, evidence retention, attachments and external storage, workflow/outbox activity, submissions and search erasure still require the owning retention checks. Remaining candidates are not authorization or proof of purge eligibility. + + + Preview retention policy + + + Retention and Advanced Data Protection have separate lifecycles. Changing a default or cancelling an add-on does not decrypt or erase protected content. The owning protection policy, recovery and offboarding process remain authoritative. This preview reads metadata only and neither changes protection nor calls a purge workflow. + + + Unpurged record headers in the sample + + + Proposed default retention years + + + Restore the previous value on its owning screen where supported. Delivered messages, deleted secrets and purged data cannot be recalled. + + + Stored active, unexpired department sessions sampled + + + Sessions that will actually require reauthentication + + + Members currently at or above the proposed next-session limit + + + Enabled SSO configuration rows (provider connection not tested) + + + Existing passwords satisfying the proposed length + + + Tracked member passwords crossing the owning expiry boundary now + + + Security-policy field + + + Compare one supported security-policy field using current member and session metadata. No credentials, recovery codes, device details or provider secrets are displayed. Current sign-in state can change after this evaluation. + + + Members without verified identity metadata + + + Current-generation managed sessions crossing the idle boundary now + + + Sampled sessions created on or after the host policy date gate + + + The owning atomic insert counts active, unexpired sessions in this department created on or after the host policy gate. Reaching the limit blocks a new managed session; it does not revoke existing sessions. Zero removes this department limit. The preview uses the same counted set, including rows that may later fail another validation gate, and cannot predict concurrent logins or future expiry. + + + Current active members in the bounded sample + + + Members subject to MFA completion at the supported sign-in gate + + + Members potentially requiring MFA enrollment — maximum + + + Members requiring MFA enrollment by this policy — minimum + + + The owning validator enforces a minimum of eight characters and its fixed digit, uppercase and lowercase requirements. This field changes supported future password changes; stored password hashes cannot reveal current length. The stored complexity preference does not disable that fixed validator, and this preview does not inspect passwords. + + + Effective minimum for supported password-change validators + + + The owning password-age check expires tracked dates only after the configured day boundary. Zero disables that check; missing dates are grandfathered rather than forced to expire. Counts do not imply that SSO users use a local password or that every client enforces expiry identically. Supported sign-in/password-change flows apply the actual enforcement. + + + Members subject to the RequireSso password-login gate + + + Stored / proposed policy value (boolean: 1 = enabled) + + + Preview sign-in and session policy + + + Proposed value + + + Working provider, account linkage and recovery access + + + Usable factors and recovery arrangements + + + TwoFactorEnabled is an enrollment indicator, not proof of a working factor or recovery path. The supported policy gate requires completed Resgrid MFA for both password and SSO sign-in. Administrator-only MFA requirements remain separate and may still apply when this field is off. Review authenticator/recovery setup with affected members before enforcement; the preview does not authenticate them. + + + The RequireSso gate blocks password sign-in only when an SSO configuration is enabled. With no enabled provider, the owning service keeps its safety valve. Enabled does not prove IdP connectivity, correct claims, member linkage or emergency recovery. Provider AllowLocalLogin and client-specific gates are separate and can further restrict access. Verify a working alternate administrator session and recovery procedure before changing policy. + + + This is a bounded metadata snapshot of current members, including hidden members. It compares one field in memory through decisions shared with the owning consumers. Other sign-in, identity, enrollment, credential and source-availability checks still apply. No password hash, factor secret, IP address or session ticket is read; no session is revoked, refreshed or created. + + + Host session-policy date gate is active now (1 = yes) + + + Idle timeout applies only to managed sessions created on or after the configured host policy date gate. A blank/invalid gate means this department timeout is not enabled by that consumer; older sessions are excluded. Counts use stored last activity, which can lag because activity writes are throttled. Credential cutoff, generation, expiry, current request claims and tracking deployment determine actual reauthentication. Zero disables this department-specific idle check; it does not disable normal session expiry. + + + RequireSso set without an enabled provider (1 = safety valve applies) + + + Members without a tracked password-change date + + + Shared entity headroom + + + Roster simulation time (UTC; within seven days of now) + + + Configuration changed. Use Verify again to refresh the report, then review your proposal before previewing it. + + + Observed personnel whose displayed status changes + + + Complete authorized status evidence is unavailable. No zero-change conclusion can be drawn. + + + Proposed markers using a status location + + + Distinct personnel with an observed status in either projection + + + Compares the owning one-hour automatic-availability filter at the report time, with its one-year history bound. Hidden, disabled and deleted members are outside this query. Members with no status in either projection are outside the sample. This does not quantify shift, unit-crew or staffing-level automation. + + + Standing-by statuses in the observed sample + + + Automatic availability affects the next supported status read after the one-hour cutoff; the preview does not write a new status or prove physical availability. New status updates can change these counts. + + + Setting value (Boolean: 0 = no, 1 = yes) + + + Actual successful inbound acceptance + + + Enters the command branch before identity checks (0 = no, 1 = yes) + + + Enters the call-import branch (0 = no, 1 = yes) + + + Choose the provider path actually used by your department and a test sender number. Compare the complete proposed text-call and command switches against current source patterns. No text is received or sent. + + + Provider path + + + Matches a configured dispatch source pattern (0 = no, 1 = yes) + + + Preview a text sender scenario + + + Proposed: enable text calls + + + Proposed: enable text commands + + + Text settings are not uniform provider enforcement. The current SignalWire path reads both switches and treats unmatched senders as dispatch sources when commands are disabled. The Twilio legacy path classifies dispatch senders by patterns without consulting these two switches; its chatbot path has separate controls. This preview does not establish accepted or rejected senders, verify numbers, validate a webhook, run a parser or send a page. Review the active provider before relying on a switch to stop intake. + + + Sender scenarios evaluated + + + This is one routing scenario after department resolution and the plan gate. It does not establish number ownership, the active SMS department, supported plan, authenticated webhook, verified sender identity, chatbot permissions, opt-out exceptions, parser success or call delivery. Twilio chatbot and master-number paths, email, CAD and API imports are outside this evaluator. A zero call-branch value may still permit a command or opt-out response. The source number is used only for this request; the returned reference is masked. No historical acceptance rate is inferred. + + + Test sender number + + + Twilio legacy text path + + + Verify the owning consumer after saving; session, cache and client refresh behavior can differ. + + + Unit headroom + + + Visible unit markers for your current access + + + Total units + + + These figures cover the listed checks only. Effects without a metric, including provider delivery and session enforcement, have not been quantified. + + + Return to Setup Report and verify current evidence. Communication tests must be reviewed and started explicitly. + + + Choose a change window and responsible verifier using approved local procedures. Verify again after saving; new source data can invalidate this preview. + + + BusinessOperations availability + + + Calendar availability + + + Calendar menu name + + + Checklists availability + + + Documents availability + + + Documents menu name + + + Inventory availability + + + Inventory menu name + + + Logs availability + + + Logs menu name + + + Maintenance availability + + + Maintenance menu name + + + Mapping availability + + + Mapping menu name + + + Messaging availability + + + Messaging menu name + + + Notes availability + + + Notes menu name + + + Reports availability + + + Reports menu name + + + Shifts availability + + + Shifts menu name + + + Training availability + + + Training menu name + + + Controls availability of business operations. Disabling this module can remove navigation and block module operations; it does not delete its records. Turning it on still requires the applicable subscription, rollout and permissions. + + + Controls availability of calendar events. Disabling this module can remove navigation and block module operations; it does not delete its records. Turning it on still requires the applicable subscription, rollout and permissions. + + + Optional display name for calendar events in consumers that support the override. It changes the label, not permissions or functionality. This release has no separate name-override control in Module Settings. + + + Controls availability of checklists. Disabling this module can remove navigation and block module operations; it does not delete its records. Turning it on still requires the applicable subscription, rollout and permissions. + + + Controls availability of shared documents. Disabling this module can remove navigation and block module operations; it does not delete its records. Turning it on still requires the applicable subscription, rollout and permissions. + + + Optional display name for shared documents in consumers that support the override. It changes the label, not permissions or functionality. This release has no separate name-override control in Module Settings. + + + Controls availability of inventory. Disabling this module can remove navigation and block module operations; it does not delete its records. Turning it on still requires the applicable subscription, rollout and permissions. + + + Optional display name for inventory in consumers that support the override. It changes the label, not permissions or functionality. This release has no separate name-override control in Module Settings. + + + Controls availability of legacy logs. Disabling this module can remove navigation and block module operations; it does not delete its records. Turning it on still requires the applicable subscription, rollout and permissions. + + + Optional display name for legacy logs in consumers that support the override. It changes the label, not permissions or functionality. This release has no separate name-override control in Module Settings. + + + Controls availability of maintenance and work orders. Disabling this module can remove navigation and block module operations; it does not delete its records. Turning it on still requires the applicable subscription, rollout and permissions. + + + Optional display name for maintenance and work orders in consumers that support the override. It changes the label, not permissions or functionality. This release has no separate name-override control in Module Settings. + + + Controls availability of maps and location views. Disabling this module can remove navigation and block module operations; it does not delete its records. Turning it on still requires the applicable subscription, rollout and permissions. + + + Optional display name for maps and location views in consumers that support the override. It changes the label, not permissions or functionality. This release has no separate name-override control in Module Settings. + + + Controls availability of messages and announcements. Disabling this module can remove navigation and block module operations; it does not delete its records. Turning it on still requires the applicable subscription, rollout and permissions. + + + Optional display name for messages and announcements in consumers that support the override. It changes the label, not permissions or functionality. This release has no separate name-override control in Module Settings. + + + Controls availability of notes. Disabling this module can remove navigation and block module operations; it does not delete its records. Turning it on still requires the applicable subscription, rollout and permissions. + + + Optional display name for notes in consumers that support the override. It changes the label, not permissions or functionality. This release has no separate name-override control in Module Settings. + + + Controls availability of reports. Disabling this module can remove navigation and block module operations; it does not delete its records. Turning it on still requires the applicable subscription, rollout and permissions. + + + Optional display name for reports in consumers that support the override. It changes the label, not permissions or functionality. This release has no separate name-override control in Module Settings. + + + Controls availability of shift scheduling. Disabling this module can remove navigation and block module operations; it does not delete its records. Turning it on still requires the applicable subscription, rollout and permissions. + + + Optional display name for shift scheduling in consumers that support the override. It changes the label, not permissions or functionality. This release has no separate name-override control in Module Settings. + + + Controls availability of training. Disabling this module can remove navigation and block module operations; it does not delete its records. Turning it on still requires the applicable subscription, rollout and permissions. + + + Optional display name for training in consumers that support the override. It changes the label, not permissions or functionality. This release has no separate name-override control in Module Settings. + + + Emergency management and response + + + EMS and ambulance + + + Fire + + + Hazardous materials response + + + Industrial response + + + Mental health and mobile crisis + + + Multi-agency and mutual aid + + + Search and rescue + + + Security and facilities + + + The pack can organize declared sites, personnel, supply expiry and continuity references. Shelter capacity, welfare needs, external agency availability, deployment conflicts and actual communications coverage are not yet measured by these checks. Review those sources with the response coordinator. + + + The pack can flag credential expiry, configured staffing gaps and stock/check metadata. It does not verify clinical scope, patient-care protocols, drug administration authority, medical equipment suitability or patient handover completion. Use the approved clinical and logistics systems with their responsible owners. + + + The pack can flag configured roster vacancies, qualifications, equipment checks and holds. It does not verify apparatus minimum staffing, command competence, local response standards or pre-incident plans. Have the responsible fire-service owner review these sources and the fallback plan. + + + The pack can flag recorded qualifications, overdue checks, expiring supplies and safety holds. It does not infer Hazmat response level, instrument calibration compliance, exposure clearance or protective-equipment suitability. Review actual approved requirements with the Hazmat program owner. + + + The pack can flag site-reference gaps, qualifications, equipment restrictions and supply expiry. Permit-to-work authority, process hazards, contractor clearance, site shutdown state and external plant alarms are not evaluated. The site response owner must verify those sources. + + + The pack can organize team coverage, protected access and current procedure references. After-hours referral ownership, crisis-service availability, clinical supervision and case handover are not yet measured. Confirm these with the responsible service owner without entering client narratives into setup. + + + The pack can organize shared-work areas, personnel and agreement references. External agency commitments, resource typing, cross-agency credentials, simultaneous assignments, cost eligibility and reimbursement acceptance are not verified. Confirm these with the participating agencies and finance owner. + + + The pack can flag configured roster gaps, credentials and overdue equipment checks. Search-sector coverage, terrain access, radio coverage, external resource location, missing-person case handling and mission suitability require their owning systems and a search-service reviewer. + + + The pack can review scoped resource visibility, configured shifts, credentials and check-in configuration. Post orders, lone-worker response arrangements, client deadlines and actual site access are not verified by these checks. Review them with the responsible operations owner. + + + Capability-gap tracking owner: Resgrid.Core. Sector-specific acceptance review is pending; these prompts are not approved local operating requirements. + + + Use approved local requirements. Missing external evidence remains unknown; this report does not certify operational safety. + + + Review activation rosters, volunteer onboarding, partner contacts and supplies. + + + Review qualification mix, ambulance checks, supply expiry and receiving contacts. + + + Review apparatus, qualified crew, run cards and mutual-aid arrangements. + + + Review locally approved qualifications, instrument checks, PPE and procedure review. + + + Review site coverage, escalation, equipment restrictions and approved local procedures. + + + Review clinician, peer and interpreter coverage, escalation contacts and restricted records. + + + Review agency-specific recipients, agreements, resource typing and cost documentation. + + + Review specialty qualifications, team availability, equipment checks and check-in arrangements. + + + Review site and post coverage, qualifications, check-in configuration and client deadlines. + + + Add Call Data + + + Add Personnel + + + Adjust Inventory + + + Amend Records + + + Approve Records + + + Approve Time Reports + + + Break Glass Protected Data + + + View personnel locations + + + View unit locations + + + Close Call + + + Command App Login + + + Configure Protected Data Egress + + + Contact Delete + + + Contact Edit + + + Contact View + + + Create Calendar Entry + + + Create calls + + + Create Document + + + Create Log + + + Create Message + + + Create notes + + + Create Record + + + Create Shift + + + Create Training + + + Create Workflow + + + Delete Call + + + Delete Log + + + Delete Record + + + Dispatch App Login + + + Edit Protected Call Data + + + Export Pay Data Reporting + + + Export Protected Data + + + Export Records + + + Finalize Records + + + Issue Inventory + + + Manage Bids + + + Manage Certification Setup + + + Manage Certifications + + + Manage Checklists + + + Manage Contracts + + + Manage Controlled Substances + + + Manage Department Data Protection + + + Manage Deployments + + + Manage Invoicing + + + Manage Mutual Aid Reimbursement + + + Manage Pay Data Reporting + + + Manage Record Definitions + + + Manage Record Disclosures + + + Manage Record Legal Hold + + + Manage Record Reports + + + Manage Routes + + + Manage Work Orders + + + Manage Workflow Credentials + + + Manage Workforce Compensation + + + Publish Record Definitions + + + Reassign Record Drafts + + + Records Prevention Admin + + + Remove Personnel + + + Review Records + + + Share Records Externally + + + Submit Records + + + Transfer Inventory + + + Use Calendar Sync + + + View All Work Orders + + + View Certifications + + + View Checklist Results + + + View Group Records + + + View units + + + View personnel + + + View Internal Costs + + + View Invoicing + + + View Legacy Records + + + View personal information + + + View Protected Call Data + + + View Protected Contact Data + + + View Protected Operational Data + + + View Protected Personnel Data + + + View Restricted Records + + + View custom field Fields + + + View Workflow Runs + + + View Workforce Compensation + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Controls the location visibility gate for each viewer and person. The preview counts access pairs, not current map markers, tracking consent or GPS availability. + + + Controls the location visibility gate for each viewer and unit. A group lock refers to the unit’s station and the owning group hierarchy, not any station a viewer can otherwise see. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Allows starting a call. The department-level action uses each member’s actual administrator state and personnel roles; later dispatch validation remains authoritative. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Allows creating a department note. The action does not grant access to protected note contents or another department. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Controls visibility of unit targets and their assigned stations. Ungrouped viewers and unassigned units do not share a group. + + + Controls visibility of personnel targets. A group lock can narrow target scope; administrators of a target group or its ancestors use the owning visibility policy. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Controls the personal-information permission gate. Protected fields still require their own current authorization and data protection grant. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Accessibility preferences + + + Authoritative source systems + + + Another administrator changed this profile. Your proposed values are still shown. Reload the current profile before saving again. + + + Approved continuity procedures + + + Approximate member count (optional) + + + Dispatch model + + + Enter existing numeric document IDs for approved policies in this department. Expired documents cannot be referenced. Saving verifies existence, not approval or suitability. + + + Enter existing numeric group IDs from this department. Saving adds another optional row. + + + One or more group or document references are invalid, expired or unavailable in this department. Check the references and try again. + + + Working languages + + + Participates in mutual aid + + + No + + + Operating hours + + + Approved qualification policies + + + Reference + + + Enter existing reference identifiers, not sensitive details. Saving adds another optional row. + + + Reload current profile + + + Save operating profile + + + Season ends (MM-DD, optional) + + + Season starts (MM-DD, optional) + + + Sites and service-area groups + + + Approved staffing policies + + + Use a short source-system reference label. These labels are declarations; saving does not verify a connection or import. + + + Department time zone + + + Workforce mix + + + Yes + + + Captions + + + Career + + + Central dispatch + + + Combination + + + 24-hour operation + + + Contract + + + External dispatch + + + Large text + + + On call + + + Plain language + + + Scheduled hours + + + Screen reader + + + Seasonal + + + Self dispatch + + + Unknown + + + Volunteer + + + Accessibility preferences + + + Operating sectors + + + Authoritative system references + + + Continuity procedure documents + + + Declared member count + + + Dispatch model + + + Expected email polling interval (minutes) + + + Working languages + + + Mutual aid participation + + + Operating hours + + + Qualification policy documents + + + Profile review time + + + Profile revision + + + Season end + + + Season start + + + Sites and groups + + + Staffing policy documents + + + Workforce mix + + + Record screen-reader, caption, large-text or plain-language needs for setup planning. Verify client behavior with the people who use it. + + + Choose all sectors served by the department. A pack suggests administrative reviews; it does not establish qualification or clinical scope. + + + Short declared source-system labels. Saving this list does not verify a connection, an import or the freshness of external data. + + + Reference existing department document IDs for approved continuity procedures. Test fallback communications and ownership through the approved procedure. + + + Optional planning estimate; actual membership and subscription counts come from their owning sources. + + + Record whether dispatch is central, self-directed, external or a combination. Verify each routing path through its owning settings. + + + Optional maximum time between recorded mailbox polls, from 1 to 10080 minutes. Blank means no declared polling expectation. This checks configured email connectors, not how often calls arrive; it does not measure SMS, CAD push or API health. Review the host polling schedule before setting it. + + + Languages used by the team. This preference does not prove interpreter availability or translate operational records. + + + Declare whether mutual aid is part of operations. Agreements, access and response eligibility require separate review. + + + Describe continuous, scheduled, on-call or seasonal operation. This is context and does not create shifts or guarantee coverage. + + + Reference existing department document IDs for approved qualification policies. Do not infer a license, scope of practice or deployment qualification from a role label. + + + Server-recorded time of the most recent validated profile save; it does not certify operational readiness. + + + Server revision used to reject concurrent overwrites. Reload when another administrator has saved a newer profile. + + + End of a declared seasonal period, in MM-DD format. A period can cross a calendar year; verify actual rosters separately. + + + Start of a declared seasonal period, in MM-DD format. Provide both start and end; this does not enable or disable resources on those dates. + + + Reference existing numeric group IDs in this department. This does not grant group access or change dispatch scope. + + + Reference existing department document IDs for approved staffing policies. The save checks existence and expiry, not policy approval or adequacy. + + + Describe the workforce so setup guidance can reflect career, volunteer, contract or combined operations. + + + Administrators need MFA enrollment + + + Administrator MFA is not enforced + + + Administrator succession needs review + + + Automatic check-in timers have no configuration + + + Required checks are overdue + + + Combined text intake and commands lack sender separation + + + Communication verification needs review + + + No continuity procedure is linked + + + Qualifications expire soon + + + Email import reports a failure + + + Groups have no active members + + + Equipment restrictions need review + + + Expected email polling evidence is missing + + + Custom map style is missing + + + Custom map credentials are missing + + + Administrator password resets need review + + + Personnel locations never expire + + + Personnel capacity is nearly full + + + Referenced policy documents expire soon + + + Referenced policies are unavailable + + + Required qualifications are uncovered + + + Record review deadlines need attention + + + Dispatch recommendations have no run cards + + + Shift status automation is inactive + + + Shift coverage needs review + + + Upcoming shifts have unfilled configured slots + + + Personnel have overlapping shift assignments + + + Upcoming shift trades are incomplete + + + Declared site references are unavailable + + + Station locations are incomplete + + + Stock expires soon + + + Text call sender routing needs review + + + Units have no group + + + Unit capacity is nearly full + + + Unit locations never expire + + + Units have no type + + + Weather alerts have no zones + + + Workflow failures need review + + + Review current administrator enrollment. + + + Review the administrator MFA requirement and recovery arrangements. + + + Review another authorized administrator and account recovery. + + + Review timer targets and escalation before activation. + + + Review overdue checklist assignments. + + + Review provider-specific dispatch sender classification and verified member commands. + + + Explicitly run a reviewed Communication Test. + + + Link an approved current document or review the external source with its responsible owner. + + + Review credentials due within the next 30 days. + + + Review the import error on the authorized Call Settings screen and verify the next poll. + + + Review group membership and intended use. + + + Review active holds and corrective work. + + + Review configured mailboxes, the host polling schedule and the declared interval. SMS, CAD push and API intake require separate evidence. + + + Review the custom map style. + + + Review the map override on its protected configuration screen. + + + Review the password-reset policy. + + + Choose a location lifetime appropriate to your workflow. + + + Review member usage and current subscription options. + + + Review document replacement or expiry with the responsible policy owner. + + + Review the operating profile references and their owning documents. + + + Review the owning qualification and roster evidence. + + + Review authorized overdue records. + + + Review run cards before relying on recommendations. + + + Review shift dispatch and automatic-status settings. + + + Review coverage and preview the dispatch recipients. + + + Review the shift roster and approved local staffing requirements. + + + Review assignments and trades; role eligibility does not establish physical availability. + + + Review outstanding offers and supervisor approvals. + + + Review current sites and service-area groups in the operating profile. + + + Review station addresses and coordinates. + + + Review expiring lots and authorized replenishment. + + + Review the active provider, approved sender patterns and controlled intake verification. + + + Review each unit group assignment. + + + Review unit usage and current subscription options. + + + Review unit location lifetime and device updates. + + + Assign the appropriate unit types through the Units screen. + + + Review weather alert zones. + + + Review failed runs and their owning workflow. + + + Enforcement settings alone do not prove that every administrator has enrolled. + + + Administrative accounts control department access and configuration. + + + A single administrative account can make recovery difficult. + + + Automatic timers need applicable target configuration. + + + Recorded overdue checks need an owner and follow-up. + + + Both text calls and commands are selected without dispatch-source patterns. Member commands use verified identity; the patterns distinguish dispatch senders rather than granting every member permission. + + + Configuration and device registration do not prove delivery. + + + The reviewed operating profile has no reference to a continuity or fallback procedure. The procedure may exist elsewhere; this is an administrative review prompt. + + + Expiring qualifications may affect upcoming assignments. + + + One or more configured mailboxes currently report a failed polling state. This is the latest stored observation, not proof of a continuing provider outage. + + + An empty group has no members to receive its work. + + + Only the owning workflow can release a safety hold. + + + Mailbox polling is compared with the interval declared in the operating profile. Missing calls alone do not imply an outage; absent or future poll timestamps remain unknown. + + + An enabled map override needs a style configuration. + + + An enabled map override needs its provider credentials. + + + Sending a single-use recovery link avoids an administrator choosing a member password. + + + A zero location lifetime can keep stale personnel positions on the map. + + + Adding members may exceed the current plan limit. + + + A linked staffing, qualification or continuity document is scheduled for removal within 30 days. This is document metadata, not a legal or clinical review deadline. + + + A declared policy document was removed, expired or is no longer in this department. Existence alone does not prove policy approval or suitability. + + + Coverage follows approved local qualification and role requirements. + + + Recorded due dates need administrative follow-up. + + + Enabled recommendations need reviewed run-card configuration. + + + Automatic shift status changes need shift dispatch enabled. + + + An empty shift can cause group dispatch to fall back to every group member. + + + Resolved rosters do not fill every configured role slot in the next seven days. + + + The same person appears on duty in overlapping shifts in the next seven days. + + + A requested or pending trade has not changed the resolved roster. + + + An operating-profile group reference no longer belongs to this department or has been removed. + + + Missing station locations reduce mapping and closest-resource usefulness. + + + Recorded lot expiry can affect supply availability. + + + Text call intake is selected without source patterns. Provider paths differ: this can broaden acceptance or prevent sender classification. The stored switch alone does not prove intake enforcement. + + + Group membership affects resource organization and routing. + + + Adding units may exceed the current plan limit. + + + A zero location lifetime can keep stale unit positions on the map. + + + Unit types support consistent status and qualification configuration. + + + Enabled weather alerts need an applicable area configuration. + + + Observed failures can leave administrative work incomplete. + + + Allow Signups For Multiple Shift Groups + + + Auto Set Status For Shift Dispatch Personnel + + + Big Board Hide Unavailable + + + Big Board Map Center Address + + + Big Board Map Center Gps Coordinates + + + Big Board Map Zoom Level + + + Big Board Page Refresh + + + Brain Tree Customer Id + + + Calls Sort Order + + + Check In Timers Auto Enable For New Calls + + + Department Operating Profile + + + Disabled Auto Available + + + Dispatch Recommendation Auto Dispatch + + + Dispatch Recommendation Config + + + Dispatch Recommendation Mode + + + Dispatch Shift Instead Of Group + + + Enable Modern Notifications + + + Enable Text Command + + + Enable Text To Call + + + Force Chatbot Security Pin + + + Group Dispatch Scope Config + + + Hardware Tracking Location Retention Days + + + Hardware Tracking Mobile Fallback Enabled + + + Hardware Tracking Stale After Seconds + + + Internal Dispatch Email + + + Mapping Mapbox Access Token + + + Mapping Mapbox Style Url + + + Mapping Personnel Allow Status With No Location To Overwrite + + + Mapping Personnel Location TTL + + + Mapping Unit Allow Status With No Location To Overwrite + + + Mapping Unit Location TTL + + + Mapping Use Mapbox Override + + + Module Settings + + + New Call Field Policy + + + Paddle Customer Id + + + Personnel List Status Sort Order + + + Personnel On Unit Set Unit Status + + + Personnel Sort Order + + + Records Default Lifecycle Preset + + + Records Disclosure Config + + + Records Group Scope Config + + + Records Group Visibility Mode + + + Records Numbering Config + + + Records Retention Policy + + + Records Review Due Hours + + + Records Search Config + + + Require2 FAFor Admins + + + Require Password Reset Via Email + + + Rss Feed Key For Active Calls + + + Shift Call Dispatch Personnel Status To Set + + + Shift Call Release Personnel Status To Set + + + Staffing Suppress Staffing Levels + + + Stripe Customer Id + + + Test Enabled + + + Text To Call Import Format + + + Text To Call Number + + + Text To Call Source Numbers + + + Tts Language + + + Unit Call Dispatch Status To Set + + + Unit Call Release Status To Set + + + Unit Call Status Overrides By Unit Type + + + Unit Dispatch Also Dispatch To Assigned Personnel + + + Unit Dispatch Also Dispatch To Group + + + Unit Status Thresholds + + + Units Sort Order + + + Update Timestamp + + + Weather Alert Auto Message Schedule + + + Weather Alert Auto Message Severity + + + Weather Alert Cache Minutes + + + Weather Alert Call Integration + + + Weather Alert Excluded Events + + + Weather Alert Minimum Severity + + + Weather Alerts Enabled + + + Allow a member to sign up for multiple shift groups. Review overlaps and local coverage rules separately. + + + Apply the configured status to shift-dispatched personnel. This only takes effect with shift dispatch enabled. + + + Hide unavailable resources on the Big Board; this affects visibility, not their dispatch eligibility. + + + Address used to center the Big Board. Address details remain on their owning screen. + + + Explicit board-center coordinates take precedence over address geocoding. Partial coordinates do not overwrite the saved center. + + + Initial Big Board map zoom. An unset override lets the consuming board select its default. + + + Big Board refresh interval. Review load and the age of displayed information before changing it. + + + Legacy billing reference. Use the subscription provider workflow, not a direct settings edit. + + + Sort order for displayed calls; does not reorder provider delivery or change call priority. + + + Automatically start configured check-in timers for new calls. Review timer targets and escalation before enabling it. + + + Declared organization, workforce, dispatch, seasonal, language and policy context. Recommendations never infer licenses or clinical scope from this profile. + + + Disables automatic availability behavior. Review staffing/status reset interactions before changing it. + + + Allow the recommendation path to dispatch automatically where enabled. Preview recipients and verify local procedures first. + + + Location, ETA, rest, crew and move-up settings used by recommendation consumers. External ETA calls occur only in the owning operational flow. + + + Dispatch recommendation selection mode. Review run cards, eligible resources and human dispatch responsibility. + + + Expand dispatched groups using the resolved on-duty roster. An empty shift result falls back to group members; preview both cases. + + + Use supported modern notification behavior. Verify each consumer and channel; provider acceptance is not delivery. + + + Configured text-command preference. Dispatch-source patterns and verified member command identity are different checks. Provider and chatbot paths do not enforce this switch uniformly; review the active consumer before changing it. + + + Configured text-call preference. Enforcement differs by provider and chatbot path; the stored value alone does not prove intake is stopped or accepted. Review sender classification and perform an explicit controlled verification on the owning screen. + + + Require the member security PIN for sensitive supported chatbot and SMS actions, including members who have not opted in. + + + Group-subtree dispatch visibility with explicit role exceptions. Preview each actor and target scope before changing it. + + + Tracking retention bounded by host configuration and applicable holds. Shortening retention can make data eligible for deletion. + + + Use supported mobile location fallback when hardware data is stale. Verify each device/source rather than assuming continuous tracking. + + + Age at which a hardware location becomes stale, clamped to at least one second. Review device reporting intervals. + + + Assigned internal dispatch email address. Presence can be shown; the address stays on the authorized import screen. + + + Map-provider access token. Only presence is reported. Disabling the override deletes it and requires re-entry to restore. + + + Custom map style reference. Verify the provider style and credential together; disabling the override deletes this value. + + + Allow a personnel status without a location to replace the previous location-bearing state. This can remove a marker. + + + Maximum personnel location age in minutes. Zero keeps stale positions visible without an age limit; assess actual ping ages. + + + Allow a unit status without a location to overwrite earlier location data. Check hardware and app updates together. + + + Maximum unit location age in minutes. Zero retains stale unit locations indefinitely in supported map consumers. + + + Use the department map provider override. Turning it off on the owning screen removes the stored style and token. + + + Controls module navigation and supported feature gates. Hiding a module does not delete its records or prove independent dispatch settings are disabled. + + + Controls optional new-call fields across supported clients. Hidden fields cannot be required; core dispatch fields remain mandatory. + + + Billing-provider customer reference. Managed by authorized billing flows and never returned in assistant evidence. + + + Custom ordering of personnel statuses in lists. Review all configured statuses before replacing the order. + + + Allow personnel status updates to influence their assigned unit status. Review automation and crew assignment together. + + + Sort order for personnel lists; does not change availability, access or dispatch priority. + + + Default lifecycle for new department-owned definitions. Locked definitions retain their own lifecycle. + + + Disclosure review clock, redaction profile and release approver. Review local obligations through the authorized disclosure workflow. + + + Reserved identifier with no editable behavior in this release. Do not present it as a shipped setting. + + + Whether record visibility is department-wide or group scoped. Group scope narrows existing permission and never grants access. + + + Number assignment, year reset and sequence formatting for definitions that do not override the department configuration. + + + Default and definition-specific retention, including prior-policy history. Holds and restricted-class rules can prevent purge eligibility. + + + Default administrative review deadline; a definition-level override takes precedence. This is not a statutory deadline inference. + + + Allowed record search scope and protected-data behavior. Search access remains bounded by current source permissions. + + + Administrator MFA requirement mode. Verify actual factors and recovery for each administrator before enforcement changes. + + + Use single-use recovery links for administrator-initiated member password resets, instead of selecting a replacement password. + + + Credential for the active-call feed. Treat the feed and its key as sensitive; rotate through its owning screen. + + + Personnel status applied when shift dispatch occurs. Minus one leaves status unchanged; use an existing approved status. + + + Personnel status applied on call release for the shift-dispatch path. Minus one leaves status unchanged. + + + Staffing levels suppressed in supported notifications. Suppression can change who hears an alert and is not evidence of delivery. + + + Billing-provider customer reference managed by subscription workflows; never edit this as an ordinary department setting. + + + Department testing behavior. Do not treat enabling a test setting as proof of production readiness. + + + Parser used for inbound text call creation. Verify the sender format with a controlled example on the owning screen. + + + Provisioned inbound text number. Provisioning has external effects and must use the normal text settings flow. + + + Allowed inbound source numbers for text calls and commands. Empty sources prevent accepted intake; never display raw numbers in Admin Assist. + + + Text-to-speech language for supported voice notifications. Confirm supported voices and pronunciation with a human-run test. + + + Unit status applied during call dispatch. Minus one or an invalid built-in unit status leaves the status unchanged. + + + Unit status applied at call release. Review unit-type overrides before assuming one department-wide effect. + + + Per-unit-type call status overrides. Existing type-specific values take precedence over department defaults. + + + Expand unit dispatch to its assigned crew. Review duplicate routes, current assignments and recipient permissions. + + + Expand unit dispatch to the station group. This can substantially increase the recipient audience. + + + Visual warning thresholds for time in a unit status. A highlight does not change the unit status or certify availability. + + + Sort order for unit lists; does not change unit eligibility or dispatch recommendation ranking. + + + Internal configuration timestamp. Read-only metadata, not an administrator-editable preference. + + + Schedule for automatic weather messaging. Review the department time zone, overnight periods and daylight-saving changes. + + + Minimum weather severity eligible for automatic messages. Review recipients, schedule and duplicate suppression. + + + Weather cache duration; longer caching changes freshness and provider load. + + + Include supported weather context in call integration. It does not certify weather or route safety. + + + Weather event types excluded from configured processing. Review exclusions against approved local procedures. + + + Minimum severity eligible for the weather-alert view. Severity values come from the weather service, not local incident triage. + + + Enable supported weather-alert processing. Configure zones and communication behavior before relying on it. + + + Check-in timer configuration is recorded. Verify target selection, deadlines, escalation ownership and a controlled scenario through the owning screen. + + + Mailbox configuration and available polling metadata are checked. Parser success, routing and page delivery require a controlled authorized test. + + + Checks recorded group membership and station location metadata. Review intended dispatch and site coverage separately. + + + Current active members are recorded. This does not verify invitations, account access, role assignment or qualifications. + + + Run-card records are present. The listed check verifies presence when recommendations are enabled, not correctness of local response requirements. + + + Checks recorded unit types and groups. Crew suitability, device setup and actual communications need separate verification. + + + Weather zones are recorded. Confirm geographic coverage and the actual provider and notification path. + + + Chatbot Department Config / Allow Dispatch Via Chatbot + + + Chatbot Department Config / Allowed Platforms + + + Chatbot Department Config / Is Enabled + + + Chatbot Department Config / Llm Api Endpoint + + + Chatbot Department Config / Llm Api Key + + + Chatbot Department Config / Llm Model Name + + + Chatbot Department Config / Max Sessions Per User + + + Chatbot Department Config / Messages Per Department Per Minute + + + Chatbot Department Config / Messages Per User Per Minute + + + Chatbot Department Config / Proactive Notifications Enabled + + + Chatbot Department Config / Require Confirmation For Status Change + + + Chatbot Department Config / Require Linking Confirmation + + + Chatbot Department Config / Session Ttl Minutes + + + Department / Address Id + + + Department / Api Key + + + Department / Code + + + Department / Department Type + + + Department / Link Code + + + Department / Managing User Id + + + Department / Name + + + Department / Public Api Key + + + Department / Shared Secret + + + Department / Show Welcome + + + Department / Time Zone + + + Department / Use24 Hour Time + + + Department Call Email / Error Message + + + Department Call Email / Format Type + + + Department Call Email / Hostname + + + Department Call Email / Is Failure + + + Department Call Email / Last Check + + + Department Call Email / Password + + + Department Call Email / Port + + + Department Call Email / Use Ssl + + + Department Call Email / Username + + + Department Notification / Before Data + + + Department Notification / Current Data + + + Department Notification / Data + + + Department Notification / Department Admins + + + Department Notification / Disabled + + + Department Notification / Event Type + + + Department Notification / Everyone + + + Department Notification / Groups To Notify + + + Department Notification / Lock To Group + + + Department Notification / Lower Limit + + + Department Notification / Roles To Notify + + + Department Notification / Selected Groups Admins Only + + + Department Notification / Upper Limit + + + Department Notification / Users To Notify + + + Department Security Policy / Allowed Ip Ranges + + + Department Security Policy / Data Classification Level + + + Department Security Policy / Max Concurrent Sessions + + + Department Security Policy / Min Password Length + + + Department Security Policy / Password Expiration Days + + + Department Security Policy / Require Mfa + + + Department Security Policy / Require Password Complexity + + + Department Security Policy / Require Sso + + + Department Security Policy / Session Timeout Minutes + + + Department Sso Config / Allow Local Login + + + Department Sso Config / Assertion Consumer Service Url + + + Department Sso Config / Attribute Mapping Json + + + Department Sso Config / Authority + + + Department Sso Config / Auto Provision Users + + + Department Sso Config / Client Id + + + Department Sso Config / Default Rank Id + + + Department Sso Config / Encrypted Client Secret + + + Department Sso Config / Encrypted Idp Certificate + + + Department Sso Config / Encrypted Scim Bearer Token + + + Department Sso Config / Encrypted Signing Certificate + + + Department Sso Config / Entity Id + + + Department Sso Config / Is Enabled + + + Department Sso Config / Metadata Url + + + Department Sso Config / Scim Enabled + + + Department Sso Config / Sso Provider Type + + + Weather Alert Zone / Center Geo Location + + + Weather Alert Zone / Is Active + + + Weather Alert Zone / Is Primary + + + Weather Alert Zone / Name + + + Weather Alert Zone / Radius Miles + + + Weather Alert Zone / Zone Code + + + Permits supported chatbot dispatch actions subject to their own authorization and confirmation. Enabling it may allow real calls and pages. + + + Allowed chatbot platform codes; the stored asterisk represents the platform default. Review each connected provider and its account-linking requirements. + + + Department chatbot availability. Platform feature rollout, account linking and action permissions are separate gates. + + + Optional existing chatbot model endpoint. Phase 0 Admin Assist does not call it and does not use chatbot configuration as an AI entitlement. + + + Credential for the chatbot model provider. Never copy it into setup metadata or public reference content. + + + Model identifier used by the existing chatbot integration. This is separate from the later Admin Assist open-source inference deployment. + + + Maximum chatbot sessions per member in supported consumers. This is separate from authenticated web/mobile session limits. + + + Optional department chatbot rate bound. Consider shared demand and provider quotas before changing it. + + + Optional per-member chatbot rate bound. Review provider limits and burst behavior; this is not a delivery guarantee. + + + Allows supported proactive chatbot notifications. Recipient permissions, channel configuration and sending workflows remain authoritative. + + + Requests confirmation for supported status changes. Review each platform consumer before assuming all commands are covered. + + + Requires confirmation in the supported account-linking flow. Linking must not bypass verified identity or departmental authorization. + + + Chatbot session lifetime in minutes. Expiry does not revoke an unrelated Resgrid sign-in session. + + + Department address reference. Review the owning address and mapping consumers; it is separate from station and site addresses. + + + Department API credential. Never place it in setup notes or model input; manage and rotate it through the authorized workflow. + + + Department code used by supported identification and access flows. Review those consumers before changing it. + + + Legacy organization-type label. The operating profile provides multiple sectors; neither establishes clinical scope or response qualifications. + + + Department linking code. Review who can use a link and rotate it using the owning workflow. + + + Managing-member identity. Subscription and high-risk account operations may require this member; change ownership through its explicit owning workflow. + + + Department display name. Review member-facing labels and documents that snapshot the name; changing it does not rename historical artifacts. + + + Credential used by supported public API consumers. Public in the field name does not make credential disclosure safe. + + + Department integration secret. Rotation can affect integrations and needs coordinated verification. + + + Legacy welcome flag. Restored setup dismissal is per administrator and does not use this flag as evidence of setup completion. + + + Department time zone used by scheduling and local-date reports. Review overnight shifts and daylight-saving transitions before changing it. + + + Preferred time display. This does not change stored timestamps or the department time zone. + + + Provider/import error details on the authorized screen. Admin Assist uses a normalized failure indication and does not copy provider bodies. + + + Selects the inbound email parser. Verify representative permitted source messages without sending an operational page unintentionally. + + + Mailbox host for email intake. Review the approved provider and network reachability without exposing credentials in setup guidance. + + + Latest recorded mailbox failure flag. Verify a new poll before treating it as a continuing outage or a resolved problem. + + + Last recorded polling timestamp. Missing or future timestamps are unknown; quiet call volume is not proof of a poll failure. + + + Mailbox credential. Do not copy it into Admin Assist; rotate it through the mailbox and owning editor together. + + + Mailbox connection port. It must match the selected provider and TLS mode. + + + Mailbox transport encryption option. Verify actual provider compatibility and successful polling; a stored flag alone does not prove transport protection. + + + Mailbox account identifier used for intake. It remains on the authorized configuration screen. + + + Previous-state filter for applicable events. Review valid status IDs and the event-specific interpretation of an empty or wildcard value. + + + Current-state filter for applicable events. Compare it with the previous-state filter to avoid unintended volume. + + + Event-specific configuration payload. The owning editor validates its meaning; Admin Assist does not execute arbitrary stored expressions. + + + Includes department administrators in supported notification processing. Review overlap and channel eligibility before estimating volume. + + + Stored disabled marker. The inspected legacy notification processor does not consult this flag, so it must not be treated as a verified stop switch. Review the active consumer; queued and delivered notifications cannot be recalled. + + + Event that can trigger this notification rule. Match the event to a real administrative responsibility and expected frequency. + + + Selects the department-wide recipient option. Review actual active membership, suppression and deduplication before use. + + + Department-group recipients. Review group membership and lock-to-group behavior together. + + + Restricts applicable event processing to group scope. Review the selected event's actual source group and recipient resolver. + + + Lower threshold for event-specific alerts. Use approved local thresholds and verify the actual event interpretation. + + + Personnel-role recipients. Role membership can change; a role label is not a qualification or delivery guarantee. + + + Limits selected-group recipients to their administrators in supported event paths. It does not designate department administrators automatically. + + + Upper threshold for event-specific alerts. Units and comparisons depend on the selected event. + + + Explicit member recipients. Membership and channel eligibility are evaluated separately at send time. + + + Allowed login network ranges. Empty adds no range restriction. Review legitimate responder networks and recovery access before narrowing it. + + + Department classification label. Selecting it does not enroll ADP, establish compliance or automatically classify every record correctly. + + + Maximum policy-managed concurrent sessions per user; zero means unlimited for this setting. Existing sessions and enforcement rollout must be checked separately. + + + Minimum length offered by the policy editor. Actual password validators and supported credential-change paths remain authoritative. + + + Password age in days; zero disables this policy's age limit. Verify local-password and SSO behavior separately. + + + Department-wide MFA policy, distinct from the administrator-only setting. Review enrollment, supported factors and recovery before enforcement. + + + Stored complexity preference. Consumer and editor coverage must be verified before relying on this field; it is not proof that an existing password meets a rule. + + + Requires the supported SSO policy gate. Verify the active identity provider and local recovery arrangements before removing a sign-in path. + + + Idle timeout in minutes; zero defers to host behavior. Verify policy-managed sessions and each supported client rather than assuming immediate global logout. + + + Allows a local-password path alongside this provider where policy permits. Review the department-wide SSO requirement and recovery plan together. + + + Stored SAML callback configuration. Use the endpoint supplied by the owning SSO setup and verify the provider registration. + + + Maps provider attributes to supported member fields. Validate identity matching and missing attributes with an approved test account. + + + OIDC issuer or authority. Verify the intended tenant, discovery metadata and callback registration before enabling it. + + + Allows the supported sign-in flow to create members. Review identity matching, capacity and default assignments before enabling it. + + + Client registration identifier from the identity provider. Keep tenant and audience registration consistent with the owning SSO flow. + + + Department rank assigned by supported automatic provisioning. It does not grant permissions or prove qualifications. + + + Encrypted provider client secret. The secret is entered and rotated on the authorized provider editor; Admin Assist only describes its purpose. + + + Encrypted provider verification certificate material. Review expiry and rotation through SSO; this description is not certificate validation. + + + Encrypted credential for inbound SCIM requests. Rotate through the explicit token workflow and update the provider before retiring an old credential. + + + Encrypted signing certificate and private-key material. Do not copy it into notes, logs or assistant messages. + + + SAML service-provider identifier registered at the identity provider. It must agree with the configured integration. + + + Makes this provider configuration available to the supported SSO flow. A saved enabled flag is not a successful sign-in test. + + + SAML provider metadata location. Review the trusted provider and its current signing material through the SSO editor. + + + Enables supported SCIM provisioning for this configuration. Review identity lifecycle, deactivation and token access separately from interactive sign-in. + + + Selects the identity protocol and provider configuration. OIDC and SAML have different metadata, certificates and callback requirements. + + + Center point used by supported radius-based weather coverage. Coordinate values stay on their owning map/editor. + + + Whether the zone participates in supported weather processing. Department weather settings and worker operation are additional requirements. + + + Marks the primary zone for consumers that select a default. Review other active zones rather than assuming they are disabled. + + + Administrator-facing label for this weather zone. The name does not define its geographic coverage. + + + Radius in miles for supported geographic matching. Review actual coverage and provider behavior; larger is not automatically safer. + + + Provider weather-zone identifier. Verify the intended jurisdiction and source coverage. + + + Accept time-limited exception + + + Accepted exception + + + Needs attention + + + Features that use this add-on + + + Requires Advanced Data Protection + + + Requires Workforce and Business Operations + + + Requires Push-to-Talk + + + Requires Readiness Pro + + + Show add-on comparisons only + + + Before adopting + + + After + + + All findings + + + Area + + + Assign to me + + + Assigned + + + Availability could not be verified + + + Current access allows the next setup step + + + Availability + + + Before + + + Recorded configuration, supported checks, add-on access and personal learning are separate. No activity measurement is implied: a feature with no recorded setup is not necessarily unused. Unknown evidence remains unassessed. + + + Feature setup and optional opportunities + + + Catalog version + + + Verified checks in selected areas + + + Supported configuration checks + + + Feature configuration has not been certified. Review the owning screen and the applicable checks in this report. + + + Open owning screen + + + Another administrator changed setup. The latest workspace has been loaded; review it before saving again. + + + Critical + + + Critical checks have unknown evidence. The department cannot be shown as fully verified. + + + Department ID + + + Optional administrative follow-up using your configured notification channels. No finding details are included. Quiet hours use the department time zone. An uncertain provider handoff is not automatically retried. + + + Your weekly Resgrid configuration summary is ready. Sign in to Admin Assist to review current evidence. + + + Send me a generic weekly summary link + + + Last notification handoff + + + Scheduled digests are disabled on this deployment. Your preference is saved, but no digest will be sent until the host enables them. + + + Dismiss for me; setup stays available in the menu + + + The report could not be loaded. Retry or use the normal department screens. + + + Evidence revision + + + Example + + + Exception expires + + + Choose an expiry within 90 days. The check continues to show its actual result. Notes follow department data protection. + + + Needs attention + + + Checks needing attention + + + Not enabled for this deployment + + + Planned + + + Preview + + + Retired + + + Show + + + Fresh setup + + + Evidence as of + + + Change + + + History starts when collection is enabled. Earlier configuration changes are unavailable. + + + Subject + + + When + + + Import or migration + + + Import planning does not copy data. Use the existing reviewed import tools and verify the resulting configuration here. + + + In review + + + Configuration changed during this read. Verify again before relying on this report. + + + Information + + + Information + + + Interested in this feature + + + Choose fresh setup, review or import mode above. Decide who dispatches, who responds and which applications or shared devices each role uses. A synthetic example: a dispatcher creates a training scenario, a responder sees assigned work and a unit crew updates its status. This lesson creates no incident or message. + + + Welcome and goals + + + Review the time zone, language, address and operating profile. Declare mission, operating hours, service areas and approved policy references. Unitless and non-dispatch teams should select only the work they need. Saving this profile does not validate a local staffing or clinical policy. + + + Department and operating profile + + + Use the area choices below and explore the full feature map, including capabilities hidden from your current menus. Choose use now, learn later or a reason why an area does not apply. Mark understood features individually; reading does not verify configuration. + + + Explore every area of Resgrid + + + Review the managing member and backup administrators, MFA, groups or stations, roles and qualifications. Add or invite personnel through the owning screen only after reviewing recipients and the send action. Confirm least-privilege access with the responsible users. + + + People, structure and access + + + Configure the calls, units, statuses, intake, notifications and mapping needed for your selected mission. Review dispatch previews and source eligibility before relying on a channel. App installation, device identity, provider setup and current contact verification are separate tasks. + + + Operational essentials + + + Open the selected-workflow list in Setup Report. Check prerequisites and responsible owners for shifts, training, checklists, inventory, records, contacts and automation. Save on the normal configuration screen, use the return-to-setup link, and verify again. + + + Configure selected areas + + + Compare current included capabilities with Push-to-Talk, Advanced Data Protection, Readiness Pro and Business Operations. Review feature value, alternatives and adoption requirements. Enhanced AI remains planned. Interest and learning never purchase, start a trial, enroll protection or enable a module. + + + Understand plans and add-ons + + + Run Setup Report and review critical failures and unknowns. Use available dry-run previews. A communication test is a separate deliberate action: review its recipients and channels before sending through Communication Tests. A completed setup review does not guarantee field response or delivery. + + + Verify and practice + + + Record the reviewed evidence, unresolved work, owners and optional revisit date in Setup Report. Use the redacted print view for an authorized handover. Train other administrators and members on their normal workflows. Each new administrator keeps a separate orientation checklist. + + + Review and handover + + + Configuration effort depends on your selected areas, data quality, local policy and provider setup. Reserve time with the responsible owners; a department-specific effort estimate is not yet available. + + + Work through these nine steps at your own pace. Shared scope, personal learning and review evidence are saved separately. Configure features on their owning screens, then use Setup Report to verify current evidence. + + + Your setup journey + + + Available + + + Learn later + + + Learn about this feature + + + I understand this feature + + + Learning progress + + + Learning marked complete by you + + + Learning not yet marked complete + + + Loading department setup… + + + Subscription changes require the managing member + + + Mark this report reviewed + + + Setup mode + + + Module is disabled + + + Load more + + + Next setup actions + + + Learning and interest choices never purchase, enable, send, import or change operational configuration. + + + No matching features. + + + Not yet reviewed + + + No items in this view. + + + Not applicable + + + Department operating profile + + + Feature without an add-on requirement has no configuration recorded in the checked source + + + No add-on requirement in this release; applicable base-plan limits still apply + + + Optional add-on opportunity; no current entitlement was verified + + + Not currently released for setup + + + Current add-on entitlement verified; setup is a separate step + + + Subscribed feature with no configuration recorded in the checked source + + + Current add-on or access information is unavailable + + + Unpurchased optional add-ons do not reduce core completion. Deferring an area does not hide a critical verified failure or active critical uncertainty. + + + Optional capabilities and add-ons + + + Operating packs + + + Verified + + + Follow-up preferences + + + Print + + + This report contains only the current authorized setup summary and public guidance. Protected notes, names, source records and credentials are omitted. It is not an operational readiness certification. Handle any copy you print or save under your department’s information policy; this page creates no stored server export. + + + Open a fresh printable report + + + Use approved local staffing, qualification and continuity policies; operating packs suggest areas to review without assuming local requirements. + + + Your operating profile could not be verified. Pack suggestions are unavailable. + + + Data protection enrollment is required + + + Quiet hours end (0–23; same hour means no quiet period) + + + Quiet hours start (0–23) + + + Read source text + + + Reason + + + Protected evidence unavailable + + + Configuration health is administrative guidance. A learned feature or saved setting is not proof of operational readiness or message delivery. + + + Required add-ons + + + Resolved by verification + + + Start or resume setup + + + Retry + + + Return to setup and verify + + + Review existing setup + + + Configuration, selected setup scope or catalog version has changed since that review. Verify current evidence and review again. + + + Review due + + + Checks at the recorded administrative review + + + Last administrative review + + + Review again on (UTC date) + + + Choose an optional shared review date within the next year. It is stored at noon UTC and shown in the report; this choice does not send a reminder. Optional weekly follow-up is configured separately in the admin worklist. Clear the date to remove it. + + + The change was not saved. Reload and try again. + + + Save preferences + + + Save review date + + + Save area choice + + + Save changes on this screen first, then return to setup and verify the current evidence. The return link does not save this form. + + + Saving… + + + Why this area is not applicable + + + No current department need + + + Managed in another approved system + + + Outside our department mission + + + Managed by a responsible partner + + + Search areas and features + + + Search reference documentation + + + Choose a reason + + + Choose features of interest in Explore Resgrid and add-ons to see their prerequisites here. + + + Selected in your operating profile + + + Your selected feature interests + + + Setup choice + + + Learn the available areas and optional add-ons, configure your selected areas, then review the evidence together. + + + Recorded configuration and listed checks passed + + + Configuration recorded; verification is incomplete + + + Configuration recorded; listed checks need attention + + + Configuration has not been verified + + + No configuration recorded in the checked source + + + Current source access unavailable + + + Start review + + + View subscription options + + + Subscription status could not be verified + + + Suggested by your selected operating packs + + + Resgrid Admin Assist + + + Unassigned + + + Unavailable + + + Selected areas without automated checks + + + Unknown + + + Checks with unknown evidence + + + Use now + + + Value to your department + + + Results cover only the listed checks. Review procedures, external systems and areas without automated checks with their responsible owners. + + + Verify again + + + Choose Verify again to create or update the worklist. + + + Warning + + + Choose the work your department needs, learn the available areas, then configure each feature on its own screen. Return here to verify. + + + Last completed background evaluation + + + The background worker has not completed an evaluation. + + + Current access could not be verified + + + A prerequisite prevents the next setup step + + + Interest and learning are personal choices. Availability, setup and verified checks are separate; purchasing an add-on never completes setup automatically. + + + Verification updates findings. Assignment and accepted exceptions do not resolve a failed check. A fresh passing check resolves it. + + + Explore Resgrid and add-ons + + + Health checks + + + Change history + + + Overview + + + Settings reference + + + Setup Report + + + Setup Wizard + + + Admin worklist + + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.es.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.es.resx new file mode 100644 index 000000000..aa185da7a --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.es.resx @@ -0,0 +1,348 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms + + + System.Resources.ResXResourceWriter, System.Windows.Forms + + + Agregar persona + + + Protección de datos avanzada + + + Llamadas archivadas + + + Ofertas + + + tablero grande + + + Calendario + + + Llamadas + + + Listas de verificación + + + Pruebas de comunicación + + + Contactos + + + Contratos + + + Panel + + + Protección de datos + + + Configuración del departamento + + + Configuración de envío + + + Documentos + + + Formularios + + + Bandeja de entrada + + + Inventario + + + Administrar invitaciones + + + Facturas + + + Cartografía + + + Configuración de módulos + + + Nueva llamada + + + Nuevo contacto + + + Nuevo despliegue + + + Nuevo Grupo + + + Nuevo registro + + + Nueva nota + + + Nuevo Protocolo + + + Nueva capacitación + + + Nueva unidad + + + Nuevo flujo de trabajo + + + Mensajes enviados + + + Personal + + + Mantenimiento preventivo + + + Tarifas + + + Retenciones de conservación de registros + + + Inspecciones + + + Investigaciones + + + Permisos + + + Registros + + + Configuración de registros + + + Rutas + + + Turnos + + + Autenticación de Dos Factores + + + Personal de la unidad + + + Unidades + + + Voz + + + Ejecuciones del flujo de trabajo + + + Flujos de trabajo + + + Plantilla + + + Todos + + + Permiso + + + Búsqueda y rescate + + + Ajustar inventario + + + Aprobar registros + + + cerrar llamada + + + Crear flujo de trabajo + + + Eliminar llamada + + + Finalizar registros + + + Administrar definiciones de registro + + + Administrar divulgaciones de registros + + + Administrar informes de registros + + + Publicar definiciones de registro + + + Revisar registros + + + Referencia + + + Sí + + + Contrato + + + Voluntario + + + Después + + + Área + + + Antes + + + Versión del catálogo + + + Abrir la página de configuración correspondiente + + + Crítico + + + Requiere atención + + + Planificado + + + Evidencias a fecha de + + + Cuándo + + + Información + + + Me interesa esta función + + + Disponible + + + Conocer más adelante + + + Entiendo esta función + + + Cargando la configuración del departamento… + + + No corresponde + + + Verificado + + + Imprimir + + + Leer el texto de origen + + + Las evidencias protegidas no están disponibles + + + La comprobación de la configuración ofrece orientación administrativa. Comprender una función o guardar un ajuste no demuestra preparación operativa ni entrega de mensajes. + + + Reintentar + + + Revisión pendiente + + + Guardando… + + + Buscar áreas y funciones + + + Buscar en la documentación de referencia + + + Sin asignar + + + No disponible + + + Desconocido + + + Usar ahora + + + Volver a verificar + + + Advertencia + + + Explorar Resgrid y sus complementos + + + Comprobaciones de configuración + + + Historial de cambios + + + Resumen + + + Referencia de configuración + + + Informe de configuración + + + Asistente de configuración + + + Lista de tareas del administrador + + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.fr.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.fr.resx new file mode 100644 index 000000000..1fcdc74d5 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.fr.resx @@ -0,0 +1,303 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms + + + System.Resources.ResXResourceWriter, System.Windows.Forms + + + Protection avancée des données + + + Appels archivés + + + Offres + + + Grand tableau + + + Calendrier + + + Appels + + + Discussion + + + Listes de contrôle + + + Tests de communication + + + Contrats + + + Tableau de bord + + + Protection des données + + + Paramètres du département + + + Vos départements + + + Formulaires + + + Rapports d'incident + + + Inventaire + + + Factures + + + Journaux + + + Cartographie + + + Paramètres des modules + + + Nouveau déploiement + + + Nouveau Groupe + + + Nouveau Protocole + + + Nouvelle formation + + + Personnels + + + Maintenance préventive + + + Protocoles + + + Grilles tarifaires + + + Conservations légales des dossiers + + + Enquêtes + + + Permis + + + Paramètres des dossiers + + + Itinéraires + + + Quarts + + + Formations + + + Unités + + + Appel vocal + + + Effectifs + + + Incendie + + + Recherche et sauvetage + + + Ajuster l'inventaire + + + Approuver des rapports + + + Finaliser des rapports + + + Gérer les définitions de rapport + + + Gérer les divulgations de rapports + + + Gérer les états de rapports + + + Publier les définitions de rapport + + + Réviser des rapports + + + Non + + + Référence + + + Oui + + + Contrat + + + Bénévole + + + Après + + + Zone + + + Avant + + + Version du catalogue + + + Ouvrir la page de configuration correspondante + + + Critique + + + Nécessite une intervention + + + Planifié + + + Aperçu + + + Éléments de preuve au + + + Date + + + Informations + + + Cette fonctionnalité m’intéresse + + + Disponible + + + Découvrir plus tard + + + Je comprends cette fonctionnalité + + + Chargement de la configuration du service… + + + Sans objet + + + Vérifié + + + Imprimer + + + Lire le texte source + + + Éléments de preuve protégés indisponibles + + + La vérification de la configuration fournit des indications administratives. Comprendre une fonctionnalité ou enregistrer un paramètre ne prouve ni la disponibilité opérationnelle ni la remise des messages. + + + Réessayer + + + Relecture due + + + Enregistrement… + + + Rechercher des domaines et des fonctionnalités + + + Rechercher dans la documentation de référence + + + Non attribué + + + Indisponible + + + Inconnu + + + Utiliser maintenant + + + Vérifier à nouveau + + + Avertissement + + + Découvrir Resgrid et ses modules complémentaires + + + Vérifications de configuration + + + Historique des modifications + + + Vue d’ensemble + + + Référence des paramètres + + + Rapport de configuration + + + Assistant de configuration + + + Liste des tâches de l’administrateur + + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.it.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.it.resx new file mode 100644 index 000000000..078b23927 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.it.resx @@ -0,0 +1,312 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms + + + System.Resources.ResXResourceWriter, System.Windows.Forms + + + Protezione avanzata dei dati + + + Chiamate archiviate + + + Offerte + + + Tabellone + + + Calendario + + + Chiamate + + + Liste di controllo + + + Test di comunicazione + + + Contatti + + + Contratti + + + Protezione dei dati + + + Impostazioni dipartimento + + + I tuoi dipartimenti + + + Documenti + + + Moduli + + + Rapporti di incidente + + + Inventario + + + Fatture + + + Registri + + + Mappatura + + + Impostazioni moduli + + + Nuovo impiego + + + Nuovo Gruppo + + + Nuovo Protocollo + + + Nuova formazione + + + Note + + + Personale + + + Manutenzione preventiva + + + Protocolli + + + Listini + + + Blocchi di conservazione dei registri + + + Ispezioni + + + Indagini + + + Permessi + + + Impostazioni registri + + + Percorsi + + + Turni + + + Formazioni + + + Unità + + + Voce + + + Flussi di lavoro + + + Personale + + + Tutti + + + Ricerca e soccorso + + + Rettifica inventario + + + Approvare rapporti + + + Finalizzare rapporti + + + Gestire definizioni di rapporto + + + Gestire divulgazioni di rapporti + + + Gestire report sui rapporti + + + Pubblicare definizioni di rapporto + + + Revisionare rapporti + + + Riferimento + + + Sì + + + Contratto + + + Volontario + + + Dopo + + + Assegnato + + + Prima + + + Versione catalogo + + + Apri la pagina delle impostazioni pertinente + + + Critico + + + Richiede attenzione + + + Pianificato + + + Anteprima + + + Evidenze aggiornate al + + + Data + + + Informazioni + + + Mi interessa questa funzionalità + + + Disponibile + + + Scopri più avanti + + + Comprendo questa funzionalità + + + Caricamento della configurazione del reparto… + + + Non applicabile + + + Verificato + + + Stampa + + + Leggi il testo originale + + + Motivo + + + Evidenze protette non disponibili + + + La verifica della configurazione fornisce indicazioni amministrative. Comprendere una funzionalità o salvare un’impostazione non dimostra la prontezza operativa né la consegna dei messaggi. + + + Riprova + + + Revisione entro + + + Salvataggio… + + + Cerca aree e funzionalità + + + Cerca nella documentazione di riferimento + + + Non assegnato + + + Non disponibile + + + Sconosciuto + + + Usa ora + + + Verifica di nuovo + + + Avviso + + + Esplora Resgrid e i componenti aggiuntivi + + + Verifiche della configurazione + + + Cronologia delle modifiche + + + Panoramica + + + Riferimento delle impostazioni + + + Rapporto di configurazione + + + Configurazione guidata + + + Elenco attività dell’amministratore + + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.pl.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.pl.resx new file mode 100644 index 000000000..53e904219 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.pl.resx @@ -0,0 +1,318 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms + + + System.Resources.ResXResourceWriter, System.Windows.Forms + + + Zaawansowana ochrona danych + + + Zarchiwizowane zgłoszenia + + + Oferty + + + Wielka tablica + + + Kalendarz + + + Zgłoszenia + + + Czat + + + Listy kontrolne + + + Testy łączności + + + Kontakty + + + Umowy + + + Pulpit + + + Ochrona danych + + + Ustawienia oddziału + + + Twoje oddziały + + + Dokumenty + + + Formularze + + + Raporty ze zdarzeń + + + Inwentarz + + + Faktury + + + Dzienniki + + + Mapowanie + + + Ustawienia modułów + + + Nowa dyslokacja + + + Nowa Grupa + + + Nowy Protokół + + + Nowe szkolenie + + + Personel + + + Konserwacja zapobiegawcza + + + Protokoły + + + Cenniki + + + Blokady zachowania rejestrów + + + Kontrole + + + Dochodzenia + + + Zezwolenia + + + Ustawienia rejestrów + + + Trasy + + + Zmiany + + + Szkolenia + + + Jednostki + + + Połączenie głosowe + + + Przepływy pracy + + + Kadry + + + Wszyscy + + + Poszukiwania i ratownictwo + + + Dostosuj inwentarz + + + Zatwierdzanie raportów + + + Finalizowanie raportów + + + Zarządzanie definicjami raportów + + + Zarządzanie ujawnieniami raportów + + + Zarządzanie zestawieniami raportów + + + Publikowanie definicji raportów + + + Przeglądanie raportów + + + Nie + + + Godziny pracy + + + Tak + + + Umowa + + + Wolontariusz + + + Godziny pracy + + + Po + + + Obszar + + + Przed + + + Wersja katalogu + + + Otwórz odpowiednią stronę ustawień + + + Krytyczne + + + Wymaga uwagi + + + Zaplanowana + + + Podgląd + + + Dane aktualne na + + + Informacje + + + Interesuje mnie ta funkcja + + + Dostępne + + + Poznaj później + + + Rozumiem tę funkcję + + + Ładowanie konfiguracji jednostki… + + + Nie dotyczy + + + Zweryfikowano + + + Drukuj + + + Czytaj tekst źródłowy + + + Powód + + + Chronione dane potwierdzające są niedostępne + + + Kontrola konfiguracji służy jako wskazówka administracyjna. Poznanie funkcji lub zapisanie ustawienia nie potwierdza gotowości operacyjnej ani doręczenia wiadomości. + + + Ponów próbę + + + Termin przeglądu + + + Zapisywanie… + + + Szukaj obszarów i funkcji + + + Szukaj w dokumentacji referencyjnej + + + Nieprzypisane + + + Niedostępne + + + Nieznane + + + Używaj teraz + + + Sprawdź ponownie + + + Ostrzeżenie + + + Poznaj Resgrid i dodatki + + + Kontrole konfiguracji + + + Historia zmian + + + Przegląd + + + Informacje o ustawieniach + + + Raport konfiguracji + + + Kreator konfiguracji + + + Lista zadań administratora + + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.resx new file mode 100644 index 000000000..3d852e3d8 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.resx @@ -0,0 +1,5409 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms + + + System.Resources.ResXResourceWriter, System.Windows.Forms + + + Automation and connectivity + + + Deployments and business + + + Calls and response + + + Communication + + + Home and applications + + + Inventory and readiness + + + Contacts and site knowledge + + + Units and location + + + Checklists and maintenance + + + People and organization + + + Plans, add-ons and AI + + + Records and reporting + + + Administration and security + + + Review a Workflow and its permissions before explicitly enabling it. + + + Compare an event standby invoice with its approved deployment documentation. + + + Review a test scenario before enabling CAD intake or changing dispatch routing. + + + Preview recipients and explicitly run a communication test through its own screen. + + + A dispatcher creates a call while responders and unit crews receive their assigned work. + + + Review an expiring supply lot and arrange restocking through the inventory screen. + + + Review a receiving-agency contact and an approved site plan before a planned event. + + + Review an apparatus location and its last update before relying on the map. + + + Record an equipment defect, assign repair and review return to service through the owning module. + + + Check that the next shift has the locally required qualified crew. + + + Review feature requirements with the managing member before using the normal billing screen. + + + Review an incident report and its outstanding corrections using the Records workflow. + + + Verify an additional administrator and review MFA and recovery procedures. + + + Configure Workflows, user-defined fields, APIs, imports and integrations. + + + Understand free Deployment Finance and optional Business Operations workflows. + + + Create and receive calls, choose recipients and follow response status. + + + Use messages, notifications, communication tests, chat and Push-to-Talk. + + + Learn how the web, Dispatch, Responder, Unit, Incident Command and Big Board fit your team. + + + Track stock, equipment, issue and return, purchasing and expiry. + + + Maintain contacts, preplans, forms, notes, documents, files and links. + + + Manage units, crews, maps, location tracking, routes and weather. + + + Use included checklists and optional Readiness Pro repair and maintenance workflows. + + + Organize members, stations, groups, roles, qualifications, shifts and training. + + + Compare included features, base-plan limits and paid add-ons. + + + Understand Records, legacy Logs, reviews, prevention, investigations and reporting. + + + Review department settings, permissions, MFA, SSO, audit and data protection. + + + Reduce repeated administrative work while retaining ownership and review of external effects. + + + Connect deployment records to invoicing, reimbursement or internal cost review when needed. + + + Make call intake and recipient selection consistent across shifts. + + + Choose a communication channel appropriate to the audience and task. + + + Give each role the right app and a clear starting point. + + + See recorded shortages and due dates before they become an administrative surprise. + + + Help authorized members find the current reference and responsible contact. + + + Make resource information easier to find and show when positions are stale. + + + Connect recorded equipment checks with owned corrective work when maintenance is enabled. + + + Keep access and responsibilities aligned with the people doing the work. + + + Choose optional capabilities by the work they support and their adoption requirements. + + + Keep administrative follow-up and reporting evidence connected to the source record. + + + Keep administrative access recoverable and limited to authorized people. + + + Account sessions and recovery + + + Add Person + + + Advanced Data Protection + + + Enhanced AI + + + Business Operations + + + Push-to-Talk + + + Readiness Pro + + + API and MCP integrations + + + Archived Calls + + + Audit history + + + Bids + + + Big Board + + + Billing Settings + + + Cal OES MARS + + + MARS Action Queue + + + MARS Annual Rates + + + MARS Reconciliation + + + Calendar + + + Call Settings + + + Call templates + + + Call types and priorities + + + Calls + + + Certification Dashboard + + + Certification Settings + + + Certification Types + + + Chat + + + Chatbot and Assistant integration settings + + + Check-in timers + + + Checklist compliance reports + + + Checklist schedules and due work + + + Checklist Templates + + + Checklists + + + Communication Tests + + + Compliance Documents + + + New Message + + + Contact Categories + + + Contacts + + + Contracts + + + Field Cost Runs + + + Custom map layers + + + Personnel and unit statuses + + + Dashboard + + + Data Protection + + + Department Settings + + + Your Departments + + + Deployment From External Order + + + Deployment Finance + + + Dispatch application + + + Dispatch Settings + + + Distribution lists + + + Documents + + + Email and CAD call intake + + + Files and attachments + + + Forms + + + Groups & Stations + + + Hardware location tracking + + + Import and migration planning + + + Inbox + + + Incident command and accountability + + + Incident reports + + + Indoor maps + + + Inventory + + + Issue Equipment + + + Inventory counts, expiry and alerts + + + Inventory purchasing + + + Inventory Status + + + Transfer Inventory + + + Manage Invites + + + Accounts Receivable Aging + + + Invoices + + + Live Routing + + + Logs + + + Maintenance policies and approvals + + + Maintenance history and reports + + + Map Layers + + + Mapping + + + Module settings + + + My Certifications + + + My Demographic Response + + + New Bid + + + New Calendar Event + + + New Call + + + New Checklist + + + New Contact + + + New Contract + + + New Deployment + + + Upload Document + + + New Group + + + New Invoice + + + New Log + + + New Note + + + New Protocol + + + New Training + + + New Unit + + + New Work Order + + + New Workflow + + + Notes + + + Notification rules + + + Online Payment Settings + + + Sent Messages + + + California Pay Data Reporting + + + Permissions + + + Personnel + + + Personnel roles + + + Points of Interest + + + Preventive maintenance + + + My Profile + + + Protected workflows + + + Protocols + + + Rate Cards + + + Rate Schedules + + + Records analytics + + + Record definitions and templates + + + Disclosure requests + + + Evidence capture and provenance + + + Record exports and templates + + + Legal holds + + + Hydrants and water sources + + + Inspections + + + Investigations + + + Occupancies and preplans + + + Permits + + + Saved record reports + + + Reporting submissions + + + Records + + + Records Dashboard + + + Records Settings + + + Reports + + + Resource Cost Profiles + + + Responder application + + + Routes + + + Run cards and recommendations + + + Security and session policy + + + Setup help and support + + + Shared links + + + Shifts + + + Single sign-on and provisioning + + + Base plans and capacity + + + Text call intake and commands + + + Trainings + + + Two-Factor Authentication + + + Unit application + + + Unit Staffing + + + Unit types + + + Units + + + User-defined fields + + + Voice + + + Weather zones and alerts + + + Work Orders + + + Workflow Runs + + + Workflows + + + Workforce + + + Annual Pay Facts + + + Compensation Profiles + + + Workshifts + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Buying does not enroll the department. Review MFA, recovery, migration and integration behavior; this is not a compliance certification. + + + Planned until product and runtime availability are verified. Deterministic Setup Wizard, Setup Report and Admin Assist do not require an AI purchase. + + + Certifications and Deployment Finance do not require this add-on. Pay-data reporting additionally needs ADP Enabled; online payments require provider setup. No reimbursement or savings are guaranteed. + + + Review seats, supported clients and channel setup. PTT does not provide phone dispatch alerts or certify radio replacement. + + + Checklists do not require this add-on. Maintenance needs its module, permissions and active entitlement. Historical evidence and hold release follow owning-module rules. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup. + + + Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup. + + + Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup. + + + Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup. + + + Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup. + + + Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Purchase and enrollment are separate. Review MFA, key recovery and effects on search, exports, integrations and notifications before enrollment. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup. + + + Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup. + + + Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Checklists do not require Readiness Pro. Maintenance requires the add-on, module access and reviewed repair and approval procedures. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Requires Business Operations and Advanced Data Protection in the Enabled state. Review regional applicability and protected workforce access. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup. + + + Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Review supported clients, channel membership and current subscription quantities. Push-to-Talk is distinct from phone voice alerts and department radio requirements. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + Checklists do not require Readiness Pro. Maintenance requires the add-on, module access and reviewed repair and approval procedures. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Review permissions and setup requirements on the owning screen; saving or sending remains an explicit action. + + + Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup. + + + Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup. + + + Requires Business Operations and the capability rollout. Certifications and Deployment Finance remain available without this add-on; payment processing needs its own provider setup. + + + Review the owning screen, current access and source-specific requirements. Configuration, provisioning, exports and messages require explicit actions; learning about a capability does not activate it. + + + An administrator enrolls the department and verifies its recovery arrangements. + + + Ask for an explanation of a configuration finding when the conversational feature is available. + + + Prepare an event standby invoice or review documented mutual-aid costs. + + + A SAR coordinator uses a team channel during a training exercise. + + + A failed apparatus check leads to an explicitly created maintenance work order. + + + Review the current member's sessions and account security controls. + + + Manually create a user account in the department + + + Additional protection and scoped access for cataloged sensitive department content. + + + Planned summaries, drafts, knowledge assistance and optional Admin Assist conversation. + + + Invoicing, payments, rates, contracts, bids, MARS reimbursement, workforce and field costing. + + + Voice channels for crew communication in supported Resgrid clients. + + + Maintenance, work orders, preventive tasks, corrective work, approvals and safety holds. + + + Use supported APIs and MCP with authorized credentials and permissions. + + + Closed and historical calls + + + Review recorded administrative changes and available source details. + + + Priced estimates for customer contacts and contracts + + + Shared display clients show the configured resource and call picture. + + + Legal name, remit-to address and tax registrations printed on invoices + + + CFAA cost recovery readiness: agency, F-5 resources, annual rates and agreements + + + F-42 and expense claims to prepare, validate and hand off to the MARS portal + + + Salary Survey, Attachment A, Administrative Rate, Rate Letter and Special Equipment snapshots + + + Observed MARS invoices, local approval and payment reconciliation + + + Events, meetings and trainings you can sign up for + + + Call types, priorities, email import and dispatch settings + + + Maintain reusable call information and note templates. + + + Maintain the department's call classifications and priorities. + + + View calls and dispatches + + + Expiring and expired certifications for people and units + + + Enforcement mode, grace period and expiry notifications + + + The department's certification catalog and template gallery + + + Real-time department chat + + + Review supported chatbot platforms, account linking, limits and operational actions. + + + Configure supported check-in targets and escalation behavior. + + + Review expected, completed, skipped and overdue checks. + + + Assign recurring checks and review due occurrences. + + + Start from a checklist template + + + Apparatus, station and readiness checklists + + + Prepare and explicitly run tests of configured channels. + + + Insurance, workers' comp, SAM, licences and bonds with expiry alerts + + + Send a message, poll or callback request + + + Manage contact categories + + + People and organizations outside the department + + + Service contracts, document requirements and compliance + + + Internal loaded cost and margin for bids, calls and deployments + + + Manage supported custom map content for the department. + + + Configure status names, base meanings and supported behavior. + + + Department home: status, staffing and activity + + + Advanced Data Protection enrollment and policies + + + Department profile, address, API keys and module settings + + + Switch between the departments you belong to + + + Create a deployment from an open RMS mutual-aid order + + + Deployments, rosters, daily time reports and expenses + + + Dispatchers use the dispatch interface to review calls and resources and explicitly select recipients. + + + Dispatch behaviour and notification settings + + + Maintain reusable recipient lists for supported communication flows. + + + Upload and share documents + + + Configure supported email and CAD import formats and source credentials. + + + Attach and maintain files through their owning documents and records. + + + Custom call and dispatch forms + + + Department groups and stations + + + Associate supported tracking devices with department resources. + + + Plan source ownership and review existing import tools before copying data. + + + Your messages inbox + + + Command tools organize incident roles and accountability for authorized commanders. + + + Prepare incident reports linked to authorized call evidence. + + + Organize indoor map references and zones where supported. + + + Inventory for stations and units + + + Issue or return equipment to personnel + + + Review stock counts, expiry and configured inventory alerts. + + + Manage suppliers, purchase orders and receipts. + + + On-hand, low-stock and expiring inventory + + + Move inventory between locations + + + Send email invites so people create their own accounts + + + Outstanding invoice balances by age + + + Customer invoices and payments + + + Routing and directions for active resources + + + Run, training, work and meeting logs + + + Configure supported work-order policies and approval requirements. + + + Review maintenance work, recorded holds and repair history. + + + Manage map layers + + + Large map with layers, personnel and units + + + Choose which optional modules appear and operate for the department. + + + Your own certification records + + + Your voluntary self-identification for California pay data reporting + + + Draft a bid for a customer + + + Create a calendar event + + + Create and dispatch a new call + + + Author a checklist definition + + + Add a person or organization contact + + + Create a service contract for a customer + + + Create a deployment finance wrapper + + + Upload a new document + + + Create a group or station + + + Draft an invoice for a customer + + + Create a run report, training log or work log + + + Post a department note + + + Create a dispatch protocol + + + Create a training with optional quiz + + + Add an apparatus, vehicle or team + + + Open a maintenance work order + + + Create an automation workflow + + + Department notes: small bits of shared information + + + Select events, recipients and conditions for administrative notifications. + + + Connect your Stripe account to collect invoice payments online + + + Messages you sent + + + CRD pay data report runs: snapshots, aggregation, validation, export and the portal worksheet + + + Configure action permissions, roles and supported group restrictions. + + + People in the department, status and staffing + + + Organize department-defined role assignments. + + + Manage map points of interest + + + Configure recurring maintenance work and due tasks. + + + View and edit your own profile, contact methods and notifications + + + Configure approved releases to supported external workflow destinations. + + + Dispatch protocols and procedures + + + Billing rates for units, personnel and fees + + + Contractor rate tables: certifications, crews, vehicles, equipment, premiums and policies + + + Review supported aggregate response, workload and readiness reports. + + + Configure record forms, lifecycle, numbering and review requirements. + + + Manage disclosure scope, redaction and release approval. + + + Capture supported source evidence from the owning record workflow. + + + Configure supported export templates and review their runs. + + + Place and release holds through the authorized Records lifecycle. + + + Maintain hydrant and water-source records and their review history. + + + Schedule and record authorized inspection work and corrections. + + + Manage authorized investigation records and their controlled workflow. + + + Maintain premises, contacts, hazards and review metadata. + + + Manage supported permit records and lifecycle. + + + Create reusable report selections over authorized record data. + + + Prepare and reconcile supported reporting submissions from an authorized incident report. + + + Records queue: run reports, training and operational records + + + Records due, submissions and quality at a glance + + + Lifecycle, numbering, search, retention and visibility settings for Records + + + Generate reports from department data + + + Depreciation, fuel, maintenance and fixed costs per unit or asset + + + Individual members use the supported Responder client for their account, availability, calls and notifications. + + + Manage configured routes and supported route assignments. + + + Configure matching rules, resource requirements and supported dispatch recommendations. + + + Configure supported MFA, password, session and network restrictions. + + + Resume orientation and review current configuration evidence. + + + Configure supported shared views and links. + + + Shift signups, recurring shifts and trades + + + Configure supported identity providers and provisioning. + + + Review current plan allowances and authorized subscription options. + + + Configure source numbers, parsing and supported inbound text actions. + + + Trainings, study guides and procedures + + + Set up or manage two-factor sign-in + + + A shared unit client represents an apparatus or team rather than an individual member. + + + Assign personnel to units + + + Configure apparatus and resource types and their supported status behavior. + + + Apparatus, vehicles and teams + + + Define additional fields for supported forms and records. + + + Voice channels and push-to-talk + + + Configure monitored zones and weather alert processing. + + + Maintenance and repair work orders + + + Workflow execution history + + + Automations triggered by department events + + + Employer identity, establishments, workers, employments and job assignments + + + W-2 earnings and hours per employment for pay data reporting, with CSV import + + + Employee, role-default and department-default compensation with pay and employer-cost components + + + Configure repeating workforce schedules supported by Workshifts. + + + Keep personal account recovery distinct from department-wide settings. + + + Control protected content disclosure and recovery through the department protection lifecycle. + + + Help explain approved evidence and prepare drafts for human review when released. + + + Connect administrative commercial work to the department activities it supports. + + + Coordinate field teams through configured voice channels. + + + Give equipment defects an owner and a recorded repair and return-to-service review. + + + Connect approved systems without placing credentials in setup examples. + + + Identify what changed and who should verify the effect. + + + Choose display refresh, location age and visibility appropriate to a station screen. + + + Reduce repeated entry while keeping dispatch decisions explicit. + + + Use consistent reporting and run-card matching terms. + + + Keep automated access and confirmations under administrator control. + + + Review lone-worker and response follow-up with approved local procedures. + + + Find missing recorded checks without treating a report as safety certification. + + + Make recorded equipment and procedure checks visible to their owners. + + + Collect observed test evidence; registration alone does not prove delivery. + + + Give responders useful reference layers with an identified owner and update process. + + + Use terminology familiar to the department while preserving automation meaning. + + + Give dispatch personnel a focused operating surface alongside web administration. + + + Keep audience ownership explicit and review recipients before sending. + + + Review parsing, routing and observed integration failures before activation. + + + Keep reference material with its owner, permissions and retention policy. + + + Review device ownership, stale-position behavior and fallback sources. + + + Avoid duplicate resources and verify resulting configuration in Setup Report. + + + Prepare command structures and access before an exercise or response. + + + Review source completeness before finalization or external submission. + + + Keep building references available to authorized users without inferring safe routes. + + + Identify inventory data and supplies that need owner follow-up. + + + Connect replenishment to recorded stock needs and authorized receipt. + + + Make authority and repair completion requirements explicit. + + + Support accountable return-to-service review through the owning workflow. + + + Reduce navigation clutter while understanding retained data and add-on prerequisites. + + + Reduce missed follow-up and review unnecessary message volume. + + + Review who can perform sensitive work and which resources they may see. + + + Describe responsibility consistently without assuming that a role proves qualification. + + + Plan equipment upkeep and track corrective responsibility. + + + Keep protected-data egress scoped, reviewed and auditable. + + + Use measured source data with its scope and completeness limits. + + + Fit reporting to approved local procedures while preserving built-in definitions. + + + Assign review ownership and verify the material approved for release. + + + Preserve evidence context and access restrictions without copying it into setup guidance. + + + Keep release destinations, protected access and output handling explicit. + + + Retain affected evidence while a hold remains active. + + + Identify records needing verification without claiming current flow or availability. + + + Track administrative follow-up against configured inspection procedures. + + + Keep case responsibility and access separate from general department visibility. + + + Keep site knowledge owned and current; recorded hazards do not certify safety. + + + Assign review responsibility; a software state is not a hazardous-work safety clearance. + + + Make repeatable administrative reviews easier. + + + Review destination requirements and actual submission outcomes. + + + Separate member device setup from department configuration and verify each member's channels. + + + Prepare recurring route information with current source ownership. + + + Review candidate coverage before enabling operational use. + + + Review lockout and recovery risks with an enrolled administrator. + + + Keep learning, configuration and verification as separate steps. + + + Review exposed data and link access before distribution. + + + Review identity mapping, local fallback and recovery before enforcing SSO. + + + Choose capacity for actual department needs without inferring prices or terms. + + + Make approved inbound sources and their operational effects explicit. + + + Review device identity, assigned crew and unit communications together. + + + Group comparable resources without assuming staffing or qualification. + + + Collect the information the department actually needs with appropriate classification. + + + Review alert coverage and automatic-message settings before use. + + + Review schedule ownership and local time separately from dispatch eligibility. + + + Department Suppress Staffing Info / Enable Supress Staffing + + + Department Suppress Staffing Info / Staffing Levels To Supress + + + Dispatch Recommendation Config / Eta Shortlist Size + + + Dispatch Recommendation Config / Include Stale Locations + + + Dispatch Recommendation Config / Max Location Age Seconds + + + Dispatch Recommendation Config / Max Radius Meters + + + Dispatch Recommendation Config / Move Up Recommendations Enabled + + + Dispatch Recommendation Config / Personnel Max Location Age Seconds + + + Dispatch Recommendation Config / Rest Period Minutes + + + Dispatch Recommendation Config / Unit Minimum Staffing Level + + + Dispatch Recommendation Config / Use Routed Eta + + + Group Dispatch Scope Config / Department Wide Role Ids + + + Group Dispatch Scope Config / Enabled + + + New Call Field Policy / Rules + + + New Call Field Rule / Key + + + New Call Field Rule / Required + + + New Call Field Rule / Visible + + + Personnel List Status Order / Status Id + + + Personnel List Status Order / Weight + + + Personnel List Status Order Setting / Orders + + + Records Disclosure Config / Default Redaction Profile + + + Records Disclosure Config / Release Approver User Id + + + Records Disclosure Config / Statutory Clock Days + + + Records Numbering Config / Include Year + + + Records Numbering Config / Number Assignment + + + Records Numbering Config / Per Group Sequence + + + Records Numbering Config / Reset Yearly + + + Records Numbering Config / Sequence Width + + + Records Retention Override / Applies From + + + Records Retention Override / Definition Key + + + Records Retention Override / Retention Years + + + Records Retention Policy / Department Default Years + + + Records Retention Policy / History + + + Records Retention Policy / Last Changed By User Id + + + Records Retention Policy / Last Changed On + + + Records Retention Policy / Overrides + + + Records Retention Policy Version / Effective On + + + Records Retention Policy Version / Policy + + + Records Search Config / Include Legacy History + + + Records Search Config / Index Narrative + + + Unit Status Threshold / Alert Seconds + + + Unit Status Threshold / Base Type + + + Unit Status Threshold / Warn Seconds + + + Unit Status Thresholds / Thresholds + + + Unit Type Call Status Override / Dispatch Status + + + Unit Type Call Status Override / Release Status + + + Unit Type Call Status Override / Unit Type Id + + + Unit Type Call Status Override Setting / Overrides + + + Enables the configured staffing-level suppression in supported notification consumers; review reachability before changing it. + + + Existing staffing levels to suppress. A suppressed level is not proof that another channel reaches the member. + + + Number of straight-line candidates per requirement sent for routed ETA when enabled. The owning validator caps provider work. + + + Permit positions beyond the configured age limit in closest-unit selection, marked stale. Review the risk of outdated positions. + + + Exclude older unit positions from closest-unit candidates; zero removes the age limit. IncludeStaleLocations changes this behavior. + + + Maximum candidate distance in meters; zero removes the radius cap. This is not a response-time or route-safety guarantee. + + + Run the station coverage move-up pass after selection. Recommendations remain subject to approved local dispatch procedures. + + + Maximum age of personnel positions for closest-unit candidate selection; zero removes the age limit. + + + Deprioritize recently dispatched resources for this duration; zero disables rotation. This does not infer fatigue or medical fitness. + + + Minimum configured unit staffing level for recommendation eligibility; zero disables the gate. Units without defined seats pass, and run cards may override it. + + + Re-rank shortlisted candidates using provider travel estimates. The operational path can make external provider calls; Admin Assist does not. + + + Existing roles allowed department-wide dispatch views while group scoping is enabled. This changes view scope, not licensing or operational qualification. + + + Limit supported dispatch views to the member's group subtree. Department administrators and configured department-wide roles keep department-wide access. + + + Per-field visibility and requiredness for optional call-creation fields. Unconfigured fields remain visible and optional. + + + One supported built-in call-field key. Name, nature, priority and type cannot be removed through this policy. + + + Whether a visible optional field must be supplied. Review imports and each supported client before making a field mandatory. + + + Whether the optional call field is shown on supported creation surfaces. Hidden fields cannot be required. + + + Existing personnel-status identifier whose list position is being configured. + + + Relative list order for this status. Confirm all configured statuses remain represented. + + + Ordered status weights for supported personnel lists. This does not set availability or dispatch priority. + + + Default redaction profile in the owning disclosure workflow. Review the actual proposed release before approval. + + + Authorized member responsible for disclosure release approval. Selecting an approver does not grant missing permissions or protected-data access. + + + Configured disclosure review clock. Verify local obligations with the responsible owner; the default does not establish a legal deadline. + + + Include the year between the record prefix and sequence number. + + + Choose when numbers are assigned for definitions using department defaults. Review existing references before changing numbering behavior. + + + Use separate station/group sequences. This changes numbering, not record visibility. + + + Restart record sequences each calendar year in the department time zone. + + + Zero-padded sequence width for definitions using department defaults. The Records editor validates the supported width. + + + Prospective boundary for revisions eligible for the override, evaluated by the Records lifecycle. + + + Stable record-definition key to which this override applies. Display names do not identify a retention policy. + + + Years retained under this definition override; zero means permanent. Holds and prospective policy resolution still apply. + + + Department retention for standard record classes. Zero means permanent; protected classes, prior policy and holds can retain records longer. + + + Prior policy versions retained by the owning lifecycle so current changes do not silently rewrite historical retention. + + + Recorded policy-change actor. It is not an editable retention rule and is not exposed as raw identity in this reference. + + + Policy effective timestamp used with prior policy versions. It is not the record's retention expiry date. + + + Definition-specific prospective retention overrides. Use the Records workflow and its confirmations; Admin Assist never purges records. + + + Effective timestamp of a stored historical retention policy version. + + + Historical policy snapshot used to resolve retention for older revisions. Never edit this as an ordinary preference. + + + Include supported legacy personnel and unit logs in the Records search scope. Source permissions still apply. + + + Include unprotected narrative in search. Advanced Data Protection enrollment withdraws narrative indexing; this preference cannot override protection. + + + Seconds in this status before a higher-priority highlight; zero disables it. Highlighting does not send a page or change status. + + + Base status meaning for a threshold, independent of custom status names and colors. + + + Seconds in this status before a warning highlight; zero disables it. An alert at or before this threshold makes the row alert-only. + + + Board highlighting thresholds grouped by base status meaning. An empty list disables highlighting. + + + Status applied to this unit type on dispatch; minus one leaves it unchanged. + + + Status applied to this unit type on release; minus one leaves it unchanged. + + + Existing unit type receiving this override. A unit type name is not a qualification or staffing clearance. + + + Per-unit-type dispatch and release status overrides. The owning consumer resolves these before department defaults. + + + Choose the areas your department uses + + + Configure and verify + + + Follow up on findings + + + Use Settings Reference and Change History + + + Start or resume setup + + + Understand optional add-ons + + + Administrators selected by this policy who lack MFA enrollment + + + Department members and authorized consumers of this setting. + + + Saved call ID + + + Capacity headroom + + + Enter proposed total personnel and unit counts, including existing resources. This does not add, remove or purchase anything. + + + Preview plan capacity + + + A preview needs no rollback. Any later resource or subscription change follows its owning workflow. + + + Positive headroom is remaining allowance; a negative value exceeds the observed base-plan limit. Entity plans share one allowance across personnel and units. Add-on seats, storage, provider quotas and prices are not included. + + + Billing observations can change. Existing plan enforcement remains authoritative when adding resources. No purchase, reservation, resource deletion or entitlement change is performed. + + + Personnel newly selected + + + Personnel route attempts, including direct duplicates + + + Route selection is not channel eligibility or delivery. Profiles, suppression, registrations, provider availability and other recipient gates can prevent a send. Review the authorized Calls and Communication Tests screens; this preview never dispatches or tests a channel. + + + Dispatched groups with empty-shift fallback + + + Use a saved call as a route scenario. Enter its numeric ID from the Calls screen and choose all three proposed routing options. Current settings and membership are read afresh; nothing is sent or saved. + + + Distinct personnel selected + + + Preview dispatch routing + + + Personnel no longer selected + + + Call scenarios checked + + + Compares direct, group, role and unit-crew/group routes through the broadcaster’s recipient resolver. Repeated direct dispatch rows remain separate attempts; expanded routes deduplicate. Unit device, printer and external routes are outside these personnel counts. This does not propose group-scope or permission changes. + + + The explicit UTC time selects resolved shifts, including approvals, trades and overnight windows. Call routes, group/role membership and unit crews are current observations, not reconstructed historical assignments. Inputs are reread to detect changes during the preview. + + + Effect + + + Disabling the map override on the owning screen removes its saved style and token. Re-enabling requires re-entering those values. + + + Complete authorized map evidence could not be read. Marker effects are unknown; an unavailable source is not a zero count. + + + TTL expires location pings; a saved status location can remain visible. A shorter TTL does not necessarily remove a marker. + + + Personnel or units checked + + + Locations and statuses are read during this request and evaluated at the report time. Counts can change with new pings; historical replay and other map consumers are not included. + + + Marker counts use the v4 map selection rules and your current location permissions. They do not predict another member’s access or change location permissions. + + + Markers newly visible + + + Markers no longer visible + + + Scope 1 includes department admins and the managing member; scope 2 also includes group admins without double-counting them. Hidden current members remain in scope. This is enrollment metadata; session enforcement, supported recovery factors, other MFA policies and SSO behavior require separate verification. The owning screen requires the managing member and the current admin to enroll before enforcement can be enabled. + + + Module + + + Stored rows in the module’s primary table + + + Hide this module + + + Preview the legacy module navigation switches. Enabling a switch does not grant permissions, enroll an add-on or complete feature setup. Calls, personnel and units remain core areas. + + + Memberships whose module navigation changes + + + Current memberships with the module navigation entry enabled + + + Preview a module switch + + + Stored rows behind a hidden module entry + + + Menu counts cover current department memberships, including hidden members. They describe the shared web navigation gate, not successful sign-in, each member’s visible records or mobile clients. Content counts cover only primary Messages, Shifts, Documents, CalendarItems, Notes and Trainings rows; they include retained or expired rows. Mapping and Reports aggregate other sources; Logs/Records and Inventory have multiple data models. Their content totals remain unknown until the corresponding adapters are available. The licensed maintenance, checklist and business switches need their own entitlement-aware preview. + + + A saved switch affects navigation after its owning settings cache and page refresh. Hiding a menu does not delete data, revoke an existing API permission or prove that a worker stops. Verify relevant consumers and scheduled work separately before relying on a switch as an operational shutdown. + + + This preview reads current evidence and changes values only in memory. Use the owning screen to make a change. + + + Host broadcast gate blocks this department (1 = yes) + + + Counts use the same ordinary notification preference/contact gate as the sender. A grandfathered verification value is allowed by that gate. Missing profiles and staffing outside this department widen the range. Email addresses, phone numbers, device tokens and staffing notes are not read. Address/device validity, provider availability, SMS segmentation/retries, chat/voice and IC-app delivery remain unverified. Zero to the displayed upper bound is an ordinary channel-handoff estimate, not a device-message count, delivery guarantee or SMS bill. + + + Future window (days) + + + Email preference/contact gate — maximum members + + + Email preference/contact gate — minimum members + + + Assumed events per member in the entire window + + + Compare staffing suppression for a department-wide scenario. Enter the same number of ordinary notification events per current active member over the next 1–30 days. This is a declared scenario, not an observed event forecast. + + + Historical events sampled (not used in this scenario) + + + Current active members evaluated, including hidden members + + + Members with unavailable channel preferences + + + Unblocked members with none of these three channel gates enabled + + + Preview notification volume + + + Push preference gate — maximum members + + + Push preference gate — minimum members + + + Members potentially passing at least one channel preference/contact gate — maximum + + + Members passing at least one channel preference/contact gate — minimum + + + Assumed ordinary events per member in the window + + + The sample contains current non-deleted, non-disabled members, including hidden members, and is bounded. Event counts are your assumption across the full future window; current membership, preferences and staffing are held constant. This does not resolve notification-rule audiences, plan-limited recipient expansion, event-specific conditions, Calendar/Training overrides or historical recipients. No event history was sampled. + + + SMS preference/contact gate — maximum members + + + SMS preference/contact gate — minimum members + + + Members whose suppression cannot be determined + + + Proposed: apply the currently configured staffing suppression list + + + Members blocked by a verified staffing or host gate + + + The hypothetical change only toggles the existing staffing suppression list in memory. Actual sends use their current membership and suppression caches plus fresh provider gates, so a later send can differ. Review the list on the owning screen and run an authorized Communication Test after a real change; this preview never sends, saves or changes staffing. + + + Possible ordinary channel handoffs in the window — upper bound + + + Possible ordinary channel handoffs in the window — lower bound + + + Declared future window in days from this evaluation + + + Proposed audience + + + Department administrators + + + Department and group administrators + + + Department administrators and selected roles + + + Everyone + + + Members allowed at least one evaluated action or target + + + Allowed member/action or member/target pairs + + + Newly allowed pairs + + + Choose a supported permission and its complete proposed policy. Current members, roles, groups and resource scopes are checked afresh. The preview does not save permissions or refresh access. + + + Restrict resource visibility to group scope + + + No longer allowed pairs + + + Preview a permission change + + + Restore the prior permission using Security. Information already viewed or actions already performed cannot be recalled. + + + Selected department roles + + + Current department members evaluated + + + Uses current memberships, including hidden members but excluding disabled and deleted memberships. Resource visibility uses the shared authorization decision, including target-group ancestors. Ungrouped people do not share a group. Counts measure the named permission gate, not complete end-to-end access: identity lockout, client claims, feature access, protected data, resource ownership and other gates still apply. More than 100,000 actor/target pairs, ambiguous memberships or unavailable source data produce unknown results. + + + Resource targets (or one department action) + + + Direct authorization reads, cached visibility matrices, claims and existing sessions can refresh at different times. Verify the affected web and mobile actions after saving; this preview neither refreshes sessions nor grants access. + + + Permission + + + Personnel headroom + + + Visible personnel markers for your current access + + + Total personnel + + + Preview a proposed value + + + Proposed: include assigned unit crews + + + Proposed: use on-duty shifts instead of group membership + + + Proposed: include unit station groups + + + Proposed value + + + Verified eligibility for deletion across all dependencies + + + Expired sample headers requiring further owning-lifecycle review + + + Default years for future standard-class revisions (0 = permanent) + + + Sample headers past their historical policy window + + + Sample covers both complete record populations (0 = no, 1 = yes) + + + Expired sample headers excluded by a known direct or preservation hold, casualty, rescue or exposure content + + + Compare a prospective Records retention default. Leave blank for the system class default; zero means permanent. Existing definition overrides and historical policies are preserved. This preview cannot delete data. + + + The owning Records policy preserves previous policy versions. A prospective default applies to future finalized revisions; older revisions keep the policy that applied when they became official. Definition overrides remain in force. Restricted classes remain permanent unless an explicit authorized override applies. This is policy behavior, not a legal retention recommendation. + + + Expired sample headers excluded because historical hold coverage is uncertain + + + The sample contains at most 250 unpurged headers of each parent record type in identifier order. Counts are not population estimates when the sample is incomplete. Open, amending, recently changed and unexpired headers are excluded. Known parent and preservation holds and permanent casualty/rescue/exposure content are excluded; possible historical period holds remain uncertain. Child analyses and their holds, revision integrity, disclosure productions, evidence retention, attachments and external storage, workflow/outbox activity, submissions and search erasure still require the owning retention checks. Remaining candidates are not authorization or proof of purge eligibility. + + + Preview retention policy + + + Retention and Advanced Data Protection have separate lifecycles. Changing a default or cancelling an add-on does not decrypt or erase protected content. The owning protection policy, recovery and offboarding process remain authoritative. This preview reads metadata only and neither changes protection nor calls a purge workflow. + + + Unpurged record headers in the sample + + + Proposed default retention years + + + Restore the previous value on its owning screen where supported. Delivered messages, deleted secrets and purged data cannot be recalled. + + + Stored active, unexpired department sessions sampled + + + Sessions that will actually require reauthentication + + + Members currently at or above the proposed next-session limit + + + Enabled SSO configuration rows (provider connection not tested) + + + Existing passwords satisfying the proposed length + + + Tracked member passwords crossing the owning expiry boundary now + + + Security-policy field + + + Compare one supported security-policy field using current member and session metadata. No credentials, recovery codes, device details or provider secrets are displayed. Current sign-in state can change after this evaluation. + + + Members without verified identity metadata + + + Current-generation managed sessions crossing the idle boundary now + + + Sampled sessions created on or after the host policy date gate + + + The owning atomic insert counts active, unexpired sessions in this department created on or after the host policy gate. Reaching the limit blocks a new managed session; it does not revoke existing sessions. Zero removes this department limit. The preview uses the same counted set, including rows that may later fail another validation gate, and cannot predict concurrent logins or future expiry. + + + Current active members in the bounded sample + + + Members subject to MFA completion at the supported sign-in gate + + + Members potentially requiring MFA enrollment — maximum + + + Members requiring MFA enrollment by this policy — minimum + + + The owning validator enforces a minimum of eight characters and its fixed digit, uppercase and lowercase requirements. This field changes supported future password changes; stored password hashes cannot reveal current length. The stored complexity preference does not disable that fixed validator, and this preview does not inspect passwords. + + + Effective minimum for supported password-change validators + + + The owning password-age check expires tracked dates only after the configured day boundary. Zero disables that check; missing dates are grandfathered rather than forced to expire. Counts do not imply that SSO users use a local password or that every client enforces expiry identically. Supported sign-in/password-change flows apply the actual enforcement. + + + Members subject to the RequireSso password-login gate + + + Stored / proposed policy value (boolean: 1 = enabled) + + + Preview sign-in and session policy + + + Proposed value + + + Working provider, account linkage and recovery access + + + Usable factors and recovery arrangements + + + TwoFactorEnabled is an enrollment indicator, not proof of a working factor or recovery path. The supported policy gate requires completed Resgrid MFA for both password and SSO sign-in. Administrator-only MFA requirements remain separate and may still apply when this field is off. Review authenticator/recovery setup with affected members before enforcement; the preview does not authenticate them. + + + The RequireSso gate blocks password sign-in only when an SSO configuration is enabled. With no enabled provider, the owning service keeps its safety valve. Enabled does not prove IdP connectivity, correct claims, member linkage or emergency recovery. Provider AllowLocalLogin and client-specific gates are separate and can further restrict access. Verify a working alternate administrator session and recovery procedure before changing policy. + + + This is a bounded metadata snapshot of current members, including hidden members. It compares one field in memory through decisions shared with the owning consumers. Other sign-in, identity, enrollment, credential and source-availability checks still apply. No password hash, factor secret, IP address or session ticket is read; no session is revoked, refreshed or created. + + + Host session-policy date gate is active now (1 = yes) + + + Idle timeout applies only to managed sessions created on or after the configured host policy date gate. A blank/invalid gate means this department timeout is not enabled by that consumer; older sessions are excluded. Counts use stored last activity, which can lag because activity writes are throttled. Credential cutoff, generation, expiry, current request claims and tracking deployment determine actual reauthentication. Zero disables this department-specific idle check; it does not disable normal session expiry. + + + RequireSso set without an enabled provider (1 = safety valve applies) + + + Members without a tracked password-change date + + + Shared entity headroom + + + Roster simulation time (UTC; within seven days of now) + + + Configuration changed. Use Verify again to refresh the report, then review your proposal before previewing it. + + + Observed personnel whose displayed status changes + + + Complete authorized status evidence is unavailable. No zero-change conclusion can be drawn. + + + Proposed markers using a status location + + + Distinct personnel with an observed status in either projection + + + Compares the owning one-hour automatic-availability filter at the report time, with its one-year history bound. Hidden, disabled and deleted members are outside this query. Members with no status in either projection are outside the sample. This does not quantify shift, unit-crew or staffing-level automation. + + + Standing-by statuses in the observed sample + + + Automatic availability affects the next supported status read after the one-hour cutoff; the preview does not write a new status or prove physical availability. New status updates can change these counts. + + + Setting value (Boolean: 0 = no, 1 = yes) + + + Actual successful inbound acceptance + + + Enters the command branch before identity checks (0 = no, 1 = yes) + + + Enters the call-import branch (0 = no, 1 = yes) + + + Choose the provider path actually used by your department and a test sender number. Compare the complete proposed text-call and command switches against current source patterns. No text is received or sent. + + + Provider path + + + Matches a configured dispatch source pattern (0 = no, 1 = yes) + + + Preview a text sender scenario + + + Proposed: enable text calls + + + Proposed: enable text commands + + + Text settings are not uniform provider enforcement. The current SignalWire path reads both switches and treats unmatched senders as dispatch sources when commands are disabled. The Twilio legacy path classifies dispatch senders by patterns without consulting these two switches; its chatbot path has separate controls. This preview does not establish accepted or rejected senders, verify numbers, validate a webhook, run a parser or send a page. Review the active provider before relying on a switch to stop intake. + + + Sender scenarios evaluated + + + This is one routing scenario after department resolution and the plan gate. It does not establish number ownership, the active SMS department, supported plan, authenticated webhook, verified sender identity, chatbot permissions, opt-out exceptions, parser success or call delivery. Twilio chatbot and master-number paths, email, CAD and API imports are outside this evaluator. A zero call-branch value may still permit a command or opt-out response. The source number is used only for this request; the returned reference is masked. No historical acceptance rate is inferred. + + + Test sender number + + + Twilio legacy text path + + + Verify the owning consumer after saving; session, cache and client refresh behavior can differ. + + + Unit headroom + + + Visible unit markers for your current access + + + Total units + + + These figures cover the listed checks only. Effects without a metric, including provider delivery and session enforcement, have not been quantified. + + + Return to Setup Report and verify current evidence. Communication tests must be reviewed and started explicitly. + + + Choose a change window and responsible verifier using approved local procedures. Verify again after saving; new source data can invalidate this preview. + + + BusinessOperations availability + + + Calendar availability + + + Calendar menu name + + + Checklists availability + + + Documents availability + + + Documents menu name + + + Inventory availability + + + Inventory menu name + + + Logs availability + + + Logs menu name + + + Maintenance availability + + + Maintenance menu name + + + Mapping availability + + + Mapping menu name + + + Messaging availability + + + Messaging menu name + + + Notes availability + + + Notes menu name + + + Reports availability + + + Reports menu name + + + Shifts availability + + + Shifts menu name + + + Training availability + + + Training menu name + + + Controls availability of business operations. Disabling this module can remove navigation and block module operations; it does not delete its records. Turning it on still requires the applicable subscription, rollout and permissions. + + + Controls availability of calendar events. Disabling this module can remove navigation and block module operations; it does not delete its records. Turning it on still requires the applicable subscription, rollout and permissions. + + + Optional display name for calendar events in consumers that support the override. It changes the label, not permissions or functionality. This release has no separate name-override control in Module Settings. + + + Controls availability of checklists. Disabling this module can remove navigation and block module operations; it does not delete its records. Turning it on still requires the applicable subscription, rollout and permissions. + + + Controls availability of shared documents. Disabling this module can remove navigation and block module operations; it does not delete its records. Turning it on still requires the applicable subscription, rollout and permissions. + + + Optional display name for shared documents in consumers that support the override. It changes the label, not permissions or functionality. This release has no separate name-override control in Module Settings. + + + Controls availability of inventory. Disabling this module can remove navigation and block module operations; it does not delete its records. Turning it on still requires the applicable subscription, rollout and permissions. + + + Optional display name for inventory in consumers that support the override. It changes the label, not permissions or functionality. This release has no separate name-override control in Module Settings. + + + Controls availability of legacy logs. Disabling this module can remove navigation and block module operations; it does not delete its records. Turning it on still requires the applicable subscription, rollout and permissions. + + + Optional display name for legacy logs in consumers that support the override. It changes the label, not permissions or functionality. This release has no separate name-override control in Module Settings. + + + Controls availability of maintenance and work orders. Disabling this module can remove navigation and block module operations; it does not delete its records. Turning it on still requires the applicable subscription, rollout and permissions. + + + Optional display name for maintenance and work orders in consumers that support the override. It changes the label, not permissions or functionality. This release has no separate name-override control in Module Settings. + + + Controls availability of maps and location views. Disabling this module can remove navigation and block module operations; it does not delete its records. Turning it on still requires the applicable subscription, rollout and permissions. + + + Optional display name for maps and location views in consumers that support the override. It changes the label, not permissions or functionality. This release has no separate name-override control in Module Settings. + + + Controls availability of messages and announcements. Disabling this module can remove navigation and block module operations; it does not delete its records. Turning it on still requires the applicable subscription, rollout and permissions. + + + Optional display name for messages and announcements in consumers that support the override. It changes the label, not permissions or functionality. This release has no separate name-override control in Module Settings. + + + Controls availability of notes. Disabling this module can remove navigation and block module operations; it does not delete its records. Turning it on still requires the applicable subscription, rollout and permissions. + + + Optional display name for notes in consumers that support the override. It changes the label, not permissions or functionality. This release has no separate name-override control in Module Settings. + + + Controls availability of reports. Disabling this module can remove navigation and block module operations; it does not delete its records. Turning it on still requires the applicable subscription, rollout and permissions. + + + Optional display name for reports in consumers that support the override. It changes the label, not permissions or functionality. This release has no separate name-override control in Module Settings. + + + Controls availability of shift scheduling. Disabling this module can remove navigation and block module operations; it does not delete its records. Turning it on still requires the applicable subscription, rollout and permissions. + + + Optional display name for shift scheduling in consumers that support the override. It changes the label, not permissions or functionality. This release has no separate name-override control in Module Settings. + + + Controls availability of training. Disabling this module can remove navigation and block module operations; it does not delete its records. Turning it on still requires the applicable subscription, rollout and permissions. + + + Optional display name for training in consumers that support the override. It changes the label, not permissions or functionality. This release has no separate name-override control in Module Settings. + + + Emergency management and response + + + EMS and ambulance + + + Fire + + + Hazardous materials response + + + Industrial response + + + Mental health and mobile crisis + + + Multi-agency and mutual aid + + + Search and rescue + + + Security and facilities + + + The pack can organize declared sites, personnel, supply expiry and continuity references. Shelter capacity, welfare needs, external agency availability, deployment conflicts and actual communications coverage are not yet measured by these checks. Review those sources with the response coordinator. + + + The pack can flag credential expiry, configured staffing gaps and stock/check metadata. It does not verify clinical scope, patient-care protocols, drug administration authority, medical equipment suitability or patient handover completion. Use the approved clinical and logistics systems with their responsible owners. + + + The pack can flag configured roster vacancies, qualifications, equipment checks and holds. It does not verify apparatus minimum staffing, command competence, local response standards or pre-incident plans. Have the responsible fire-service owner review these sources and the fallback plan. + + + The pack can flag recorded qualifications, overdue checks, expiring supplies and safety holds. It does not infer Hazmat response level, instrument calibration compliance, exposure clearance or protective-equipment suitability. Review actual approved requirements with the Hazmat program owner. + + + The pack can flag site-reference gaps, qualifications, equipment restrictions and supply expiry. Permit-to-work authority, process hazards, contractor clearance, site shutdown state and external plant alarms are not evaluated. The site response owner must verify those sources. + + + The pack can organize team coverage, protected access and current procedure references. After-hours referral ownership, crisis-service availability, clinical supervision and case handover are not yet measured. Confirm these with the responsible service owner without entering client narratives into setup. + + + The pack can organize shared-work areas, personnel and agreement references. External agency commitments, resource typing, cross-agency credentials, simultaneous assignments, cost eligibility and reimbursement acceptance are not verified. Confirm these with the participating agencies and finance owner. + + + The pack can flag configured roster gaps, credentials and overdue equipment checks. Search-sector coverage, terrain access, radio coverage, external resource location, missing-person case handling and mission suitability require their owning systems and a search-service reviewer. + + + The pack can review scoped resource visibility, configured shifts, credentials and check-in configuration. Post orders, lone-worker response arrangements, client deadlines and actual site access are not verified by these checks. Review them with the responsible operations owner. + + + Capability-gap tracking owner: Resgrid.Core. Sector-specific acceptance review is pending; these prompts are not approved local operating requirements. + + + Use approved local requirements. Missing external evidence remains unknown; this report does not certify operational safety. + + + Review activation rosters, volunteer onboarding, partner contacts and supplies. + + + Review qualification mix, ambulance checks, supply expiry and receiving contacts. + + + Review apparatus, qualified crew, run cards and mutual-aid arrangements. + + + Review locally approved qualifications, instrument checks, PPE and procedure review. + + + Review site coverage, escalation, equipment restrictions and approved local procedures. + + + Review clinician, peer and interpreter coverage, escalation contacts and restricted records. + + + Review agency-specific recipients, agreements, resource typing and cost documentation. + + + Review specialty qualifications, team availability, equipment checks and check-in arrangements. + + + Review site and post coverage, qualifications, check-in configuration and client deadlines. + + + Add Call Data + + + Add Personnel + + + Adjust Inventory + + + Amend Records + + + Approve Records + + + Approve Time Reports + + + Break Glass Protected Data + + + View personnel locations + + + View unit locations + + + Close Call + + + Command App Login + + + Configure Protected Data Egress + + + Contact Delete + + + Contact Edit + + + Contact View + + + Create Calendar Entry + + + Create calls + + + Create Document + + + Create Log + + + Create Message + + + Create notes + + + Create Record + + + Create Shift + + + Create Training + + + Create Workflow + + + Delete Call + + + Delete Log + + + Delete Record + + + Dispatch App Login + + + Edit Protected Call Data + + + Export Pay Data Reporting + + + Export Protected Data + + + Export Records + + + Finalize Records + + + Issue Inventory + + + Manage Bids + + + Manage Certification Setup + + + Manage Certifications + + + Manage Checklists + + + Manage Contracts + + + Manage Controlled Substances + + + Manage Department Data Protection + + + Manage Deployments + + + Manage Invoicing + + + Manage Mutual Aid Reimbursement + + + Manage Pay Data Reporting + + + Manage Record Definitions + + + Manage Record Disclosures + + + Manage Record Legal Hold + + + Manage Record Reports + + + Manage Routes + + + Manage Work Orders + + + Manage Workflow Credentials + + + Manage Workforce Compensation + + + Publish Record Definitions + + + Reassign Record Drafts + + + Records Prevention Admin + + + Remove Personnel + + + Review Records + + + Share Records Externally + + + Submit Records + + + Transfer Inventory + + + Use Calendar Sync + + + View All Work Orders + + + View Certifications + + + View Checklist Results + + + View Group Records + + + View units + + + View personnel + + + View Internal Costs + + + View Invoicing + + + View Legacy Records + + + View personal information + + + View Protected Call Data + + + View Protected Contact Data + + + View Protected Operational Data + + + View Protected Personnel Data + + + View Restricted Records + + + View custom field Fields + + + View Workflow Runs + + + View Workforce Compensation + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Controls the location visibility gate for each viewer and person. The preview counts access pairs, not current map markers, tracking consent or GPS availability. + + + Controls the location visibility gate for each viewer and unit. A group lock refers to the unit’s station and the owning group hierarchy, not any station a viewer can otherwise see. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Allows starting a call. The department-level action uses each member’s actual administrator state and personnel roles; later dispatch validation remains authoritative. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Allows creating a department note. The action does not grant access to protected note contents or another department. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Controls visibility of unit targets and their assigned stations. Ungrouped viewers and unassigned units do not share a group. + + + Controls visibility of personnel targets. A group lock can narrow target scope; administrators of a target group or its ancestors use the owning visibility policy. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Controls the personal-information permission gate. Protected fields still require their own current authorization and data protection grant. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Review this permission on the Security screen. Its default when no policy is saved, group scope, dependent permissions and behavior across web, API and mobile are awaiting a complete consumer review. No live impact preview is available for this permission yet. + + + Accessibility preferences + + + Authoritative source systems + + + Another administrator changed this profile. Your proposed values are still shown. Reload the current profile before saving again. + + + Approved continuity procedures + + + Approximate member count (optional) + + + Dispatch model + + + Enter existing numeric document IDs for approved policies in this department. Expired documents cannot be referenced. Saving verifies existence, not approval or suitability. + + + Enter existing numeric group IDs from this department. Saving adds another optional row. + + + One or more group or document references are invalid, expired or unavailable in this department. Check the references and try again. + + + Working languages + + + Participates in mutual aid + + + No + + + Operating hours + + + Approved qualification policies + + + Reference + + + Enter existing reference identifiers, not sensitive details. Saving adds another optional row. + + + Reload current profile + + + Save operating profile + + + Season ends (MM-DD, optional) + + + Season starts (MM-DD, optional) + + + Sites and service-area groups + + + Approved staffing policies + + + Use a short source-system reference label. These labels are declarations; saving does not verify a connection or import. + + + Department time zone + + + Workforce mix + + + Yes + + + Captions + + + Career + + + Central dispatch + + + Combination + + + 24-hour operation + + + Contract + + + External dispatch + + + Large text + + + On call + + + Plain language + + + Scheduled hours + + + Screen reader + + + Seasonal + + + Self dispatch + + + Unknown + + + Volunteer + + + Accessibility preferences + + + Operating sectors + + + Authoritative system references + + + Continuity procedure documents + + + Declared member count + + + Dispatch model + + + Expected email polling interval (minutes) + + + Working languages + + + Mutual aid participation + + + Operating hours + + + Qualification policy documents + + + Profile review time + + + Profile revision + + + Season end + + + Season start + + + Sites and groups + + + Staffing policy documents + + + Workforce mix + + + Record screen-reader, caption, large-text or plain-language needs for setup planning. Verify client behavior with the people who use it. + + + Choose all sectors served by the department. A pack suggests administrative reviews; it does not establish qualification or clinical scope. + + + Short declared source-system labels. Saving this list does not verify a connection, an import or the freshness of external data. + + + Reference existing department document IDs for approved continuity procedures. Test fallback communications and ownership through the approved procedure. + + + Optional planning estimate; actual membership and subscription counts come from their owning sources. + + + Record whether dispatch is central, self-directed, external or a combination. Verify each routing path through its owning settings. + + + Optional maximum time between recorded mailbox polls, from 1 to 10080 minutes. Blank means no declared polling expectation. This checks configured email connectors, not how often calls arrive; it does not measure SMS, CAD push or API health. Review the host polling schedule before setting it. + + + Languages used by the team. This preference does not prove interpreter availability or translate operational records. + + + Declare whether mutual aid is part of operations. Agreements, access and response eligibility require separate review. + + + Describe continuous, scheduled, on-call or seasonal operation. This is context and does not create shifts or guarantee coverage. + + + Reference existing department document IDs for approved qualification policies. Do not infer a license, scope of practice or deployment qualification from a role label. + + + Server-recorded time of the most recent validated profile save; it does not certify operational readiness. + + + Server revision used to reject concurrent overwrites. Reload when another administrator has saved a newer profile. + + + End of a declared seasonal period, in MM-DD format. A period can cross a calendar year; verify actual rosters separately. + + + Start of a declared seasonal period, in MM-DD format. Provide both start and end; this does not enable or disable resources on those dates. + + + Reference existing numeric group IDs in this department. This does not grant group access or change dispatch scope. + + + Reference existing department document IDs for approved staffing policies. The save checks existence and expiry, not policy approval or adequacy. + + + Describe the workforce so setup guidance can reflect career, volunteer, contract or combined operations. + + + Administrators need MFA enrollment + + + Administrator MFA is not enforced + + + Administrator succession needs review + + + Automatic check-in timers have no configuration + + + Required checks are overdue + + + Combined text intake and commands lack sender separation + + + Communication verification needs review + + + No continuity procedure is linked + + + Qualifications expire soon + + + Email import reports a failure + + + Groups have no active members + + + Equipment restrictions need review + + + Expected email polling evidence is missing + + + Custom map style is missing + + + Custom map credentials are missing + + + Administrator password resets need review + + + Personnel locations never expire + + + Personnel capacity is nearly full + + + Referenced policy documents expire soon + + + Referenced policies are unavailable + + + Required qualifications are uncovered + + + Record review deadlines need attention + + + Dispatch recommendations have no run cards + + + Shift status automation is inactive + + + Shift coverage needs review + + + Upcoming shifts have unfilled configured slots + + + Personnel have overlapping shift assignments + + + Upcoming shift trades are incomplete + + + Declared site references are unavailable + + + Station locations are incomplete + + + Stock expires soon + + + Text call sender routing needs review + + + Units have no group + + + Unit capacity is nearly full + + + Unit locations never expire + + + Units have no type + + + Weather alerts have no zones + + + Workflow failures need review + + + Review current administrator enrollment. + + + Review the administrator MFA requirement and recovery arrangements. + + + Review another authorized administrator and account recovery. + + + Review timer targets and escalation before activation. + + + Review overdue checklist assignments. + + + Review provider-specific dispatch sender classification and verified member commands. + + + Explicitly run a reviewed Communication Test. + + + Link an approved current document or review the external source with its responsible owner. + + + Review credentials due within the next 30 days. + + + Review the import error on the authorized Call Settings screen and verify the next poll. + + + Review group membership and intended use. + + + Review active holds and corrective work. + + + Review configured mailboxes, the host polling schedule and the declared interval. SMS, CAD push and API intake require separate evidence. + + + Review the custom map style. + + + Review the map override on its protected configuration screen. + + + Review the password-reset policy. + + + Choose a location lifetime appropriate to your workflow. + + + Review member usage and current subscription options. + + + Review document replacement or expiry with the responsible policy owner. + + + Review the operating profile references and their owning documents. + + + Review the owning qualification and roster evidence. + + + Review authorized overdue records. + + + Review run cards before relying on recommendations. + + + Review shift dispatch and automatic-status settings. + + + Review coverage and preview the dispatch recipients. + + + Review the shift roster and approved local staffing requirements. + + + Review assignments and trades; role eligibility does not establish physical availability. + + + Review outstanding offers and supervisor approvals. + + + Review current sites and service-area groups in the operating profile. + + + Review station addresses and coordinates. + + + Review expiring lots and authorized replenishment. + + + Review the active provider, approved sender patterns and controlled intake verification. + + + Review each unit group assignment. + + + Review unit usage and current subscription options. + + + Review unit location lifetime and device updates. + + + Assign the appropriate unit types through the Units screen. + + + Review weather alert zones. + + + Review failed runs and their owning workflow. + + + Enforcement settings alone do not prove that every administrator has enrolled. + + + Administrative accounts control department access and configuration. + + + A single administrative account can make recovery difficult. + + + Automatic timers need applicable target configuration. + + + Recorded overdue checks need an owner and follow-up. + + + Both text calls and commands are selected without dispatch-source patterns. Member commands use verified identity; the patterns distinguish dispatch senders rather than granting every member permission. + + + Configuration and device registration do not prove delivery. + + + The reviewed operating profile has no reference to a continuity or fallback procedure. The procedure may exist elsewhere; this is an administrative review prompt. + + + Expiring qualifications may affect upcoming assignments. + + + One or more configured mailboxes currently report a failed polling state. This is the latest stored observation, not proof of a continuing provider outage. + + + An empty group has no members to receive its work. + + + Only the owning workflow can release a safety hold. + + + Mailbox polling is compared with the interval declared in the operating profile. Missing calls alone do not imply an outage; absent or future poll timestamps remain unknown. + + + An enabled map override needs a style configuration. + + + An enabled map override needs its provider credentials. + + + Sending a single-use recovery link avoids an administrator choosing a member password. + + + A zero location lifetime can keep stale personnel positions on the map. + + + Adding members may exceed the current plan limit. + + + A linked staffing, qualification or continuity document is scheduled for removal within 30 days. This is document metadata, not a legal or clinical review deadline. + + + A declared policy document was removed, expired or is no longer in this department. Existence alone does not prove policy approval or suitability. + + + Coverage follows approved local qualification and role requirements. + + + Recorded due dates need administrative follow-up. + + + Enabled recommendations need reviewed run-card configuration. + + + Automatic shift status changes need shift dispatch enabled. + + + An empty shift can cause group dispatch to fall back to every group member. + + + Resolved rosters do not fill every configured role slot in the next seven days. + + + The same person appears on duty in overlapping shifts in the next seven days. + + + A requested or pending trade has not changed the resolved roster. + + + An operating-profile group reference no longer belongs to this department or has been removed. + + + Missing station locations reduce mapping and closest-resource usefulness. + + + Recorded lot expiry can affect supply availability. + + + Text call intake is selected without source patterns. Provider paths differ: this can broaden acceptance or prevent sender classification. The stored switch alone does not prove intake enforcement. + + + Group membership affects resource organization and routing. + + + Adding units may exceed the current plan limit. + + + A zero location lifetime can keep stale unit positions on the map. + + + Unit types support consistent status and qualification configuration. + + + Enabled weather alerts need an applicable area configuration. + + + Observed failures can leave administrative work incomplete. + + + Allow Signups For Multiple Shift Groups + + + Auto Set Status For Shift Dispatch Personnel + + + Big Board Hide Unavailable + + + Big Board Map Center Address + + + Big Board Map Center Gps Coordinates + + + Big Board Map Zoom Level + + + Big Board Page Refresh + + + Brain Tree Customer Id + + + Calls Sort Order + + + Check In Timers Auto Enable For New Calls + + + Department Operating Profile + + + Disabled Auto Available + + + Dispatch Recommendation Auto Dispatch + + + Dispatch Recommendation Config + + + Dispatch Recommendation Mode + + + Dispatch Shift Instead Of Group + + + Enable Modern Notifications + + + Enable Text Command + + + Enable Text To Call + + + Force Chatbot Security Pin + + + Group Dispatch Scope Config + + + Hardware Tracking Location Retention Days + + + Hardware Tracking Mobile Fallback Enabled + + + Hardware Tracking Stale After Seconds + + + Internal Dispatch Email + + + Mapping Mapbox Access Token + + + Mapping Mapbox Style Url + + + Mapping Personnel Allow Status With No Location To Overwrite + + + Mapping Personnel Location TTL + + + Mapping Unit Allow Status With No Location To Overwrite + + + Mapping Unit Location TTL + + + Mapping Use Mapbox Override + + + Module Settings + + + New Call Field Policy + + + Paddle Customer Id + + + Personnel List Status Sort Order + + + Personnel On Unit Set Unit Status + + + Personnel Sort Order + + + Records Default Lifecycle Preset + + + Records Disclosure Config + + + Records Group Scope Config + + + Records Group Visibility Mode + + + Records Numbering Config + + + Records Retention Policy + + + Records Review Due Hours + + + Records Search Config + + + Require2 FAFor Admins + + + Require Password Reset Via Email + + + Rss Feed Key For Active Calls + + + Shift Call Dispatch Personnel Status To Set + + + Shift Call Release Personnel Status To Set + + + Staffing Suppress Staffing Levels + + + Stripe Customer Id + + + Test Enabled + + + Text To Call Import Format + + + Text To Call Number + + + Text To Call Source Numbers + + + Tts Language + + + Unit Call Dispatch Status To Set + + + Unit Call Release Status To Set + + + Unit Call Status Overrides By Unit Type + + + Unit Dispatch Also Dispatch To Assigned Personnel + + + Unit Dispatch Also Dispatch To Group + + + Unit Status Thresholds + + + Units Sort Order + + + Update Timestamp + + + Weather Alert Auto Message Schedule + + + Weather Alert Auto Message Severity + + + Weather Alert Cache Minutes + + + Weather Alert Call Integration + + + Weather Alert Excluded Events + + + Weather Alert Minimum Severity + + + Weather Alerts Enabled + + + Allow a member to sign up for multiple shift groups. Review overlaps and local coverage rules separately. + + + Apply the configured status to shift-dispatched personnel. This only takes effect with shift dispatch enabled. + + + Hide unavailable resources on the Big Board; this affects visibility, not their dispatch eligibility. + + + Address used to center the Big Board. Address details remain on their owning screen. + + + Explicit board-center coordinates take precedence over address geocoding. Partial coordinates do not overwrite the saved center. + + + Initial Big Board map zoom. An unset override lets the consuming board select its default. + + + Big Board refresh interval. Review load and the age of displayed information before changing it. + + + Legacy billing reference. Use the subscription provider workflow, not a direct settings edit. + + + Sort order for displayed calls; does not reorder provider delivery or change call priority. + + + Automatically start configured check-in timers for new calls. Review timer targets and escalation before enabling it. + + + Declared organization, workforce, dispatch, seasonal, language and policy context. Recommendations never infer licenses or clinical scope from this profile. + + + Disables automatic availability behavior. Review staffing/status reset interactions before changing it. + + + Allow the recommendation path to dispatch automatically where enabled. Preview recipients and verify local procedures first. + + + Location, ETA, rest, crew and move-up settings used by recommendation consumers. External ETA calls occur only in the owning operational flow. + + + Dispatch recommendation selection mode. Review run cards, eligible resources and human dispatch responsibility. + + + Expand dispatched groups using the resolved on-duty roster. An empty shift result falls back to group members; preview both cases. + + + Use supported modern notification behavior. Verify each consumer and channel; provider acceptance is not delivery. + + + Configured text-command preference. Dispatch-source patterns and verified member command identity are different checks. Provider and chatbot paths do not enforce this switch uniformly; review the active consumer before changing it. + + + Configured text-call preference. Enforcement differs by provider and chatbot path; the stored value alone does not prove intake is stopped or accepted. Review sender classification and perform an explicit controlled verification on the owning screen. + + + Require the member security PIN for sensitive supported chatbot and SMS actions, including members who have not opted in. + + + Group-subtree dispatch visibility with explicit role exceptions. Preview each actor and target scope before changing it. + + + Tracking retention bounded by host configuration and applicable holds. Shortening retention can make data eligible for deletion. + + + Use supported mobile location fallback when hardware data is stale. Verify each device/source rather than assuming continuous tracking. + + + Age at which a hardware location becomes stale, clamped to at least one second. Review device reporting intervals. + + + Assigned internal dispatch email address. Presence can be shown; the address stays on the authorized import screen. + + + Map-provider access token. Only presence is reported. Disabling the override deletes it and requires re-entry to restore. + + + Custom map style reference. Verify the provider style and credential together; disabling the override deletes this value. + + + Allow a personnel status without a location to replace the previous location-bearing state. This can remove a marker. + + + Maximum personnel location age in minutes. Zero keeps stale positions visible without an age limit; assess actual ping ages. + + + Allow a unit status without a location to overwrite earlier location data. Check hardware and app updates together. + + + Maximum unit location age in minutes. Zero retains stale unit locations indefinitely in supported map consumers. + + + Use the department map provider override. Turning it off on the owning screen removes the stored style and token. + + + Controls module navigation and supported feature gates. Hiding a module does not delete its records or prove independent dispatch settings are disabled. + + + Controls optional new-call fields across supported clients. Hidden fields cannot be required; core dispatch fields remain mandatory. + + + Billing-provider customer reference. Managed by authorized billing flows and never returned in assistant evidence. + + + Custom ordering of personnel statuses in lists. Review all configured statuses before replacing the order. + + + Allow personnel status updates to influence their assigned unit status. Review automation and crew assignment together. + + + Sort order for personnel lists; does not change availability, access or dispatch priority. + + + Default lifecycle for new department-owned definitions. Locked definitions retain their own lifecycle. + + + Disclosure review clock, redaction profile and release approver. Review local obligations through the authorized disclosure workflow. + + + Reserved identifier with no editable behavior in this release. Do not present it as a shipped setting. + + + Whether record visibility is department-wide or group scoped. Group scope narrows existing permission and never grants access. + + + Number assignment, year reset and sequence formatting for definitions that do not override the department configuration. + + + Default and definition-specific retention, including prior-policy history. Holds and restricted-class rules can prevent purge eligibility. + + + Default administrative review deadline; a definition-level override takes precedence. This is not a statutory deadline inference. + + + Allowed record search scope and protected-data behavior. Search access remains bounded by current source permissions. + + + Administrator MFA requirement mode. Verify actual factors and recovery for each administrator before enforcement changes. + + + Use single-use recovery links for administrator-initiated member password resets, instead of selecting a replacement password. + + + Credential for the active-call feed. Treat the feed and its key as sensitive; rotate through its owning screen. + + + Personnel status applied when shift dispatch occurs. Minus one leaves status unchanged; use an existing approved status. + + + Personnel status applied on call release for the shift-dispatch path. Minus one leaves status unchanged. + + + Staffing levels suppressed in supported notifications. Suppression can change who hears an alert and is not evidence of delivery. + + + Billing-provider customer reference managed by subscription workflows; never edit this as an ordinary department setting. + + + Department testing behavior. Do not treat enabling a test setting as proof of production readiness. + + + Parser used for inbound text call creation. Verify the sender format with a controlled example on the owning screen. + + + Provisioned inbound text number. Provisioning has external effects and must use the normal text settings flow. + + + Allowed inbound source numbers for text calls and commands. Empty sources prevent accepted intake; never display raw numbers in Admin Assist. + + + Text-to-speech language for supported voice notifications. Confirm supported voices and pronunciation with a human-run test. + + + Unit status applied during call dispatch. Minus one or an invalid built-in unit status leaves the status unchanged. + + + Unit status applied at call release. Review unit-type overrides before assuming one department-wide effect. + + + Per-unit-type call status overrides. Existing type-specific values take precedence over department defaults. + + + Expand unit dispatch to its assigned crew. Review duplicate routes, current assignments and recipient permissions. + + + Expand unit dispatch to the station group. This can substantially increase the recipient audience. + + + Visual warning thresholds for time in a unit status. A highlight does not change the unit status or certify availability. + + + Sort order for unit lists; does not change unit eligibility or dispatch recommendation ranking. + + + Internal configuration timestamp. Read-only metadata, not an administrator-editable preference. + + + Schedule for automatic weather messaging. Review the department time zone, overnight periods and daylight-saving changes. + + + Minimum weather severity eligible for automatic messages. Review recipients, schedule and duplicate suppression. + + + Weather cache duration; longer caching changes freshness and provider load. + + + Include supported weather context in call integration. It does not certify weather or route safety. + + + Weather event types excluded from configured processing. Review exclusions against approved local procedures. + + + Minimum severity eligible for the weather-alert view. Severity values come from the weather service, not local incident triage. + + + Enable supported weather-alert processing. Configure zones and communication behavior before relying on it. + + + Check-in timer configuration is recorded. Verify target selection, deadlines, escalation ownership and a controlled scenario through the owning screen. + + + Mailbox configuration and available polling metadata are checked. Parser success, routing and page delivery require a controlled authorized test. + + + Checks recorded group membership and station location metadata. Review intended dispatch and site coverage separately. + + + Current active members are recorded. This does not verify invitations, account access, role assignment or qualifications. + + + Run-card records are present. The listed check verifies presence when recommendations are enabled, not correctness of local response requirements. + + + Checks recorded unit types and groups. Crew suitability, device setup and actual communications need separate verification. + + + Weather zones are recorded. Confirm geographic coverage and the actual provider and notification path. + + + Chatbot Department Config / Allow Dispatch Via Chatbot + + + Chatbot Department Config / Allowed Platforms + + + Chatbot Department Config / Is Enabled + + + Chatbot Department Config / Llm Api Endpoint + + + Chatbot Department Config / Llm Api Key + + + Chatbot Department Config / Llm Model Name + + + Chatbot Department Config / Max Sessions Per User + + + Chatbot Department Config / Messages Per Department Per Minute + + + Chatbot Department Config / Messages Per User Per Minute + + + Chatbot Department Config / Proactive Notifications Enabled + + + Chatbot Department Config / Require Confirmation For Status Change + + + Chatbot Department Config / Require Linking Confirmation + + + Chatbot Department Config / Session Ttl Minutes + + + Department / Address Id + + + Department / Api Key + + + Department / Code + + + Department / Department Type + + + Department / Link Code + + + Department / Managing User Id + + + Department / Name + + + Department / Public Api Key + + + Department / Shared Secret + + + Department / Show Welcome + + + Department / Time Zone + + + Department / Use24 Hour Time + + + Department Call Email / Error Message + + + Department Call Email / Format Type + + + Department Call Email / Hostname + + + Department Call Email / Is Failure + + + Department Call Email / Last Check + + + Department Call Email / Password + + + Department Call Email / Port + + + Department Call Email / Use Ssl + + + Department Call Email / Username + + + Department Notification / Before Data + + + Department Notification / Current Data + + + Department Notification / Data + + + Department Notification / Department Admins + + + Department Notification / Disabled + + + Department Notification / Event Type + + + Department Notification / Everyone + + + Department Notification / Groups To Notify + + + Department Notification / Lock To Group + + + Department Notification / Lower Limit + + + Department Notification / Roles To Notify + + + Department Notification / Selected Groups Admins Only + + + Department Notification / Upper Limit + + + Department Notification / Users To Notify + + + Department Security Policy / Allowed Ip Ranges + + + Department Security Policy / Data Classification Level + + + Department Security Policy / Max Concurrent Sessions + + + Department Security Policy / Min Password Length + + + Department Security Policy / Password Expiration Days + + + Department Security Policy / Require Mfa + + + Department Security Policy / Require Password Complexity + + + Department Security Policy / Require Sso + + + Department Security Policy / Session Timeout Minutes + + + Department Sso Config / Allow Local Login + + + Department Sso Config / Assertion Consumer Service Url + + + Department Sso Config / Attribute Mapping Json + + + Department Sso Config / Authority + + + Department Sso Config / Auto Provision Users + + + Department Sso Config / Client Id + + + Department Sso Config / Default Rank Id + + + Department Sso Config / Encrypted Client Secret + + + Department Sso Config / Encrypted Idp Certificate + + + Department Sso Config / Encrypted Scim Bearer Token + + + Department Sso Config / Encrypted Signing Certificate + + + Department Sso Config / Entity Id + + + Department Sso Config / Is Enabled + + + Department Sso Config / Metadata Url + + + Department Sso Config / Scim Enabled + + + Department Sso Config / Sso Provider Type + + + Weather Alert Zone / Center Geo Location + + + Weather Alert Zone / Is Active + + + Weather Alert Zone / Is Primary + + + Weather Alert Zone / Name + + + Weather Alert Zone / Radius Miles + + + Weather Alert Zone / Zone Code + + + Permits supported chatbot dispatch actions subject to their own authorization and confirmation. Enabling it may allow real calls and pages. + + + Allowed chatbot platform codes; the stored asterisk represents the platform default. Review each connected provider and its account-linking requirements. + + + Department chatbot availability. Platform feature rollout, account linking and action permissions are separate gates. + + + Optional existing chatbot model endpoint. Phase 0 Admin Assist does not call it and does not use chatbot configuration as an AI entitlement. + + + Credential for the chatbot model provider. Never copy it into setup metadata or public reference content. + + + Model identifier used by the existing chatbot integration. This is separate from the later Admin Assist open-source inference deployment. + + + Maximum chatbot sessions per member in supported consumers. This is separate from authenticated web/mobile session limits. + + + Optional department chatbot rate bound. Consider shared demand and provider quotas before changing it. + + + Optional per-member chatbot rate bound. Review provider limits and burst behavior; this is not a delivery guarantee. + + + Allows supported proactive chatbot notifications. Recipient permissions, channel configuration and sending workflows remain authoritative. + + + Requests confirmation for supported status changes. Review each platform consumer before assuming all commands are covered. + + + Requires confirmation in the supported account-linking flow. Linking must not bypass verified identity or departmental authorization. + + + Chatbot session lifetime in minutes. Expiry does not revoke an unrelated Resgrid sign-in session. + + + Department address reference. Review the owning address and mapping consumers; it is separate from station and site addresses. + + + Department API credential. Never place it in setup notes or model input; manage and rotate it through the authorized workflow. + + + Department code used by supported identification and access flows. Review those consumers before changing it. + + + Legacy organization-type label. The operating profile provides multiple sectors; neither establishes clinical scope or response qualifications. + + + Department linking code. Review who can use a link and rotate it using the owning workflow. + + + Managing-member identity. Subscription and high-risk account operations may require this member; change ownership through its explicit owning workflow. + + + Department display name. Review member-facing labels and documents that snapshot the name; changing it does not rename historical artifacts. + + + Credential used by supported public API consumers. Public in the field name does not make credential disclosure safe. + + + Department integration secret. Rotation can affect integrations and needs coordinated verification. + + + Legacy welcome flag. Restored setup dismissal is per administrator and does not use this flag as evidence of setup completion. + + + Department time zone used by scheduling and local-date reports. Review overnight shifts and daylight-saving transitions before changing it. + + + Preferred time display. This does not change stored timestamps or the department time zone. + + + Provider/import error details on the authorized screen. Admin Assist uses a normalized failure indication and does not copy provider bodies. + + + Selects the inbound email parser. Verify representative permitted source messages without sending an operational page unintentionally. + + + Mailbox host for email intake. Review the approved provider and network reachability without exposing credentials in setup guidance. + + + Latest recorded mailbox failure flag. Verify a new poll before treating it as a continuing outage or a resolved problem. + + + Last recorded polling timestamp. Missing or future timestamps are unknown; quiet call volume is not proof of a poll failure. + + + Mailbox credential. Do not copy it into Admin Assist; rotate it through the mailbox and owning editor together. + + + Mailbox connection port. It must match the selected provider and TLS mode. + + + Mailbox transport encryption option. Verify actual provider compatibility and successful polling; a stored flag alone does not prove transport protection. + + + Mailbox account identifier used for intake. It remains on the authorized configuration screen. + + + Previous-state filter for applicable events. Review valid status IDs and the event-specific interpretation of an empty or wildcard value. + + + Current-state filter for applicable events. Compare it with the previous-state filter to avoid unintended volume. + + + Event-specific configuration payload. The owning editor validates its meaning; Admin Assist does not execute arbitrary stored expressions. + + + Includes department administrators in supported notification processing. Review overlap and channel eligibility before estimating volume. + + + Stored disabled marker. The inspected legacy notification processor does not consult this flag, so it must not be treated as a verified stop switch. Review the active consumer; queued and delivered notifications cannot be recalled. + + + Event that can trigger this notification rule. Match the event to a real administrative responsibility and expected frequency. + + + Selects the department-wide recipient option. Review actual active membership, suppression and deduplication before use. + + + Department-group recipients. Review group membership and lock-to-group behavior together. + + + Restricts applicable event processing to group scope. Review the selected event's actual source group and recipient resolver. + + + Lower threshold for event-specific alerts. Use approved local thresholds and verify the actual event interpretation. + + + Personnel-role recipients. Role membership can change; a role label is not a qualification or delivery guarantee. + + + Limits selected-group recipients to their administrators in supported event paths. It does not designate department administrators automatically. + + + Upper threshold for event-specific alerts. Units and comparisons depend on the selected event. + + + Explicit member recipients. Membership and channel eligibility are evaluated separately at send time. + + + Allowed login network ranges. Empty adds no range restriction. Review legitimate responder networks and recovery access before narrowing it. + + + Department classification label. Selecting it does not enroll ADP, establish compliance or automatically classify every record correctly. + + + Maximum policy-managed concurrent sessions per user; zero means unlimited for this setting. Existing sessions and enforcement rollout must be checked separately. + + + Minimum length offered by the policy editor. Actual password validators and supported credential-change paths remain authoritative. + + + Password age in days; zero disables this policy's age limit. Verify local-password and SSO behavior separately. + + + Department-wide MFA policy, distinct from the administrator-only setting. Review enrollment, supported factors and recovery before enforcement. + + + Stored complexity preference. Consumer and editor coverage must be verified before relying on this field; it is not proof that an existing password meets a rule. + + + Requires the supported SSO policy gate. Verify the active identity provider and local recovery arrangements before removing a sign-in path. + + + Idle timeout in minutes; zero defers to host behavior. Verify policy-managed sessions and each supported client rather than assuming immediate global logout. + + + Allows a local-password path alongside this provider where policy permits. Review the department-wide SSO requirement and recovery plan together. + + + Stored SAML callback configuration. Use the endpoint supplied by the owning SSO setup and verify the provider registration. + + + Maps provider attributes to supported member fields. Validate identity matching and missing attributes with an approved test account. + + + OIDC issuer or authority. Verify the intended tenant, discovery metadata and callback registration before enabling it. + + + Allows the supported sign-in flow to create members. Review identity matching, capacity and default assignments before enabling it. + + + Client registration identifier from the identity provider. Keep tenant and audience registration consistent with the owning SSO flow. + + + Department rank assigned by supported automatic provisioning. It does not grant permissions or prove qualifications. + + + Encrypted provider client secret. The secret is entered and rotated on the authorized provider editor; Admin Assist only describes its purpose. + + + Encrypted provider verification certificate material. Review expiry and rotation through SSO; this description is not certificate validation. + + + Encrypted credential for inbound SCIM requests. Rotate through the explicit token workflow and update the provider before retiring an old credential. + + + Encrypted signing certificate and private-key material. Do not copy it into notes, logs or assistant messages. + + + SAML service-provider identifier registered at the identity provider. It must agree with the configured integration. + + + Makes this provider configuration available to the supported SSO flow. A saved enabled flag is not a successful sign-in test. + + + SAML provider metadata location. Review the trusted provider and its current signing material through the SSO editor. + + + Enables supported SCIM provisioning for this configuration. Review identity lifecycle, deactivation and token access separately from interactive sign-in. + + + Selects the identity protocol and provider configuration. OIDC and SAML have different metadata, certificates and callback requirements. + + + Center point used by supported radius-based weather coverage. Coordinate values stay on their owning map/editor. + + + Whether the zone participates in supported weather processing. Department weather settings and worker operation are additional requirements. + + + Marks the primary zone for consumers that select a default. Review other active zones rather than assuming they are disabled. + + + Administrator-facing label for this weather zone. The name does not define its geographic coverage. + + + Radius in miles for supported geographic matching. Review actual coverage and provider behavior; larger is not automatically safer. + + + Provider weather-zone identifier. Verify the intended jurisdiction and source coverage. + + + Accept time-limited exception + + + Accepted exception + + + Needs attention + + + Features that use this add-on + + + Requires Advanced Data Protection + + + Requires Workforce and Business Operations + + + Requires Push-to-Talk + + + Requires Readiness Pro + + + Show add-on comparisons only + + + Before adopting + + + After + + + All findings + + + Area + + + Assign to me + + + Assigned + + + Availability could not be verified + + + Current access allows the next setup step + + + Availability + + + Before + + + Recorded configuration, supported checks, add-on access and personal learning are separate. No activity measurement is implied: a feature with no recorded setup is not necessarily unused. Unknown evidence remains unassessed. + + + Feature setup and optional opportunities + + + Catalog version + + + Verified checks in selected areas + + + Supported configuration checks + + + Feature configuration has not been certified. Review the owning screen and the applicable checks in this report. + + + Open owning screen + + + Another administrator changed setup. The latest workspace has been loaded; review it before saving again. + + + Critical + + + Critical checks have unknown evidence. The department cannot be shown as fully verified. + + + Department ID + + + Optional administrative follow-up using your configured notification channels. No finding details are included. Quiet hours use the department time zone. An uncertain provider handoff is not automatically retried. + + + Your weekly Resgrid configuration summary is ready. Sign in to Admin Assist to review current evidence. + + + Send me a generic weekly summary link + + + Last notification handoff + + + Scheduled digests are disabled on this deployment. Your preference is saved, but no digest will be sent until the host enables them. + + + Dismiss for me; setup stays available in the menu + + + The report could not be loaded. Retry or use the normal department screens. + + + Evidence revision + + + Example + + + Exception expires + + + Choose an expiry within 90 days. The check continues to show its actual result. Notes follow department data protection. + + + Needs attention + + + Checks needing attention + + + Not enabled for this deployment + + + Planned + + + Preview + + + Retired + + + Show + + + Fresh setup + + + Evidence as of + + + Change + + + History starts when collection is enabled. Earlier configuration changes are unavailable. + + + Subject + + + When + + + Import or migration + + + Import planning does not copy data. Use the existing reviewed import tools and verify the resulting configuration here. + + + In review + + + Configuration changed during this read. Verify again before relying on this report. + + + Information + + + Information + + + Interested in this feature + + + Choose fresh setup, review or import mode above. Decide who dispatches, who responds and which applications or shared devices each role uses. A synthetic example: a dispatcher creates a training scenario, a responder sees assigned work and a unit crew updates its status. This lesson creates no incident or message. + + + Welcome and goals + + + Review the time zone, language, address and operating profile. Declare mission, operating hours, service areas and approved policy references. Unitless and non-dispatch teams should select only the work they need. Saving this profile does not validate a local staffing or clinical policy. + + + Department and operating profile + + + Use the area choices below and explore the full feature map, including capabilities hidden from your current menus. Choose use now, learn later or a reason why an area does not apply. Mark understood features individually; reading does not verify configuration. + + + Explore every area of Resgrid + + + Review the managing member and backup administrators, MFA, groups or stations, roles and qualifications. Add or invite personnel through the owning screen only after reviewing recipients and the send action. Confirm least-privilege access with the responsible users. + + + People, structure and access + + + Configure the calls, units, statuses, intake, notifications and mapping needed for your selected mission. Review dispatch previews and source eligibility before relying on a channel. App installation, device identity, provider setup and current contact verification are separate tasks. + + + Operational essentials + + + Open the selected-workflow list in Setup Report. Check prerequisites and responsible owners for shifts, training, checklists, inventory, records, contacts and automation. Save on the normal configuration screen, use the return-to-setup link, and verify again. + + + Configure selected areas + + + Compare current included capabilities with Push-to-Talk, Advanced Data Protection, Readiness Pro and Business Operations. Review feature value, alternatives and adoption requirements. Enhanced AI remains planned. Interest and learning never purchase, start a trial, enroll protection or enable a module. + + + Understand plans and add-ons + + + Run Setup Report and review critical failures and unknowns. Use available dry-run previews. A communication test is a separate deliberate action: review its recipients and channels before sending through Communication Tests. A completed setup review does not guarantee field response or delivery. + + + Verify and practice + + + Record the reviewed evidence, unresolved work, owners and optional revisit date in Setup Report. Use the redacted print view for an authorized handover. Train other administrators and members on their normal workflows. Each new administrator keeps a separate orientation checklist. + + + Review and handover + + + Configuration effort depends on your selected areas, data quality, local policy and provider setup. Reserve time with the responsible owners; a department-specific effort estimate is not yet available. + + + Work through these nine steps at your own pace. Shared scope, personal learning and review evidence are saved separately. Configure features on their owning screens, then use Setup Report to verify current evidence. + + + Your setup journey + + + Available + + + Learn later + + + Learn about this feature + + + I understand this feature + + + Learning progress + + + Learning marked complete by you + + + Learning not yet marked complete + + + Loading department setup… + + + Subscription changes require the managing member + + + Mark this report reviewed + + + Setup mode + + + Module is disabled + + + Load more + + + Next setup actions + + + Learning and interest choices never purchase, enable, send, import or change operational configuration. + + + No matching features. + + + Not yet reviewed + + + No items in this view. + + + Not applicable + + + Department operating profile + + + Feature without an add-on requirement has no configuration recorded in the checked source + + + No add-on requirement in this release; applicable base-plan limits still apply + + + Optional add-on opportunity; no current entitlement was verified + + + Not currently released for setup + + + Current add-on entitlement verified; setup is a separate step + + + Subscribed feature with no configuration recorded in the checked source + + + Current add-on or access information is unavailable + + + Unpurchased optional add-ons do not reduce core completion. Deferring an area does not hide a critical verified failure or active critical uncertainty. + + + Optional capabilities and add-ons + + + Operating packs + + + Verified + + + Follow-up preferences + + + Print + + + This report contains only the current authorized setup summary and public guidance. Protected notes, names, source records and credentials are omitted. It is not an operational readiness certification. Handle any copy you print or save under your department’s information policy; this page creates no stored server export. + + + Open a fresh printable report + + + Use approved local staffing, qualification and continuity policies; operating packs suggest areas to review without assuming local requirements. + + + Your operating profile could not be verified. Pack suggestions are unavailable. + + + Data protection enrollment is required + + + Quiet hours end (0–23; same hour means no quiet period) + + + Quiet hours start (0–23) + + + Read source text + + + Reason + + + Protected evidence unavailable + + + Configuration health is administrative guidance. A learned feature or saved setting is not proof of operational readiness or message delivery. + + + Required add-ons + + + Resolved by verification + + + Start or resume setup + + + Retry + + + Return to setup and verify + + + Review existing setup + + + Configuration, selected setup scope or catalog version has changed since that review. Verify current evidence and review again. + + + Review due + + + Checks at the recorded administrative review + + + Last administrative review + + + Review again on (UTC date) + + + Choose an optional shared review date within the next year. It is stored at noon UTC and shown in the report; this choice does not send a reminder. Optional weekly follow-up is configured separately in the admin worklist. Clear the date to remove it. + + + The change was not saved. Reload and try again. + + + Save preferences + + + Save review date + + + Save area choice + + + Save changes on this screen first, then return to setup and verify the current evidence. The return link does not save this form. + + + Saving… + + + Why this area is not applicable + + + No current department need + + + Managed in another approved system + + + Outside our department mission + + + Managed by a responsible partner + + + Search areas and features + + + Search reference documentation + + + Choose a reason + + + Choose features of interest in Explore Resgrid and add-ons to see their prerequisites here. + + + Selected in your operating profile + + + Your selected feature interests + + + Setup choice + + + Learn the available areas and optional add-ons, configure your selected areas, then review the evidence together. + + + Recorded configuration and listed checks passed + + + Configuration recorded; verification is incomplete + + + Configuration recorded; listed checks need attention + + + Configuration has not been verified + + + No configuration recorded in the checked source + + + Current source access unavailable + + + Start review + + + View subscription options + + + Subscription status could not be verified + + + Suggested by your selected operating packs + + + Resgrid Admin Assist + + + Unassigned + + + Unavailable + + + Selected areas without automated checks + + + Unknown + + + Checks with unknown evidence + + + Use now + + + Value to your department + + + Results cover only the listed checks. Review procedures, external systems and areas without automated checks with their responsible owners. + + + Verify again + + + Choose Verify again to create or update the worklist. + + + Warning + + + Choose the work your department needs, learn the available areas, then configure each feature on its own screen. Return here to verify. + + + Last completed background evaluation + + + The background worker has not completed an evaluation. + + + Current access could not be verified + + + A prerequisite prevents the next setup step + + + Interest and learning are personal choices. Availability, setup and verified checks are separate; purchasing an add-on never completes setup automatically. + + + Verification updates findings. Assignment and accepted exceptions do not resolve a failed check. A fresh passing check resolves it. + + + Explore Resgrid and add-ons + + + Health checks + + + Change history + + + Overview + + + Settings reference + + + Setup Report + + + Setup Wizard + + + Admin worklist + + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.sv.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.sv.resx new file mode 100644 index 000000000..453178b81 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.sv.resx @@ -0,0 +1,324 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms + + + System.Resources.ResXResourceWriter, System.Windows.Forms + + + Avancerat dataskydd + + + Arkiverade larm + + + Anbud + + + Stortavla + + + Kalender + + + Larm + + + Chatt + + + Checklistor + + + Kommunikationstester + + + Kontakter + + + Avtal + + + Översikt + + + Dataskydd + + + Avdelningsinställningar + + + Dina avdelningar + + + Dokument + + + Formulär + + + Fakturor + + + Loggar + + + Kartläggning + + + Modulinställningar + + + Ny insats + + + Ny grupp + + + Nytt Protokoll + + + Ny utbildning + + + Anteckningar + + + Personal + + + Förebyggande underhåll + + + Protokoll + + + Prislistor + + + Bevarandespärrar för journaler + + + Tillsyner + + + Utredningar + + + Tillstånd + + + Journalinställningar + + + Rapporter + + + Rutter + + + Skift + + + Utbildningar + + + Enheter + + + Röstsamtal + + + Arbetsflöden + + + Personal + + + Alla + + + Brand + + + Sök och räddning + + + Justera lager + + + Godkänna rapporter + + + Slutföra rapporter + + + Hantera rapportdefinitioner + + + Hantera utlämnanden av rapporter + + + Hantera rapportsammanställningar + + + Publicera rapportdefinitioner + + + Granska rapporter + + + Nej + + + Drifttimmar + + + Referens + + + Ja + + + Avtal + + + Okänd + + + Frivillig + + + Drifttimmar + + + Efter + + + Område + + + Tilldelad + + + Före + + + Katalogversion + + + Öppna tillhörande inställningssida + + + Kritisk + + + Behöver åtgärdas + + + Planerad + + + Underlag per + + + Information + + + Jag är intresserad av funktionen + + + Tillgänglig + + + Lär dig senare + + + Jag förstår funktionen + + + Läser in avdelningens konfiguration… + + + Ej tillämpligt + + + Verifierad + + + Skriv ut + + + Läs källtexten + + + Skyddade underlag är inte tillgängliga + + + Konfigurationskontrollen ger administrativ vägledning. Att förstå en funktion eller spara en inställning bevisar inte operativ beredskap eller att meddelanden har levererats. + + + Försök igen + + + Granskning senast + + + Sparar… + + + Sök områden och funktioner + + + Sök i referensdokumentationen + + + Ej tilldelad + + + Inte tillgänglig + + + Okänd + + + Använd nu + + + Kontrollera igen + + + Varning + + + Utforska Resgrid och tillägg + + + Konfigurationskontroller + + + Ändringshistorik + + + Översikt + + + Inställningsreferens + + + Konfigurationsrapport + + + Installationsguide + + + Administratörens uppgiftslista + + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.uk.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.uk.resx new file mode 100644 index 000000000..ad51f51ce --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.uk.resx @@ -0,0 +1,297 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms + + + System.Resources.ResXResourceWriter, System.Windows.Forms + + + Розширений захист даних + + + Архівні виклики + + + Пропозиції + + + Велика дошка + + + Календар + + + Виклики + + + Чат + + + Контрольні списки + + + Тести зв’язку + + + Контакти + + + Договори + + + Захист даних + + + Налаштування підрозділу + + + Ваші підрозділи + + + Документи + + + Форми + + + Звіти про інциденти + + + Інвентар + + + Рахунки + + + Журнали + + + Картографування + + + Налаштування модулів + + + Нове розгортання + + + Нова Група + + + Новий Протокол + + + Нове навчання + + + Профілактичне обслуговування + + + Протоколи + + + Тарифи + + + Утримання записів для збереження + + + Перевірки + + + Розслідування + + + Дозволи + + + Налаштування записів + + + Маршрути + + + Зміни + + + Навчання + + + Голосовий виклик + + + Робочі процеси + + + Персонал + + + Усі + + + Пошук і порятунок + + + Коригувати інвентар + + + Затвердження звітів + + + Завершення звітів + + + Керування визначеннями звітів + + + Керування розкриттям звітів + + + Керування зведеннями звітів + + + Публікація визначень звітів + + + Перевірка звітів + + + Ні + + + Так + + + Договір + + + Волонтер + + + Після + + + До + + + Версія каталогу + + + Відкрити відповідну сторінку налаштувань + + + Критично + + + Потребує уваги + + + Заплановано + + + Попередній перегляд + + + Дані станом на + + + Інформація + + + Мене цікавить ця функція + + + Доступно + + + Ознайомитися пізніше + + + Я розумію цю функцію + + + Завантаження налаштувань підрозділу… + + + Не застосовується + + + Перевірено + + + Друк + + + Читати вихідний текст + + + Захищені підтверджувальні дані недоступні + + + Перевірка налаштувань надає адміністративні рекомендації. Розуміння функції або збереження налаштування не підтверджує оперативної готовності чи доставки повідомлень. + + + Повторити спробу + + + Термін перевірки + + + Збереження… + + + Пошук розділів і функцій + + + Пошук у довідковій документації + + + Не призначено + + + Недоступно + + + Невідомо + + + Використовувати зараз + + + Перевірити знову + + + Попередження + + + Огляд Resgrid і доповнень + + + Перевірки налаштувань + + + Історія змін + + + Огляд + + + Довідник налаштувань + + + Звіт про налаштування + + + Майстер налаштування + + + Список завдань адміністратора + + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.ar.resx b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.ar.resx index 98be35e26..690acb42e 100644 --- a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.ar.resx @@ -135,4 +135,22 @@ رمز التحقق الخاص بك في Resgrid هو: {0}. تنتهي صلاحيته خلال {1} دقيقة. + رمز PIN للبلاغات المحمية + اختر رمز PIN شخصيًا من 6 إلى 12 رقمًا. يلزم تحقق جديد عبر تطبيق المصادقة. يجب أن يفعّل قسمك إتاحة البيانات برمز PIN لتلقي تفاصيل البلاغات المحمية. + رمز PIN الشخصي + رمز تطبيق المصادقة + حفظ + تم الحفظ. + تعذر إتاحة البيانات المحمية أو حفظها. سجّل الدخول إلى Resgrid وحاول مجددًا. + يتوفر بلاغ محمي. أرسل OPEN {0} ثم مسافة ثم رمز PIN الشخصي. + يتوفر بلاغ محمي. أدخل رمز PIN الشخصي ثم اضغط على مفتاح المربع. + إعدادات إتاحة بيانات ADP + الإرسال بالرسائل النصية + الإرسال الصوتي + تنبيه عام + الإتاحة بعد رمز PIN + السماح بوصول مؤقت لموظفي دعم Resgrid بموافقة موظف ثانٍ. + أقر بأن التفاصيل المتاحة تغادر Resgrid وقد تحتفظ بها شركات الاتصالات أو المستلمون. + السماح بالمحتوى المحمي + طلب موظفو Resgrid الوصول إلى البلاغ المحمي {0} لحالة الدعم {1}. يتطلب الوصول موافقة موظف مستقل وتنتهي صلاحيته خلال 15 دقيقة. \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.de.resx b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.de.resx index aa6159b3f..4ad1c2d87 100644 --- a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.de.resx +++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.de.resx @@ -135,4 +135,22 @@ Ihr Resgrid-Team Ihr Resgrid-Bestätigungscode lautet: {0}. Er läuft in {1} Minuten ab. + PIN für geschützte Einsätze + Wählen Sie eine persönliche PIN mit 6–12 Ziffern. Eine erneute Bestätigung per Authenticator ist erforderlich. Ihre Abteilung muss die PIN-Freigabe aktivieren, bevor geschützte Einsatzdetails empfangen werden können. + Persönliche PIN + Authenticator-Code + Speichern + Gespeichert. + Geschützte Daten konnten nicht freigegeben oder gespeichert werden. Melden Sie sich bei Resgrid an und versuchen Sie es erneut. + Ein geschützter Einsatz ist verfügbar. Antworten Sie mit OPEN {0}, einem Leerzeichen und Ihrer persönlichen PIN. + Ein geschützter Einsatz ist verfügbar. Geben Sie Ihre persönliche PIN ein und drücken Sie die Raute-Taste. + ADP-Freigabeeinstellungen + SMS-Zustellung + Sprachzustellung + Allgemeine Benachrichtigung + Freigabe nach PIN + Zeitlich begrenzten Zugriff durch Resgrid-Supportmitarbeiter mit Genehmigung eines zweiten Mitarbeiters erlauben. + Ich bestätige, dass freigegebene Details Resgrid verlassen und von Telefonanbietern oder Empfängern gespeichert werden können. + Geschützte Inhalte erlauben + Resgrid-Mitarbeiter haben Zugriff auf den geschützten Einsatz {0} für Supportfall {1} angefordert. Der Zugriff erfordert einen unabhängigen Genehmiger und läuft innerhalb von 15 Minuten ab. \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.el.resx b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.el.resx index 05f90b1b8..2acbc5b96 100644 --- a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.el.resx +++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.el.resx @@ -135,4 +135,22 @@ Ο κωδικός επαλήθευσης Resgrid είναι: {0}. Λήγει σε {1} λεπτά. + PIN προστατευμένων συμβάντων + Επιλέξτε προσωπικό PIN 6–12 ψηφίων. Απαιτείται νέα επαλήθευση με την εφαρμογή ελέγχου ταυτότητας. Το τμήμα σας πρέπει να ενεργοποιήσει την παροχή στοιχείων μέσω PIN για να λαμβάνετε προστατευμένες λεπτομέρειες. + Προσωπικό PIN + Κωδικός εφαρμογής ελέγχου ταυτότητας + Αποθήκευση + Αποθηκεύτηκε. + Δεν ήταν δυνατή η παροχή ή αποθήκευση προστατευμένων δεδομένων. Συνδεθείτε στο Resgrid και δοκιμάστε ξανά. + Υπάρχει προστατευμένο συμβάν. Απαντήστε OPEN {0}, ένα κενό και το προσωπικό σας PIN. + Υπάρχει προστατευμένο συμβάν. Πληκτρολογήστε το προσωπικό σας PIN και πατήστε δίεση. + Ρυθμίσεις παροχής δεδομένων ADP + Παράδοση SMS + Φωνητική παράδοση + Γενική ειδοποίηση + Παροχή μετά το PIN + Να επιτρέπεται προσωρινή πρόσβαση στο προσωπικό υποστήριξης Resgrid με έγκριση δεύτερου υπαλλήλου. + Αναγνωρίζω ότι τα παρεχόμενα στοιχεία εξέρχονται από το Resgrid και μπορεί να διατηρηθούν από τηλεφωνικούς παρόχους ή παραλήπτες. + Να επιτρέπεται προστατευμένο περιεχόμενο + Το προσωπικό Resgrid ζήτησε πρόσβαση στο προστατευμένο συμβάν {0} για την υπόθεση υποστήριξης {1}. Η πρόσβαση απαιτεί ανεξάρτητη έγκριση και λήγει εντός 15 λεπτών. \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.en.resx b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.en.resx index df185793e..29b287066 100644 --- a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.en.resx +++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.en.resx @@ -135,4 +135,22 @@ The Resgrid Team Your Resgrid verification code is: {0}. It expires in {1} minutes. + Protected dispatch PIN + Choose a personal PIN of 6–12 digits. Fresh authenticator verification is required. Your department must enable PIN release before you can receive protected dispatch details. + Personal PIN + Authenticator code + Save + Saved. + Unable to release or save protected data. Sign in to Resgrid and try again. + A protected dispatch is available. Reply OPEN {0} followed by your personal PIN, separated by a space. + A protected dispatch is available. Enter your personal PIN followed by the pound key. + ADP release settings + SMS delivery + Voice delivery + Generic alert + Release after PIN + Allow time-limited Resgrid staff support access with approval by a second staff member. + I acknowledge that released details leave Resgrid and may be retained by phone carriers or recipients. + Allow protected content + Resgrid staff requested access to protected dispatch {0} for support case {1}. Access requires an independent staff approver and expires within 15 minutes. \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.es.resx b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.es.resx index cb30dbea3..a6ca79cbd 100644 --- a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.es.resx +++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.es.resx @@ -135,4 +135,22 @@ El equipo de Resgrid Su código de verificación de Resgrid es: {0}. Caduca en {1} minutos. + PIN de despacho protegido + Elija un PIN personal de 6 a 12 dígitos. Se requiere una verificación reciente con el autenticador. Su departamento debe habilitar la entrega mediante PIN para recibir los detalles protegidos. + PIN personal + Código del autenticador + Guardar + Guardado. + No se pueden mostrar ni guardar los datos protegidos. Inicie sesión en Resgrid e inténtelo de nuevo. + Hay un despacho protegido disponible. Responda OPEN {0} seguido de su PIN personal, separado por un espacio. + Hay un despacho protegido disponible. Introduzca su PIN personal y pulse la tecla de almohadilla. + Configuración de entrega de ADP + Entrega por SMS + Entrega por voz + Alerta genérica + Mostrar después del PIN + Permitir acceso temporal al personal de soporte de Resgrid con la aprobación de otro empleado. + Reconozco que los detalles entregados salen de Resgrid y pueden ser conservados por operadores telefónicos o destinatarios. + Permitir contenido protegido + El personal de Resgrid solicitó acceso al despacho protegido {0} para el caso de soporte {1}. El acceso requiere un aprobador independiente y caduca en 15 minutos. \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.fr.resx b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.fr.resx index fd7376873..611abeb2b 100644 --- a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.fr.resx @@ -135,4 +135,22 @@ L'équipe Resgrid Votre code de vérification Resgrid est : {0}. Il expire dans {1} minutes. + Code PIN des interventions protégées + Choisissez un code PIN personnel de 6 à 12 chiffres. Une vérification récente par authentificateur est requise. Votre service doit activer la transmission par code PIN pour recevoir les détails protégés. + Code PIN personnel + Code de l’authentificateur + Enregistrer + Enregistré. + Impossible de transmettre ou d’enregistrer les données protégées. Connectez-vous à Resgrid et réessayez. + Une intervention protégée est disponible. Répondez OPEN {0} suivi de votre code PIN personnel, séparé par un espace. + Une intervention protégée est disponible. Saisissez votre code PIN personnel puis appuyez sur dièse. + Paramètres de transmission ADP + Transmission par SMS + Transmission vocale + Alerte générique + Transmettre après le code PIN + Autoriser un accès temporaire au personnel d’assistance Resgrid avec l’approbation d’un second employé. + Je reconnais que les détails transmis quittent Resgrid et peuvent être conservés par les opérateurs téléphoniques ou les destinataires. + Autoriser le contenu protégé + Le personnel Resgrid a demandé l’accès à l’intervention protégée {0} pour le dossier d’assistance {1}. L’accès nécessite un approbateur indépendant et expire sous 15 minutes. \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.it.resx b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.it.resx index 47d1f7455..13a60823b 100644 --- a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.it.resx +++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.it.resx @@ -135,4 +135,22 @@ Il team Resgrid Il tuo codice di verifica Resgrid è: {0}. Scade tra {1} minuti. + PIN per interventi protetti + Scegli un PIN personale di 6–12 cifre. È richiesta una nuova verifica con l’app di autenticazione. Il reparto deve abilitare il rilascio tramite PIN per ricevere i dettagli protetti. + PIN personale + Codice di autenticazione + Salva + Salvato. + Impossibile rilasciare o salvare i dati protetti. Accedi a Resgrid e riprova. + È disponibile un intervento protetto. Rispondi OPEN {0} seguito dal tuo PIN personale, separato da uno spazio. + È disponibile un intervento protetto. Inserisci il tuo PIN personale e premi il tasto cancelletto. + Impostazioni di rilascio ADP + Invio tramite SMS + Invio vocale + Avviso generico + Rilascio dopo il PIN + Consenti l’accesso temporaneo al personale di assistenza Resgrid con l’approvazione di un secondo dipendente. + Riconosco che i dettagli rilasciati escono da Resgrid e possono essere conservati dagli operatori telefonici o dai destinatari. + Consenti contenuti protetti + Il personale Resgrid ha richiesto accesso all’intervento protetto {0} per la richiesta di assistenza {1}. L’accesso richiede un approvatore indipendente e scade entro 15 minuti. \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.pl.resx b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.pl.resx index 01a1d43b5..aeede04c2 100644 --- a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.pl.resx @@ -135,4 +135,22 @@ Zespół Resgrid Twój kod weryfikacyjny Resgrid to: {0}. Wygaśnie za {1} minut. + PIN chronionych zgłoszeń + Wybierz osobisty PIN o długości 6–12 cyfr. Wymagana jest ponowna weryfikacja w aplikacji uwierzytelniającej. Twój dział musi włączyć udostępnianie po podaniu PIN-u, aby otrzymywać chronione szczegóły zgłoszeń. + Osobisty PIN + Kod z aplikacji uwierzytelniającej + Zapisz + Zapisano. + Nie można udostępnić ani zapisać chronionych danych. Zaloguj się do Resgrid i spróbuj ponownie. + Dostępne jest chronione zgłoszenie. Odpowiedz OPEN {0}, a następnie wpisz spację i swój osobisty PIN. + Dostępne jest chronione zgłoszenie. Wpisz swój osobisty PIN i naciśnij krzyżyk. + Ustawienia udostępniania ADP + Dostarczanie SMS + Dostarczanie głosowe + Powiadomienie ogólne + Udostępnij po podaniu PIN-u + Zezwól pracownikom pomocy Resgrid na czasowy dostęp po zatwierdzeniu przez drugiego pracownika. + Przyjmuję do wiadomości, że udostępnione szczegóły opuszczają Resgrid i mogą być przechowywane przez operatorów telefonicznych lub odbiorców. + Zezwól na chronioną treść + Pracownik Resgrid poprosił o dostęp do chronionego zgłoszenia {0} w sprawie pomocy {1}. Dostęp wymaga niezależnego zatwierdzenia i wygasa w ciągu 15 minut. \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.sv.resx b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.sv.resx index a9024ce01..128f72a52 100644 --- a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.sv.resx @@ -135,4 +135,22 @@ Resgrid-teamet Din Resgrid-verifieringskod är: {0}. Den upphör att gälla om {1} minuter. + PIN för skyddade larm + Välj en personlig PIN med 6–12 siffror. En ny verifiering med autentiseringsappen krävs. Din avdelning måste aktivera utlämning med PIN för att du ska kunna ta emot skyddade larmuppgifter. + Personlig PIN + Kod från autentiseringsappen + Spara + Sparat. + Det gick inte att lämna ut eller spara skyddade uppgifter. Logga in på Resgrid och försök igen. + Ett skyddat larm är tillgängligt. Svara OPEN {0} följt av ett mellanslag och din personliga PIN. + Ett skyddat larm är tillgängligt. Ange din personliga PIN och avsluta med fyrkant. + Inställningar för utlämning med ADP + SMS-leverans + Röstleverans + Allmän avisering + Lämna ut efter PIN + Tillåt tidsbegränsad åtkomst för Resgrids supportpersonal efter godkännande av en andra medarbetare. + Jag bekräftar att utlämnade uppgifter lämnar Resgrid och kan sparas av telefonoperatörer eller mottagare. + Tillåt skyddat innehåll + Resgrids personal har begärt åtkomst till det skyddade larmet {0} för supportärende {1}. Åtkomst kräver en oberoende godkännare och upphör inom 15 minuter. \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.uk.resx b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.uk.resx index d4ea62ffd..953fa62fa 100644 --- a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.uk.resx @@ -135,4 +135,22 @@ Ваш код підтвердження Resgrid: {0}. Діє {1} хв. + PIN захищених викликів + Виберіть особистий PIN із 6–12 цифр. Потрібна повторна перевірка в застосунку автентифікації. Ваш підрозділ має ввімкнути надання даних за PIN, щоб ви могли отримувати захищені відомості викликів. + Особистий PIN + Код автентифікатора + Зберегти + Збережено. + Не вдалося надати або зберегти захищені дані. Увійдіть у Resgrid і спробуйте ще раз. + Доступний захищений виклик. Надішліть OPEN {0}, пробіл і свій особистий PIN. + Доступний захищений виклик. Введіть свій особистий PIN і натисніть решітку. + Налаштування надання даних ADP + Доставка SMS + Голосова доставка + Загальне сповіщення + Надати після введення PIN + Дозволити тимчасовий доступ працівникам підтримки Resgrid за схваленням другого працівника. + Я підтверджую, що надані відомості залишають Resgrid і можуть зберігатися телефонними операторами або одержувачами. + Дозволити захищений вміст + Працівник Resgrid запросив доступ до захищеного виклику {0} для звернення до підтримки {1}. Доступ потребує незалежного схвалення та діє не більше 15 хвилин. \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.ar.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.ar.resx index 5fd1c01d6..1d16b38be 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.ar.resx @@ -695,6 +695,9 @@ تم إنشاء سير العمل من القالب مع مسودة إذن محمي. اضبط عنوان الوجهة وبيانات الاعتماد للخطوة، واختر الحقول المسموح بها، ثم اطلب الاعتماد. لن يُرسل أي شيء قبل اعتماد الإذن. + + تم إنشاء سير العمل من القالب، ولكن دون مسودة إذن محمي (يتطلب إنشاؤها مسؤولاً عن سير العمل المحمي). سير العمل معطّل: اطلب من مسؤول إنشاء الإذن وطلب الاعتماد قبل تفعيله. + تم بلوغ الحد الأقصى لسير العمل في خطتك. يُرجى الترقية لإضافة المزيد. diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.de.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.de.resx index 93ae7d271..4c680afcb 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.de.resx @@ -696,6 +696,9 @@ Workflow aus der Vorlage mit einem Entwurf einer geschützten Freigabe erstellt. Legen Sie Ziel-URL und Zugangsdaten des Schritts fest, wählen Sie die freigegebenen Felder und beantragen Sie die Genehmigung. Es wird nichts gesendet, bevor die Freigabe genehmigt ist. + + Workflow aus der Vorlage erstellt, jedoch ohne Entwurf einer geschützten Freigabe (dafür ist ein Administrator für geschützte Workflows erforderlich). Er ist deaktiviert: Lassen Sie einen Administrator die Freigabe erstellen und die Genehmigung beantragen, bevor Sie ihn aktivieren. + Das Workflow-Limit Ihres Tarifs ist erreicht. Bitte führen Sie ein Upgrade durch, um weitere Workflows hinzuzufügen. diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.el.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.el.resx index c27f2bd29..109e9c4a5 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.el.resx @@ -719,6 +719,9 @@ Η ροή εργασιών δημιουργήθηκε από το πρότυπο μαζί με πρόχειρη προστατευμένη έγκριση. Ορίστε το URL προορισμού και τα διαπιστευτήρια του βήματος, επιλέξτε τα πεδία προς κοινοποίηση και ζητήστε έγκριση. Δεν στέλνεται τίποτα πριν εγκριθεί. + + Η ροή εργασιών δημιουργήθηκε από το πρότυπο, αλλά χωρίς πρόχειρη προστατευμένη έγκριση (για τη δημιουργία της απαιτείται διαχειριστής προστατευμένων ροών εργασιών). Είναι απενεργοποιημένη: ζητήστε από έναν διαχειριστή να δημιουργήσει την έγκριση και να υποβάλει αίτημα έγκρισης πριν την ενεργοποιήσετε. + Συμπληρώθηκε το όριο ροών εργασιών του προγράμματός σας. Αναβαθμίστε για να προσθέσετε περισσότερες. diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.en.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.en.resx index dcb36ab68..91122225f 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.en.resx @@ -719,6 +719,9 @@ Workflow created from the template with a draft protected release. Set the destination URL and credential on the step, choose the released fields, then request approval. It never sends until the release is approved. + + Workflow created from the template, but without a draft protected release (creating one needs a Protected Workflows administrator). It is disabled: have an administrator create the release and request approval before you enable it. + Workflow limit reached for your plan. Please upgrade to add more workflows. diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.es.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.es.resx index f2ec38c9c..c95dc71dc 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.es.resx @@ -717,6 +717,9 @@ Flujo de trabajo creado a partir de la plantilla con una autorización protegida en borrador. Configure la URL de destino y la credencial del paso, elija los campos autorizados y solicite la aprobación. Nunca envía nada hasta que se apruebe la autorización. + + Flujo de trabajo creado a partir de la plantilla, pero sin una autorización protegida en borrador (crearla requiere un administrador de flujos de trabajo protegidos). Está desactivado: pida a un administrador que cree la autorización y solicite la aprobación antes de activarlo. + Se alcanzó el límite de flujos de trabajo de su plan. Actualícelo para añadir más flujos de trabajo. diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.fr.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.fr.resx index 4c8655ebb..c196bfd10 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.fr.resx @@ -696,6 +696,9 @@ Flux de travail créé à partir du modèle avec une autorisation protégée en brouillon. Définissez l'URL de destination et l'identifiant de l'étape, choisissez les champs autorisés, puis demandez l'approbation. Rien n'est envoyé avant l'approbation de l'autorisation. + + Flux de travail créé à partir du modèle, mais sans autorisation protégée en brouillon (sa création nécessite un administrateur des flux de travail protégés). Il est désactivé : demandez à un administrateur de créer l'autorisation et d'en demander l'approbation avant de l'activer. + La limite de flux de travail de votre forfait est atteinte. Passez à un forfait supérieur pour en ajouter. diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.it.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.it.resx index 63a34f156..ab280b445 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.it.resx @@ -696,6 +696,9 @@ Flusso di lavoro creato dal modello con un'autorizzazione protetta in bozza. Imposta l'URL di destinazione e la credenziale del passaggio, scegli i campi autorizzati e richiedi l'approvazione. Non invia nulla finché l'autorizzazione non è approvata. + + Flusso di lavoro creato dal modello, ma senza un'autorizzazione protetta in bozza (per crearla serve un amministratore dei flussi di lavoro protetti). È disattivato: chiedi a un amministratore di creare l'autorizzazione e di richiederne l'approvazione prima di attivarlo. + È stato raggiunto il limite di flussi di lavoro del tuo piano. Passa a un piano superiore per aggiungerne altri. diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.pl.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.pl.resx index 6f55efd3f..52b2a05f5 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.pl.resx @@ -696,6 +696,9 @@ Przepływ pracy utworzono z szablonu wraz z roboczym chronionym zezwoleniem. Ustaw docelowy adres URL i poświadczenie kroku, wybierz udostępniane pola i poproś o zatwierdzenie. Nic nie zostanie wysłane przed zatwierdzeniem zezwolenia. + + Przepływ pracy utworzono z szablonu, ale bez roboczego chronionego zezwolenia (do jego utworzenia potrzebny jest administrator chronionych przepływów pracy). Jest wyłączony: przed włączeniem poproś administratora o utworzenie zezwolenia i wysłanie prośby o zatwierdzenie. + Osiągnięto limit przepływów pracy w Twoim planie. Przejdź na wyższy plan, aby dodać więcej. diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.resx index a5176f38f..980365433 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.resx @@ -716,6 +716,9 @@ Workflow created from the template with a draft protected release. Set the destination URL and credential on the step, choose the released fields, then request approval. It never sends until the release is approved. + + Workflow created from the template, but without a draft protected release (creating one needs a Protected Workflows administrator). It is disabled: have an administrator create the release and request approval before you enable it. + Workflow limit reached for your plan. Please upgrade to add more workflows. diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.sv.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.sv.resx index b137a2ddf..8a487f5c6 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.sv.resx @@ -696,6 +696,9 @@ Arbetsflödet skapades från mallen med ett utkast till skyddad frisläppning. Ange mottagarens URL och autentiseringsuppgift för steget, välj de frisläppta fälten och begär sedan godkännande. Inget skickas förrän frisläppningen har godkänts. + + Arbetsflödet skapades från mallen, men utan ett utkast till skyddad frisläppning (det kräver en administratör för skyddade arbetsflöden). Det är inaktiverat: låt en administratör skapa frisläppningen och begära godkännande innan du aktiverar det. + Gränsen för arbetsflöden i din plan har nåtts. Uppgradera för att lägga till fler arbetsflöden. diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.uk.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.uk.resx index ebcc56d5d..7b99a9b69 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.uk.resx @@ -696,6 +696,9 @@ Робочий процес створено із шаблону разом із чернеткою захищеного дозволу. Укажіть URL призначення та облікові дані кроку, виберіть поля для розкриття та надішліть запит на затвердження. Нічого не надсилається, доки дозвіл не затверджено. + + Робочий процес створено із шаблону, але без чернетки захищеного дозволу (для її створення потрібен адміністратор захищених робочих процесів). Його вимкнено: перш ніж увімкнути, попросіть адміністратора створити дозвіл і надіслати запит на затвердження. + Досягнуто ліміту робочих процесів вашого плану. Оновіть план, щоб додати більше робочих процесів. diff --git a/Core/Resgrid.Model/AdminAssist/AdminAssistCatalog.cs b/Core/Resgrid.Model/AdminAssist/AdminAssistCatalog.cs new file mode 100644 index 000000000..296acc356 --- /dev/null +++ b/Core/Resgrid.Model/AdminAssist/AdminAssistCatalog.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Model.AdminAssist +{ + public sealed record CatalogLocation(string Controller, string Action, string Field = null) + { + public string Url => $"/User/{Uri.EscapeDataString(Controller)}/{Uri.EscapeDataString(Action)}" + + (string.IsNullOrWhiteSpace(Field) ? "" : "?aa=" + Uri.EscapeDataString(Field)); + } + public sealed record SettingImpact(string Risk, string AudienceKey, string OperationKey, + string TimingKey, string ReversibilityKey, string VerificationKey); + public sealed record SettingCatalogEntry(string Id, string AreaId, string LabelKey, string HelpKey, + string Binding, string ValueType, string Classification, bool Secret, CatalogLocation Location, + string Owner, DateTime ReviewedOn, SettingImpact Impact, IReadOnlyList Requires, + IReadOnlyList Affects, IReadOnlyList Conflicts, string DocumentationId, + string DefaultValue = null, string AllowedValues = null, string EvidenceSource = null, + string Availability = "available", string ReviewGap = null, DateTime? ReviewGapExpiresOn = null); + public sealed record CapabilityRequirement(string Kind, string Id); + public sealed record CapabilitySetupDefinition(string EvidenceId, decimal Minimum, IReadOnlyList RuleIds, string GuidanceKey); + public sealed record ProductCapability(string Id, string AreaId, string LabelKey, string PurposeKey, + string ValueKey, string ExampleKey, string AdoptionKey, string ReleaseStatus, + CatalogLocation Location, IReadOnlyList Requirements, + IReadOnlyList SettingIds, IReadOnlyList RuleIds, CapabilitySetupDefinition Setup = null); + public sealed record ProductArea(string Id, string LabelKey, string PurposeKey, int Order, + IReadOnlyList Archetypes); + public sealed record CapabilityAccess(string CapabilityId, EvidenceState State, + IReadOnlyList ReasonCodes, bool CanConfigure, string Destination, DateTime AsOfUtc, string SubscriptionDestination = null, + EvidenceState? CommercialState = null); + public sealed record OperatingPack(string Id, string LabelKey, string PurposeKey, + IReadOnlyList AreaIds, IReadOnlyList RuleIds, IReadOnlyList PrerequisiteKeys); + public sealed record KnowledgeArticle(string Id, string Locale, string TitleKey, string Body, + string SourcePath, string Anchor, string PackVersion, string Owner, DateTime ReviewedOn); + + public interface IAdminAssistCatalog + { + string Version { get; } + IReadOnlyList Settings { get; } + IReadOnlyList Areas { get; } + IReadOnlyList Capabilities { get; } + IReadOnlyList Packs { get; } + IReadOnlyList Rules { get; } + IReadOnlyList Articles { get; } + } + + public enum EvidenceComparison { IsTrue, IsFalse, Equal, NotEqual, Greater, GreaterOrEqual, Less, LessOrEqual } + public sealed record EvidenceCondition(string EvidenceId, EvidenceComparison Comparison, + decimal? Number = null, string Code = null); + /// Fixed AND predicates: applicability first, then failure. No executable catalog expressions. + public sealed record ConfigurationRuleDefinition(string Id, string AreaId, FindingSeverity Severity, + string TitleKey, string ExplanationKey, string NextActionKey, CatalogLocation Location, + IReadOnlyList AppliesWhen, IReadOnlyList FailsWhen); +} diff --git a/Core/Resgrid.Model/AdminAssist/AdminAssistContracts.cs b/Core/Resgrid.Model/AdminAssist/AdminAssistContracts.cs new file mode 100644 index 000000000..c5767181a --- /dev/null +++ b/Core/Resgrid.Model/AdminAssist/AdminAssistContracts.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.AdminAssist +{ + public interface IConfigurationSnapshotProvider + { + Task ReadAsync(AdminAssistActor actor, CancellationToken cancellationToken = default); + } + public sealed record AdminAssistSearchHit(string Id, string TitleKey, string Excerpt, string SourcePath, string Anchor, string PackVersion, string Locale); + public interface IAdminAssistReferenceSearch + { + IReadOnlyList Search(string query, string locale, int take = 20); + } + public interface IAdminAssistEvidenceSource + { + string SourceId { get; } + IReadOnlyList EvidenceIds { get; } + Task> ReadAsync(AdminAssistActor actor, DateTime asOfUtc, CancellationToken cancellationToken); + } + public interface IAdminAssistAccessService + { + Task CanAccessAsync(AdminAssistActor actor, bool setup, CancellationToken cancellationToken = default); + Task> GetCapabilitiesAsync(AdminAssistActor actor, CancellationToken cancellationToken = default); + Task GetCapabilityAsync(AdminAssistActor actor, string capabilityId, CancellationToken cancellationToken = default); + } + public interface IConfigurationRule + { + ConfigurationRuleDefinition Definition { get; } + ConfigurationFinding Evaluate(ConfigurationSnapshot snapshot, DateTime nowUtc, TimeSpan maximumAge); + } + public interface IAdminAssistService + { + Task GetOverviewAsync(AdminAssistActor actor, bool setup, CancellationToken cancellationToken = default); + Task UpdateSetupAsync(AdminAssistActor actor, SetupProgressCommand command, CancellationToken cancellationToken = default); + Task> GetHistoryAsync(AdminAssistActor actor, int skip, int take, CancellationToken cancellationToken = default); + } + public sealed record SetupWorkspace(int DepartmentId, long Revision, SetupMode Mode, + IReadOnlyDictionary Areas, IReadOnlyList LearnedCapabilityIds, + IReadOnlyList InterestedCapabilityIds, string CatalogVersion, DateTime? ReviewedOnUtc, bool SetupPromptDismissed = false, + IReadOnlyDictionary AreaReasons = null, SetupReviewEvidence ReviewEvidence = null, DateTime? RevisitOnUtc = null, long ScopeRevision = 0); + public sealed record SetupProgressCommand(long ExpectedRevision, string Operation, string TargetId = null, + string Choice = null, string CatalogVersion = null, string ReasonCode = null, string EvidenceRevision = null, DateTime? RevisitOnUtc = null) + { + [System.Text.Json.Serialization.JsonIgnore, Newtonsoft.Json.JsonIgnore] + public SetupReviewEvidence ReviewEvidence { get; init; } + } + public enum SetupAreaReason { OutsideMission, OtherSystem, PartnerManaged, NoCurrentNeed } + public sealed record SetupReviewEvidence(string CatalogVersion, string SnapshotRevision, DateTime AsOfUtc, int Required, int Verified, int Failed, int Unknown, long ScopeRevision = 0); + public sealed record AdminAssistOverview(string CatalogVersion, SetupWorkspace Workspace, + ConfigurationReport Report, IReadOnlyList Access, IReadOnlyList CapabilitySetup = null); + public enum CapabilitySetupState { NotAssessed, NotConfigured, ConfigurationPresent, ChecksPassed, NeedsAttention } + public sealed record CapabilitySetupAssessment(string CapabilityId, CapabilitySetupState State, string OpportunityKey, + string GuidanceKey, IReadOnlyList RuleIds, string SnapshotRevision, DateTime AsOfUtc); + public sealed record AdminAssistHistoryItem(string Id, string ActorId, DateTime OccurredOnUtc, + string Source, string Action, string SubjectId, string BeforeCode, string AfterCode, long Revision); + public interface IAdminAssistRepository + { + Task> GetFindingsAsync(int departmentId, CancellationToken cancellationToken); + Task SaveFindingAsync(AdminAssistFindingRow row, long expectedRevision, CancellationToken cancellationToken); + Task SaveDailySummaryAsync(int departmentId, ConfigurationReport report, string catalogVersion, CancellationToken cancellationToken); + Task LockConfigurationAsync(int departmentId, CancellationToken cancellationToken); + Task ValidateOperatingProfileReferencesAsync(int departmentId, DepartmentOperatingProfile profile, DateTime asOfUtc, CancellationToken cancellationToken); + Task AppendConfigurationChangeAsync(int departmentId, string actorId, string binding, string before, string after, string correlationId, CancellationToken cancellationToken); + Task GetConfigurationRevisionAsync(int departmentId, CancellationToken cancellationToken); + Task GetWorkspaceAsync(int departmentId, string userId, string catalogVersion, CancellationToken cancellationToken); + Task UpdateWorkspaceAsync(AdminAssistActor actor, SetupProgressCommand command, CancellationToken cancellationToken); + Task> GetHistoryAsync(int departmentId, string actorId, int skip, int take, CancellationToken cancellationToken); + } + public sealed class AdminAssistConcurrencyException : Exception + { + public AdminAssistConcurrencyException() : base("The setup workspace changed. Reload before saving.") { } + } +} diff --git a/Core/Resgrid.Model/AdminAssist/AdminAssistFindingRow.cs b/Core/Resgrid.Model/AdminAssist/AdminAssistFindingRow.cs new file mode 100644 index 000000000..2f7f0cd01 --- /dev/null +++ b/Core/Resgrid.Model/AdminAssist/AdminAssistFindingRow.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Resgrid.Model.AdminAssist +{ + [Table("AdminAssistFindings")] + public sealed class AdminAssistFindingRow : IEntity + { + [Key, DatabaseGenerated(DatabaseGeneratedOption.None)] public string AdminAssistFindingId { get; set; } + public int DepartmentId { get; set; } + public string RuleId { get; set; } + public string SubjectId { get; set; } + public int Episode { get; set; } + public int Result { get; set; } + public int Severity { get; set; } + public int ReviewStatus { get; set; } + public string OwnerId { get; set; } + public DateTime? ReviewOn { get; set; } + public DateTime? ExceptionUntil { get; set; } + public string Content { get; set; } + public bool IsProtected { get; set; } + public int ProtectedCatalogVersion { get; set; } + public string SnapshotRevision { get; set; } + public long Revision { get; set; } + public DateTime FirstObservedOn { get; set; } + public DateTime LastObservedOn { get; set; } + [NotMapped] public object IdValue { get => AdminAssistFindingId; set => AdminAssistFindingId = value?.ToString(); } + [NotMapped] public string TableName => "AdminAssistFindings"; + [NotMapped] public string IdName => nameof(AdminAssistFindingId); + [NotMapped] public int IdType => 1; + [NotMapped] public IEnumerable IgnoredProperties => new[] { nameof(IdValue), nameof(TableName), nameof(IdName), nameof(IdType) }; + } + public sealed record FindingReviewCommand(string FindingId, long ExpectedRevision, string Operation, + string OwnerId = null, DateTime? ReviewOnUtc = null, DateTime? ExceptionUntilUtc = null, string Note = null); + public interface IAdminAssistWorklistService + { + System.Threading.Tasks.Task> GetAsync(AdminAssistActor actor, System.Threading.CancellationToken cancellationToken = default); + System.Threading.Tasks.Task VerifyAsync(AdminAssistActor actor, System.Threading.CancellationToken cancellationToken = default); + System.Threading.Tasks.Task ReviewAsync(AdminAssistActor actor, FindingReviewCommand command, System.Threading.CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/AdminAssist/AdminAssistMaintenance.cs b/Core/Resgrid.Model/AdminAssist/AdminAssistMaintenance.cs new file mode 100644 index 000000000..369a8985c --- /dev/null +++ b/Core/Resgrid.Model/AdminAssist/AdminAssistMaintenance.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.AdminAssist +{ + public sealed class AdminAssistPreferences + { + public int DepartmentId { get; set; } + public string UserId { get; set; } + public bool DigestEnabled { get; set; } + public int QuietStartHour { get; set; } = 20; + public int QuietEndHour { get; set; } = 8; + public string Locale { get; set; } = "en"; + public long Revision { get; set; } + public DateTime? LastAttemptOn { get; set; } + public string LastAttemptWeek { get; set; } + public string LastAttemptOutcome { get; set; } + } + public sealed record AdminAssistPreferencesCommand(long ExpectedRevision, bool DigestEnabled, int QuietStartHour, int QuietEndHour); + public sealed record AdminAssistWorkerStatus(DateTime? LastEvaluatedOn, DateTime? LastDigestOn); + public interface IAdminAssistMaintenanceStore + { + Task> GetDueDepartmentsAsync(DateTime nowUtc, int take, CancellationToken ct); + Task TryLeaseAsync(int departmentId, string lease, DateTime nowUtc, CancellationToken ct); + Task CompleteLeaseAsync(int departmentId, string lease, DateTime? evaluatedOn, CancellationToken ct); + Task GetWorkerStatusAsync(int departmentId, CancellationToken ct); + Task GetPreferencesAsync(int departmentId, string userId, CancellationToken ct); + Task> GetDigestPreferencesAsync(int departmentId, CancellationToken ct); + Task AdvanceDigestCursorAsync(int departmentId, string userId, CancellationToken ct); + Task SavePreferencesAsync(AdminAssistActor actor, AdminAssistPreferencesCommand command, CancellationToken ct); + Task ClaimDigestAsync(AdminAssistPreferences preference, string week, DateTime nowUtc, CancellationToken ct); + Task CompleteDigestAsync(int departmentId, string userId, string week, string outcome, DateTime nowUtc, CancellationToken ct); + Task PurgeExpiredMetadataAsync(int departmentId, DateTime nowUtc, CancellationToken ct); + } + public interface IAdminAssistMaintenanceService + { + Task RunDepartmentAsync(int departmentId, CancellationToken ct); + } + public static class AdminAssistDigestSchedule + { + public static bool IsQuiet(DateTime utc, TimeZoneInfo zone, int start, int end) + { + if (utc.Kind != DateTimeKind.Utc || start is < 0 or > 23 || end is < 0 or > 23) throw new ArgumentException("Invalid digest schedule."); + var hour = TimeZoneInfo.ConvertTimeFromUtc(utc, zone).Hour; + return start == end ? false : start < end ? hour >= start && hour < end : hour >= start || hour < end; + } + public static string Week(DateTime utc, TimeZoneInfo zone) + { + if (utc.Kind != DateTimeKind.Utc) throw new ArgumentException("UTC required."); + var local = TimeZoneInfo.ConvertTimeFromUtc(utc, zone).Date; + return local.AddDays(-(((int)local.DayOfWeek + 6) % 7)).ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture); + } + } +} diff --git a/Core/Resgrid.Model/AdminAssist/AdminAssistWorkflowPayload.cs b/Core/Resgrid.Model/AdminAssist/AdminAssistWorkflowPayload.cs new file mode 100644 index 000000000..2e0a5035f --- /dev/null +++ b/Core/Resgrid.Model/AdminAssist/AdminAssistWorkflowPayload.cs @@ -0,0 +1,33 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Resgrid.Model.Services; + +namespace Resgrid.Model.AdminAssist +{ + /// Public rule codes and scalar lifecycle metadata only; no notes, names, source evidence, owners or grants. + public static class AdminAssistWorkflowPayload + { + public static readonly int[] Triggers = { 189, 190, 191 }; + public static readonly (string Variable, string Property)[] Variables = { ("id", "FindingId"), ("rule_id", "RuleId"), ("episode", "Episode"), ("result", "Result"), ("severity", "Severity"), ("review_status", "ReviewStatus") }; + public static readonly string[] RuleIds = { "shift-auto-without-dispatch", "shift-coverage", "text-sources", "command-sources", "admin-mfa", "admin-enrollment", "admin-succession", "empty-groups", "unit-type", "unit-group", "station-address", "person-location-age", "unit-location-age", "map-token", "map-style", "personnel-limit", "unit-limit", "run-cards", "checkin-timers", "weather-zones", "communication-tests", "qualified-coverage", "credential-expiry", "checklist-overdue", "equipment-holds", "stock-expiry", "workflow-failures", "import-heartbeat", "email-import-failures", "policy-references", "policy-expiry", "site-references", "continuity-reference", "record-review", "password-recovery", "shift-open-slots", "shift-overlaps", "shift-trades" }; + public static string Routing(JObject payload) + { + var safe = new JObject(); + if (payload["FindingId"]?.Type == JTokenType.String && Guid.TryParseExact(payload["FindingId"].Value(), "D", out var id)) safe["FindingId"] = id.ToString("D"); + if (payload["RuleId"]?.Type == JTokenType.String && RuleIds.Contains(payload["RuleId"].Value(), StringComparer.Ordinal)) safe["RuleId"] = payload["RuleId"].DeepClone(); + foreach (var (name, max) in new[] { ("Episode", int.MaxValue), ("Result", 3), ("Severity", 2), ("ReviewStatus", 4) }) + if (payload[name]?.Type == JTokenType.Integer && payload[name].Value() >= 0 && payload[name].Value() <= max) safe[name] = payload[name].DeepClone(); + return safe.ToString(Formatting.None); + } + public static async Task ProjectAsync(int departmentId, JObject payload, IProtectedProjectionService protection, bool wrapped) + { + var projected = await protection.BuildSafeWorkflowPayloadAsync(departmentId, JObject.Parse(Routing(payload))) + ?? throw new InvalidOperationException("Admin Assist workflow projection unavailable."); + var safe = JObject.Parse(Routing(JObject.Parse(projected))); + return (wrapped ? new JObject { ["Payload"] = safe } : safe).ToString(Formatting.None); + } + } +} diff --git a/Core/Resgrid.Model/AdminAssist/AdminAssistWorkspaceRow.cs b/Core/Resgrid.Model/AdminAssist/AdminAssistWorkspaceRow.cs new file mode 100644 index 000000000..1e9b72251 --- /dev/null +++ b/Core/Resgrid.Model/AdminAssist/AdminAssistWorkspaceRow.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model.AdminAssist +{ + [Table("AdminAssistWorkspaces")] + public sealed class AdminAssistWorkspaceRow : IEntity + { + [Key, DatabaseGenerated(DatabaseGeneratedOption.None)] + public int DepartmentId { get; set; } + public long Revision { get; set; } + public int Mode { get; set; } + /// Versioned scope/reason codes, review evidence references and revisit date; never notes, configuration or secrets. + public string AreasJson { get; set; } + public string CatalogVersion { get; set; } + public DateTime? ReviewedOn { get; set; } + public DateTime ModifiedOn { get; set; } + [NotMapped, JsonIgnore] public object IdValue { get => DepartmentId; set => DepartmentId = Convert.ToInt32(value); } + [NotMapped] public string TableName => "AdminAssistWorkspaces"; + [NotMapped] public string IdName => "DepartmentId"; + [NotMapped] public int IdType => 0; + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } +} diff --git a/Core/Resgrid.Model/AdminAssist/AdministrativeReferences.cs b/Core/Resgrid.Model/AdminAssist/AdministrativeReferences.cs new file mode 100644 index 000000000..9c5ddee1b --- /dev/null +++ b/Core/Resgrid.Model/AdminAssist/AdministrativeReferences.cs @@ -0,0 +1,12 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.AdminAssist +{ + public sealed record AdministrativeReferenceCounts(int PolicyReferences, int UnavailablePolicies, int ExpiringPolicies, int SiteReferences, int UnavailableSites); + public interface IAdministrativeReferenceStore + { + Task ReadAdministrativeReferencesAsync(int departmentId, int[] documentIds, int[] groupIds, DateTime asOfUtc, CancellationToken cancellationToken); + } +} diff --git a/Core/Resgrid.Model/AdminAssist/ConfigurationChangeAudit.cs b/Core/Resgrid.Model/AdminAssist/ConfigurationChangeAudit.cs new file mode 100644 index 000000000..cfbf7b781 --- /dev/null +++ b/Core/Resgrid.Model/AdminAssist/ConfigurationChangeAudit.cs @@ -0,0 +1,14 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.AdminAssist +{ + /// Fingerprint is request-local and must never be persisted. Values contains reviewed scalar/presence markers only. + public sealed record ConfigurationChangeStamp(string Fingerprint, string Values); + public interface IConfigurationChangeJournal + { + Task ExecuteAsync(int departmentId, string binding, Func> read, + Func> write, CancellationToken cancellationToken); + } +} diff --git a/Core/Resgrid.Model/AdminAssist/ConfigurationEvidence.cs b/Core/Resgrid.Model/AdminAssist/ConfigurationEvidence.cs new file mode 100644 index 000000000..142d7d0b0 --- /dev/null +++ b/Core/Resgrid.Model/AdminAssist/ConfigurationEvidence.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Resgrid.Model.AdminAssist +{ + public enum EvidenceState { Known, Unknown, Unavailable, Redacted, NotApplicable } + public enum FindingSeverity { Information, Warning, Critical } + public enum RuleResult { Pass, Fail, Unknown, NotApplicable } + public enum FindingReviewStatus { Unassigned, Assigned, InReview, AcceptedException, Resolved } + public enum SetupAreaChoice { UseNow, LearnLater, NotApplicable } + public enum SetupMode { Fresh, Review, Import } + + /// Only allowlisted scalar metadata enters the engine; no raw configuration, names or secrets. + public sealed record ConfigurationEvidence( + string Id, EvidenceState State, string Source, string Version, DateTime AsOfUtc, + bool? Boolean = null, decimal? Number = null, string Code = null, string ReasonCode = null) + { + public bool IsFresh(DateTime now, TimeSpan lifetime) => State == EvidenceState.Known && + AsOfUtc <= now && now - AsOfUtc <= lifetime; + } + + public sealed record ConfigurationSnapshot( + int DepartmentId, string ActorId, string Revision, DateTime AsOfUtc, bool Consistent, + IReadOnlyDictionary Evidence) + { + public ConfigurationEvidence Find(string id) => Evidence.TryGetValue(id, out var value) ? value : + new ConfigurationEvidence(id, EvidenceState.Unknown, "not-observed", Revision, AsOfUtc, + ReasonCode: "EvidenceNotObserved"); + } + + public sealed record ConfigurationFinding( + string RuleId, string AreaId, FindingSeverity Severity, RuleResult Result, + string TitleKey, string ExplanationKey, string NextActionKey, string Destination, + IReadOnlyList EvidenceIds, string SnapshotRevision, DateTime EvaluatedOnUtc, + string ReasonCode = null, bool ScopeIndependent = false); + + public sealed record ConfigurationReport(ConfigurationSnapshot Snapshot, + IReadOnlyList Findings, IReadOnlyList SelectedAreas) + { + public int Verified => Applicable.Count(f => f.Result == RuleResult.Pass); + public int Required => Applicable.Count(); + public int Failed => Applicable.Count(f => f.Result == RuleResult.Fail); + public int Unknown => Applicable.Count(f => f.Result == RuleResult.Unknown); + public IReadOnlyList UncheckedAreaIds => SelectedAreas.Where(area => !Findings.Any(f => f.AreaId == area)).Distinct().ToArray(); + public bool HasCriticalUncertainty => !Snapshot.Consistent || Applicable.Any(f => + f.Severity == FindingSeverity.Critical && f.Result == RuleResult.Unknown); + private IEnumerable Applicable => Findings.Where(f => + f.Result != RuleResult.NotApplicable && (f.AreaId == "security" || f.ScopeIndependent || SelectedAreas.Contains(f.AreaId))); + } + + public sealed record AdminAssistActor(int DepartmentId, string UserId, string Locale = "en"); +} diff --git a/Core/Resgrid.Model/AdminAssist/ConfigurationImpact.cs b/Core/Resgrid.Model/AdminAssist/ConfigurationImpact.cs new file mode 100644 index 000000000..f966f0311 --- /dev/null +++ b/Core/Resgrid.Model/AdminAssist/ConfigurationImpact.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.AdminAssist +{ + /// Only proposed scalar values are accepted from the caller. All current evidence is read by the server. + public sealed record ConfigurationImpactRequest(string SettingId, string ExpectedRevision, bool? Boolean = null, decimal? Number = null); + public sealed record CapacityImpactRequest(string ExpectedRevision, int ProposedPersonnelCount, int ProposedUnitCount); + public sealed record ConfigurationImpactMetric(string LabelKey, EvidenceState State, decimal? Before, decimal? After, string ReasonCode = null); + public sealed record ConfigurationImpactRule(string RuleId, string TitleKey, RuleResult Before, RuleResult After); + public sealed record ConfigurationImpactReport(string SettingId, string SnapshotRevision, DateTime AsOfUtc, + string EvaluatorVersion, SettingImpact Profile, IReadOnlyList Metrics, + IReadOnlyList RuleChanges, IReadOnlyList LimitKeys, string Destination); + public interface IConfigurationImpactService + { + Task PreviewAsync(AdminAssistActor actor, ConfigurationImpactRequest request, CancellationToken cancellationToken = default); + Task PreviewCapacityAsync(AdminAssistActor actor, CapacityImpactRequest request, CancellationToken cancellationToken = default); + } + public sealed record OperationalImpact(IReadOnlyList Metrics, IReadOnlyList LimitKeys, string Version); + public interface IOperationalImpactProvider + { + bool Supports(string settingId); + Task EvaluateAsync(AdminAssistActor actor, ConfigurationSnapshot snapshot, ConfigurationImpactRequest request, CancellationToken cancellationToken); + } +} diff --git a/Core/Resgrid.Model/AdminAssist/DepartmentOperatingProfile.cs b/Core/Resgrid.Model/AdminAssist/DepartmentOperatingProfile.cs new file mode 100644 index 000000000..51a65d645 --- /dev/null +++ b/Core/Resgrid.Model/AdminAssist/DepartmentOperatingProfile.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using ProtoBuf; + +namespace Resgrid.Model.AdminAssist +{ + /// Declared administrative context. Codes and references only; never clinical scope inferred from a role. + [ProtoContract] + public sealed class DepartmentOperatingProfile : IValidatableObject + { + public static readonly string[] ArchetypeCodes = { "fire", "ems", "mental-health", "sar", "emergency-response", "hazmat", "industrial", "security", "mutual-aid" }; + public static readonly string[] WorkforceCodes = { "unknown", "career", "volunteer", "contract", "combination" }; + public static readonly string[] DispatchCodes = { "unknown", "central", "self", "external", "combination" }; + public static readonly string[] HoursCodes = { "unknown", "continuous", "scheduled", "on-call", "seasonal" }; + [ProtoMember(1)] public List Archetypes { get; set; } = new(); + [ProtoMember(2)] public string WorkforceMix { get; set; } = "unknown"; + [ProtoMember(3)] public int? DeclaredMemberCount { get; set; } + [ProtoMember(4)] public List AuthoritativeSystemReferences { get; set; } = new(); + [ProtoMember(5)] public string DispatchModel { get; set; } = "unknown"; + [ProtoMember(6)] public string OperatingHours { get; set; } = "unknown"; + [ProtoMember(7)] public List SiteGroupReferences { get; set; } = new(); + [ProtoMember(8)] public bool? MutualAid { get; set; } + [ProtoMember(9)] public List LanguageCodes { get; set; } = new(); + [ProtoMember(10)] public List AccessibilityNeeds { get; set; } = new(); + [ProtoMember(11)] public List StaffingPolicyReferences { get; set; } = new(); + [ProtoMember(12)] public List QualificationPolicyReferences { get; set; } = new(); + [ProtoMember(13)] public List ContinuityProcedureReferences { get; set; } = new(); + [ProtoMember(14)] public string SeasonStartMonthDay { get; set; } + [ProtoMember(15)] public string SeasonEndMonthDay { get; set; } + [ProtoMember(16)] public DateTime? ReviewedOnUtc { get; set; } + [ProtoMember(17)] public long Revision { get; set; } + [ProtoMember(18)] public int? ExpectedEmailPollIntervalMinutes { get; set; } + + public IEnumerable Validate(ValidationContext validationContext) + { + if (Revision < 0 || Revision == long.MaxValue) yield return new ValidationResult("Invalid profile revision.", new[] { nameof(Revision) }); + if (Archetypes == null || Archetypes.Count > ArchetypeCodes.Length || Archetypes.Distinct().Count() != Archetypes.Count || Archetypes.Any(a => !ArchetypeCodes.Contains(a))) + yield return new ValidationResult("Invalid operating archetype.", new[] { nameof(Archetypes) }); + if (!WorkforceCodes.Contains(WorkforceMix) || !DispatchCodes.Contains(DispatchModel) || !HoursCodes.Contains(OperatingHours)) + yield return new ValidationResult("Invalid operating context."); + if (DeclaredMemberCount < 0 || DeclaredMemberCount > 1000000) yield return new ValidationResult("Member count is outside the supported range.", new[] { nameof(DeclaredMemberCount) }); + if (ExpectedEmailPollIntervalMinutes is < 1 or > 10080) yield return new ValidationResult("Expected email polling interval must be between 1 and 10080 minutes, or blank.", new[] { nameof(ExpectedEmailPollIntervalMinutes) }); + var references = new[] { AuthoritativeSystemReferences, SiteGroupReferences, StaffingPolicyReferences, QualificationPolicyReferences, ContinuityProcedureReferences }; + if (references.Any(list => list == null || list.Count > 25 || list.Any(value => string.IsNullOrWhiteSpace(value) || value.Length > 128 || value.Any(c => !(char.IsAsciiLetterOrDigit(c) || c == '-' || c == '_' || c == '.'))))) + yield return new ValidationResult("Use at most 25 existing document, group or system reference identifiers per field."); + if (LanguageCodes == null || LanguageCodes.Count > 20 || LanguageCodes.Any(value => !Resgrid.Model.Helpers.AdminAssistLanguageCodes.IsValid(value))) + yield return new ValidationResult("Use supported language codes.", new[] { nameof(LanguageCodes) }); + if (AccessibilityNeeds == null || AccessibilityNeeds.Count > 4 || AccessibilityNeeds.Any(value => !new[] { "captions", "screen-reader", "large-text", "plain-language" }.Contains(value))) + yield return new ValidationResult("Invalid accessibility preference.", new[] { nameof(AccessibilityNeeds) }); + if ((SeasonStartMonthDay != null || SeasonEndMonthDay != null) && (!ValidDay(SeasonStartMonthDay) || !ValidDay(SeasonEndMonthDay))) + yield return new ValidationResult("Season dates must both use MM-DD."); + } + private static bool ValidDay(string value) => DateTime.TryParseExact("2000-" + value, "yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out _); + } +} + +namespace Resgrid.Model.Helpers +{ + public static class AdminAssistLanguageCodes + { + public static bool IsValid(string code) => new[] { "ar", "de", "el", "en", "es", "fr", "it", "pl", "sv", "uk" }.Contains(code); + } +} diff --git a/Core/Resgrid.Model/AdminAssist/DispatchImpact.cs b/Core/Resgrid.Model/AdminAssist/DispatchImpact.cs new file mode 100644 index 000000000..09c770e3b --- /dev/null +++ b/Core/Resgrid.Model/AdminAssist/DispatchImpact.cs @@ -0,0 +1,14 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.AdminAssist +{ + /// A saved call supplies route identifiers, never historical recipient evidence. + public sealed record DispatchImpactRequest(string ExpectedRevision, int CallId, DateTime SimulationTimeUtc, + bool ShiftInsteadOfGroup, bool UnitCrew, bool UnitGroup); + public interface IDispatchImpactService + { + Task PreviewAsync(AdminAssistActor actor, DispatchImpactRequest request, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/AdminAssist/DispatchProviderOutcome.cs b/Core/Resgrid.Model/AdminAssist/DispatchProviderOutcome.cs new file mode 100644 index 000000000..567703bd9 --- /dev/null +++ b/Core/Resgrid.Model/AdminAssist/DispatchProviderOutcome.cs @@ -0,0 +1,14 @@ +namespace Resgrid.Model.AdminAssist +{ + public static class DispatchProviderOutcome + { + /// Normalize only documented creation-response states. Later receipt stages are not inferred. + public static bool? CreationStatus(string status) => status?.ToLowerInvariant() switch + { + "accepted" or "queued" or "sending" or "sent" or "delivered" or "read" or "scheduled" or + "ringing" or "in-progress" or "completed" => true, + "failed" or "undelivered" or "canceled" or "busy" or "no-answer" => false, + _ => null + }; + } +} diff --git a/Core/Resgrid.Model/AdminAssist/DispatchRecipientResolver.cs b/Core/Resgrid.Model/AdminAssist/DispatchRecipientResolver.cs new file mode 100644 index 000000000..cb3636ae3 --- /dev/null +++ b/Core/Resgrid.Model/AdminAssist/DispatchRecipientResolver.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Resgrid.Model.AdminAssist +{ + public enum DispatchRouteKind { Direct, Group, Shift, UnitCrew, UnitGroup, Role } + public sealed record DispatchRoute(DispatchRouteKind Kind, string SourceId, IReadOnlyList Members, + bool UseResolvedShift = false, IReadOnlyList OnDutyMembers = null); + public sealed record DispatchSelection(string UserId, DispatchRouteKind Kind, string SourceId, bool Selected, bool EmptyShiftFallback); + public sealed record DispatchResolution(DateTime AsOfUtc, IReadOnlyList Decisions, + IReadOnlyList SelectedUserIds); + + /// + /// Pure routing, used incrementally by the broadcaster and over isolated snapshots by previews. + /// Channel eligibility remains the communication service's responsibility. Inputs must already be + /// department scoped and filtered by the call's dispatch-scope decision. No I/O, wall clock or mutation. + /// + public static class DispatchRecipientResolver + { + public const string Version = "1"; + public static DispatchResolution Resolve(DateTime asOfUtc, IEnumerable routes, + IEnumerable previouslySelected = null) + { + if (asOfUtc.Kind != DateTimeKind.Utc) throw new ArgumentException("An explicit UTC simulation time is required.", nameof(asOfUtc)); + var prior = previouslySelected as IReadOnlySet ?? new HashSet(previouslySelected ?? Array.Empty(), StringComparer.Ordinal); + var seen = new HashSet(StringComparer.Ordinal); + var decisions = new List(); + var selected = new List(); + foreach (var route in routes) + { + if (route.Members == null || route.UseResolvedShift && route.OnDutyMembers == null) + throw new ArgumentException("Missing routing evidence must not be treated as an empty roster."); + var shift = route.Kind == DispatchRouteKind.Group && route.UseResolvedShift && route.OnDutyMembers.Count > 0; + var fallback = route.Kind == DispatchRouteKind.Group && route.UseResolvedShift && !shift; + foreach (var userId in shift ? route.OnDutyMembers : route.Members) + { + // Direct dispatch rows historically remain separate attempts, including duplicates. Expanded + // routes deduplicate against every earlier route even when that earlier send failed. + var added = !prior.Contains(userId) && seen.Add(userId); + var include = route.Kind == DispatchRouteKind.Direct || added; + decisions.Add(new DispatchSelection(userId, shift ? DispatchRouteKind.Shift : route.Kind, route.SourceId, include, fallback)); + if (include) selected.Add(userId); + } + } + return new DispatchResolution(asOfUtc, decisions.AsReadOnly(), selected.AsReadOnly()); + } + } +} diff --git a/Core/Resgrid.Model/AdminAssist/DispatchTraceEnvelope.cs b/Core/Resgrid.Model/AdminAssist/DispatchTraceEnvelope.cs new file mode 100644 index 000000000..401b765eb --- /dev/null +++ b/Core/Resgrid.Model/AdminAssist/DispatchTraceEnvelope.cs @@ -0,0 +1,39 @@ +using System; +using System.Text.Json; + +namespace Resgrid.Model.AdminAssist +{ + /// Versioned, bounded queue contract. No arbitrary provider payload or exception text is accepted. + public static class DispatchTraceEnvelope + { + public const int MaximumBytes = 32768; + public const string ContentType = "application/vnd.resgrid.admin-assist-trace.v1+json"; + public static void Validate(AdminAssistDispatchTraceRow row) + { + if (row == null || !Guid.TryParseExact(row.AdminAssistDispatchTraceId, "D", out _) || + !Guid.TryParseExact(row.AttemptId, "D", out _) || row.DepartmentId <= 0 || row.CallId <= 0 || + !Enum.TryParse(row.Stage, out var stage) || !Enum.IsDefined(stage) || + string.IsNullOrWhiteSpace(row.ResolverVersion) || row.ResolverVersion.Length > 64 || + row.OccurredOn == default || row.OccurredOn > DateTime.UtcNow.AddMinutes(5) || + string.IsNullOrWhiteSpace(row.Content) || row.Content.Length > MaximumBytes) + throw new ArgumentException("Invalid dispatch trace envelope."); + if (row.IsProtected) + { + if (row.ProtectedCatalogVersion < 30 || !ProtectedDataEnvelope.IsEnveloped(row.Content)) + throw new ArgumentException("Invalid protected dispatch trace envelope."); + return; + } + var observation = JsonSerializer.Deserialize(row.Content) ?? throw new ArgumentException("Missing dispatch trace observation."); + if (row.ProtectedCatalogVersion != 0 || observation.Id != row.AdminAssistDispatchTraceId || + observation.DepartmentId != row.DepartmentId || observation.CallId != row.CallId || + observation.AttemptId != row.AttemptId || observation.Stage != stage || observation.OccurredOnUtc != row.OccurredOn || + !Enum.IsDefined(observation.Channel) || !Enum.IsDefined(observation.Reason) || + !Enum.IsDefined(observation.Provider) || observation.ProviderMessageId != null && !DispatchTraceTelemetry.IsProviderMessageId(observation.Provider, observation.ProviderMessageId) || + observation.LogicalMessageId != null && !Guid.TryParseExact(observation.LogicalMessageId, "D", out _) || + observation.RouteKind.HasValue && !Enum.IsDefined(observation.RouteKind.Value) || + observation.RecipientId?.Length > 128 || observation.SourceId?.Length > 128 || + observation.Sequence < 0 || observation.PriorDropped < 0 || observation.ResolverVersion != null && observation.ResolverVersion != row.ResolverVersion) + throw new ArgumentException("Mismatched dispatch trace observation."); + } + } +} diff --git a/Core/Resgrid.Model/AdminAssist/DispatchTraceTelemetry.cs b/Core/Resgrid.Model/AdminAssist/DispatchTraceTelemetry.cs new file mode 100644 index 000000000..5cf73cadb --- /dev/null +++ b/Core/Resgrid.Model/AdminAssist/DispatchTraceTelemetry.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; + +namespace Resgrid.Model.AdminAssist +{ + public enum DispatchTraceStage { Selected, Excluded, Attempted, ServiceCompleted, ServiceDeclined, Failed, Skipped, BroadcastStarted, BroadcastCompleted, ProviderAccepted, ProviderDeclined, ProviderResultUnknown } + public enum DispatchTraceChannel { Routing, Push, Sms, Email, Voice, UnitPush } + public enum DispatchTraceProvider { None, Twilio, SignalWire, Postmark } + public enum DispatchTraceReason { None, DuplicateRoute, EmptyShiftFallback, ProfileMissing, BroadcastDisabled, MemberIneligible, ChannelDisabled, ContactUnverified, SendException } + public sealed record DispatchTraceObservation(string Id, int DepartmentId, int CallId, int? QueueItemId, + string AttemptId, DateTime OccurredOnUtc, DispatchTraceStage Stage, DispatchTraceChannel Channel, + DispatchTraceReason Reason, string RecipientId, DispatchRouteKind? RouteKind, string SourceId, DateTime? InputAsOfUtc, + int Sequence = 0, int PriorDropped = 0, string ResolverVersion = null, + string LogicalMessageId = null, DispatchTraceProvider Provider = DispatchTraceProvider.None, string ProviderMessageId = null); + + /// Bounded, non-blocking capture. Nothing here contacts a database, broker, logger or model. + public static class DispatchTraceTelemetry + { + private sealed class Context { public int DepartmentId; public int CallId; public int? QueueItemId; public string AttemptId; public int Count; public int Dropped; } + private static readonly AsyncLocal Current = new(); + private sealed record ChannelContext(string LogicalMessageId, string RecipientId); + private static readonly AsyncLocal Sending = new(); + private static readonly Channel Queue = Channel.CreateBounded(new BoundedChannelOptions(2048) { FullMode = BoundedChannelFullMode.Wait, SingleReader = true, AllowSynchronousContinuations = false }); + private static long _dropped; + public static long Dropped => Interlocked.Read(ref _dropped); + public static ChannelReader Reader => Queue.Reader; + public static IDisposable Begin(int departmentId, int callId, int? queueItemId, bool enabled) + { + var previous = Current.Value; + Current.Value = enabled ? new Context { DepartmentId = departmentId, CallId = callId, QueueItemId = queueItemId, AttemptId = Guid.NewGuid().ToString("D") } : null; + return new Scope(() => Current.Value = previous); + } + public static void Observe(DispatchTraceStage stage, DispatchTraceChannel channel = DispatchTraceChannel.Routing, + DispatchTraceReason reason = DispatchTraceReason.None, string recipientId = null, DispatchRouteKind? routeKind = null, string sourceId = null, DateTime? inputAsOfUtc = null, + DispatchTraceProvider provider = DispatchTraceProvider.None, string providerMessageId = null) + { + var context = Current.Value; + if (context == null) return; + // IDs only, never provider bodies, phone numbers, addresses, exception messages or call text. + var sequence = Interlocked.Increment(ref context.Count); + if (sequence > 10000 && stage != DispatchTraceStage.BroadcastCompleted || recipientId?.Length > 128 || sourceId?.Length > 128) + { + Interlocked.Increment(ref context.Dropped); Interlocked.Increment(ref _dropped); return; + } + var observation = new DispatchTraceObservation(Guid.NewGuid().ToString("D"), context.DepartmentId, context.CallId, context.QueueItemId, + context.AttemptId, DateTime.UtcNow, stage, channel, reason, recipientId, routeKind, sourceId, inputAsOfUtc, + sequence, Volatile.Read(ref context.Dropped), DispatchRecipientResolver.Version, Sending.Value?.LogicalMessageId, provider, providerMessageId); + if (!Queue.Writer.TryWrite(observation)) { Interlocked.Increment(ref context.Dropped); Interlocked.Increment(ref _dropped); } + } + public static async Task AttemptAsync(DispatchTraceChannel channel, string recipientId, Func> send) + { + var previous = Sending.Value; + Sending.Value = Current.Value == null ? null : new ChannelContext(Guid.NewGuid().ToString("D"), recipientId); + Observe(DispatchTraceStage.Attempted, channel, recipientId: recipientId); + try + { + var result = await send(); + // The existing service's boolean is not a provider receipt or proof of delivery. + Observe(result ? DispatchTraceStage.ServiceCompleted : DispatchTraceStage.ServiceDeclined, channel, recipientId: recipientId); + return result; + } + catch { Observe(DispatchTraceStage.Failed, channel, DispatchTraceReason.SendException, recipientId); throw; } + finally { Sending.Value = previous; } + } + /// Creation response only, never a delivery receipt. Called at the actual provider boundary. + public static void ProviderResult(DispatchTraceProvider provider, DispatchTraceChannel transport, string messageId, bool? accepted) + { + var sending = Sending.Value; + if (Current.Value == null || sending == null || provider == DispatchTraceProvider.None || !Enum.IsDefined(provider)) return; + if (!IsProviderMessageId(provider, messageId)) { messageId = null; if (accepted == true) accepted = null; } + Observe(accepted == true ? DispatchTraceStage.ProviderAccepted : accepted == false ? DispatchTraceStage.ProviderDeclined : DispatchTraceStage.ProviderResultUnknown, + transport, recipientId: sending.RecipientId, provider: provider, providerMessageId: messageId); + } + public static bool IsProviderMessageId(DispatchTraceProvider provider, string id) + { + if (string.IsNullOrWhiteSpace(id) || id.Length > 64) return false; + if (provider is DispatchTraceProvider.Postmark or DispatchTraceProvider.SignalWire && Guid.TryParseExact(id, "D", out var guid) && guid != Guid.Empty) return true; + if (provider is DispatchTraceProvider.Twilio or DispatchTraceProvider.SignalWire && id.Length == 34 && (id.StartsWith("SM", StringComparison.Ordinal) || id.StartsWith("MM", StringComparison.Ordinal) || id.StartsWith("CA", StringComparison.Ordinal))) + { + for (var i = 2; i < id.Length; i++) if (!Uri.IsHexDigit(id[i])) return false; + return true; + } + return false; + } + private sealed class Scope(Action dispose) : IDisposable { public void Dispose() => dispose(); } + } + [Table("AdminAssistDispatchTraces")] + public sealed class AdminAssistDispatchTraceRow : IEntity + { + [Key, DatabaseGenerated(DatabaseGeneratedOption.None)] public string AdminAssistDispatchTraceId { get; set; } + public int DepartmentId { get; set; } + public int CallId { get; set; } + public string AttemptId { get; set; } + public string Stage { get; set; } + public string ResolverVersion { get; set; } + public DateTime OccurredOn { get; set; } + public string Content { get; set; } + public bool IsProtected { get; set; } + public int ProtectedCatalogVersion { get; set; } + [NotMapped] public object IdValue { get => AdminAssistDispatchTraceId; set => AdminAssistDispatchTraceId = value?.ToString(); } + [NotMapped] public string TableName => "AdminAssistDispatchTraces"; + [NotMapped] public string IdName => nameof(AdminAssistDispatchTraceId); + [NotMapped] public int IdType => 1; + [NotMapped] public IEnumerable IgnoredProperties => new[] { nameof(IdValue), nameof(TableName), nameof(IdName), nameof(IdType) }; + } + public interface IAdminAssistTraceWriter + { + /// Returns null when new trace capture is disabled for this host or department. + Task PrepareAsync(DispatchTraceObservation observation, CancellationToken ct); + Task PersistPreparedAsync(AdminAssistDispatchTraceRow row, CancellationToken ct); + Task PersistAsync(DispatchTraceObservation observation, CancellationToken ct); + } + public interface IAdminAssistTraceStore + { + Task TraceDepartmentExistsAsync(int departmentId, CancellationToken ct); + Task SaveTraceAsync(AdminAssistDispatchTraceRow row, CancellationToken ct); + } + public enum DispatchTraceReceiveResult { Empty, Persisted } + /// Only prepared envelopes cross the durable queue. The sender never awaits this interface. + public interface IAdminAssistTraceQueue + { + Task EnqueueAsync(AdminAssistDispatchTraceRow row, CancellationToken ct); + Task ProcessNextAsync(Func persist, CancellationToken ct); + } +} diff --git a/Core/Resgrid.Model/AdminAssist/ModuleImpact.cs b/Core/Resgrid.Model/AdminAssist/ModuleImpact.cs new file mode 100644 index 000000000..b9245c801 --- /dev/null +++ b/Core/Resgrid.Model/AdminAssist/ModuleImpact.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.AdminAssist +{ + public sealed record ModuleImpactRequest(string ExpectedRevision, string Module, bool Disabled); + public sealed record ModuleImpactCounts(int Members, int? ContentRows); + public interface IModuleImpactStore + { + Task ReadModuleImpactCountsAsync(int departmentId, string module, int bound, CancellationToken cancellationToken); + } + public interface IModuleImpactService + { + Task PreviewAsync(AdminAssistActor actor, ModuleImpactRequest request, CancellationToken cancellationToken = default); + } + /// Legacy menu switches without an additional per-member claim gate in _Navigation or its mailbox entry. + public static class ModuleImpactSelection + { + public static IReadOnlyList Supported { get; } = Array.AsReadOnly(new[] { + "Messaging", "Mapping", "Shifts", "Logs", "Reports", "Documents", "Calendar", "Notes", "Training", "Inventory" }); + public static bool Disabled(DepartmentModuleSettings value, string module) => module switch { + "Messaging" => value.MessagingDisabled, "Mapping" => value.MappingDisabled, "Shifts" => value.ShiftsDisabled, + "Logs" => value.LogsDisabled, "Reports" => value.ReportsDisabled, "Documents" => value.DocumentsDisabled, + "Calendar" => value.CalendarDisabled, "Notes" => value.NotesDisabled, "Training" => value.TrainingDisabled, + "Inventory" => value.InventoryDisabled, _ => throw new ArgumentException("Unsupported module preview.") }; + } +} diff --git a/Core/Resgrid.Model/AdminAssist/NotificationImpact.cs b/Core/Resgrid.Model/AdminAssist/NotificationImpact.cs new file mode 100644 index 000000000..2f40f7898 --- /dev/null +++ b/Core/Resgrid.Model/AdminAssist/NotificationImpact.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.AdminAssist +{ + /// Declared future scenario, never a claimed count of historical notification events. + public sealed record NotificationImpactRequest(string ExpectedRevision, bool SuppressStaffing, int WindowDays, int EventsPerMember); + /// Internal metadata projection; no contact addresses, tokens, names or staffing notes. + public sealed class NotificationMemberEvidence + { + public int DepartmentId { get; set; } + public int MemberId { get; set; } + public string UserId { get; set; } + public int? ProfileId { get; set; } + public bool? Sms { get; set; } + public bool? MobileVerified { get; set; } + public bool? Email { get; set; } + public bool? EmailVerified { get; set; } + public bool? Push { get; set; } + public bool StaffingKnown { get; set; } + public int? Staffing { get; set; } + } + public interface INotificationImpactStore + { + Task> ReadNotificationMembersAsync(int departmentId, int bound, CancellationToken cancellationToken); + } + public interface INotificationImpactService + { + Task PreviewAsync(AdminAssistActor actor, NotificationImpactRequest request, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/AdminAssist/PermissionImpact.cs b/Core/Resgrid.Model/AdminAssist/PermissionImpact.cs new file mode 100644 index 000000000..8d0023051 --- /dev/null +++ b/Core/Resgrid.Model/AdminAssist/PermissionImpact.cs @@ -0,0 +1,15 @@ +using System.Threading; +using System.Threading.Tasks; +using System.Collections.Generic; + +namespace Resgrid.Model.AdminAssist +{ + public sealed record PermissionImpactRequest(string ExpectedRevision, string PermissionType, int Action, + bool LockToGroup, int[] RoleIds); + public sealed record PermissionRoleOption(int Id, string Name); + public interface IPermissionImpactService + { + Task PreviewAsync(AdminAssistActor actor, PermissionImpactRequest request, CancellationToken cancellationToken = default); + Task> GetRoleOptionsAsync(AdminAssistActor actor, string expectedRevision, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/AdminAssist/RetentionImpact.cs b/Core/Resgrid.Model/AdminAssist/RetentionImpact.cs new file mode 100644 index 000000000..23af5e0bc --- /dev/null +++ b/Core/Resgrid.Model/AdminAssist/RetentionImpact.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.AdminAssist +{ + public sealed record RetentionImpactRequest(string ExpectedRevision, int? ProposedDefaultYears); + /// Internal metadata projection. Record identifiers never leave the impact service. + public sealed record RetentionImpactHeader + { + public string RecordId { get; set; } + public int Kind { get; set; } + public string DefinitionKey { get; set; } + public int State { get; set; } + public DateTime? FinalizedOn { get; set; } + public DateTime ModifiedOn { get; set; } + public string AmendsRevisionId { get; set; } + public long RowVersion { get; set; } + public int HoldOrPermanentContent { get; set; } + public int HistoricalHoldUncertainty { get; set; } + } + public interface IRetentionImpactStore + { + Task> ReadRetentionHeadersAsync(int departmentId, int bound, CancellationToken ct); + } + public interface IRetentionImpactService + { + Task PreviewAsync(AdminAssistActor actor, RetentionImpactRequest request, CancellationToken ct = default); + } +} diff --git a/Core/Resgrid.Model/AdminAssist/SecurityImpact.cs b/Core/Resgrid.Model/AdminAssist/SecurityImpact.cs new file mode 100644 index 000000000..c86c1f668 --- /dev/null +++ b/Core/Resgrid.Model/AdminAssist/SecurityImpact.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.AdminAssist +{ + public sealed class SecurityMemberEvidence + { + public int DepartmentId { get; set; } + public int MemberId { get; set; } + public string UserId { get; set; } + public bool? TwoFactorEnabled { get; set; } + public DateTime? PasswordLastSetOn { get; set; } + } + public sealed class SecuritySessionEvidence + { + public int DepartmentId { get; set; } + public string Id { get; set; } + public string UserId { get; set; } + public DateTime CreatedOn { get; set; } + public DateTime LastActiveOn { get; set; } + public DateTime ExpiresOn { get; set; } + public long AuthenticationGeneration { get; set; } + public long? CurrentGeneration { get; set; } + } + public sealed record SecurityImpactEvidence(IReadOnlyList Members, IReadOnlyList Sessions, int EnabledSsoProviders); + public interface ISecurityImpactStore + { + Task ReadSecurityPolicyAsync(int departmentId, CancellationToken cancellationToken); + Task ReadSecurityImpactAsync(int departmentId, DateTime nowUtc, int bound, CancellationToken cancellationToken); + } + public interface ISecurityImpactService + { + Task PreviewAsync(AdminAssistActor actor, ConfigurationImpactRequest request, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/AdminAssist/SetupWorkspaceMetadata.cs b/Core/Resgrid.Model/AdminAssist/SetupWorkspaceMetadata.cs new file mode 100644 index 000000000..3c16cf1de --- /dev/null +++ b/Core/Resgrid.Model/AdminAssist/SetupWorkspaceMetadata.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Resgrid.Model.AdminAssist +{ + /// Versioned metadata in the existing workspace JSON column; no free text, content or credentials. + public sealed class SetupWorkspaceMetadata + { + public int SchemaVersion { get; set; } = 2; + public long ScopeRevision { get; set; } + public Dictionary Areas { get; set; } = new() { ["home"] = SetupAreaChoice.UseNow, ["security"] = SetupAreaChoice.UseNow }; + public Dictionary AreaReasons { get; set; } = new(); + public SetupReviewEvidence ReviewEvidence { get; set; } + public DateTime? RevisitOnUtc { get; set; } + public static SetupWorkspaceMetadata Read(string json) + { + if (json == null) return new(); + if (json.Length > 32768) throw new InvalidOperationException("Workspace metadata exceeds its bound."); + var document = JObject.Parse(json); + SetupWorkspaceMetadata state; + if (document.TryGetValue(nameof(SchemaVersion), StringComparison.OrdinalIgnoreCase, out var version)) + { + if (version.Type != JTokenType.Integer || version.Value() != 2) throw new InvalidOperationException("Unsupported workspace metadata version."); + state = document.ToObject(); + } + else state = new SetupWorkspaceMetadata { Areas = document.ToObject>() }; + if (state?.Areas == null || state.ScopeRevision < 0 || state.AreaReasons == null || state.Areas.Count > 100 || state.AreaReasons.Count > 100 || + state.Areas.Any(a => string.IsNullOrWhiteSpace(a.Key) || a.Key.Length > 128 || !Enum.IsDefined(a.Value)) || + state.AreaReasons.Any(a => !state.Areas.TryGetValue(a.Key, out var choice) || choice != SetupAreaChoice.NotApplicable || !Enum.IsDefined(a.Value))) + throw new InvalidOperationException("Invalid workspace metadata."); + return state; + } + public string Serialize() => JsonConvert.SerializeObject(this); + } +} diff --git a/Core/Resgrid.Model/AdminAssist/TextImportImpact.cs b/Core/Resgrid.Model/AdminAssist/TextImportImpact.cs new file mode 100644 index 000000000..b48556f1e --- /dev/null +++ b/Core/Resgrid.Model/AdminAssist/TextImportImpact.cs @@ -0,0 +1,13 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.AdminAssist +{ + public sealed record TextImportImpactRequest(string ExpectedRevision, string ProviderPath, string SourceNumber, + bool CallsEnabled, bool CommandsEnabled); + public sealed record TextImportImpactReport(string MaskedSource, string ProviderPath, ConfigurationImpactReport Impact); + public interface ITextImportImpactService + { + Task PreviewAsync(AdminAssistActor actor, TextImportImpactRequest request, CancellationToken ct = default); + } +} diff --git a/Core/Resgrid.Model/AdpAuditEvent.cs b/Core/Resgrid.Model/AdpAuditEvent.cs new file mode 100644 index 000000000..bec36b19f --- /dev/null +++ b/Core/Resgrid.Model/AdpAuditEvent.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using Newtonsoft.Json; + +namespace Resgrid.Model +{ + /// Value-free ADP evidence. Never put PINs, tokens, reasons or protected content in these fields. + public sealed class AdpAuditEvent + { + public string EventId { get; set; } = Guid.NewGuid().ToString("N"); + public int DepartmentId { get; set; } + public long Sequence { get; set; } + public string Layer { get; set; } + public string Operation { get; set; } + public string Outcome { get; set; } + public string ActorId { get; set; } + public string CorrelationId { get; set; } + public string ResourceId { get; set; } + public long PolicyEpoch { get; set; } + public DateTime OccurredUtc { get; set; } = DateTime.UtcNow; + public string PreviousHash { get; set; } + public string Hash { get; set; } + } + + public static class AdpAuditChain + { + public const string Genesis = "0000000000000000000000000000000000000000000000000000000000000000"; + + public static string ComputeHash(AdpAuditEvent row) + { + // Explicit, versioned ordering and millisecond precision survive both database engines. + var canonical = JsonConvert.SerializeObject(new object[] { 1, row.PreviousHash, row.EventId, + row.DepartmentId, row.Sequence, row.Layer, row.Operation, row.Outcome, row.ActorId, + row.CorrelationId, row.ResourceId, row.PolicyEpoch, + row.OccurredUtc.ToString("yyyy-MM-dd'T'HH:mm:ss.fff", CultureInfo.InvariantCulture) }); + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(canonical))).ToLowerInvariant(); + } + + public static void Link(AdpAuditEvent row, AdpAuditEvent tail) + { + row.OccurredUtc = new DateTime(row.OccurredUtc.Ticks - row.OccurredUtc.Ticks % TimeSpan.TicksPerMillisecond); + row.Sequence = (tail?.Sequence ?? 0) + 1; + row.PreviousHash = tail?.Hash ?? Genesis; + row.Hash = ComputeHash(row); + } + + /// Verify in stored sequence order; an external tail checkpoint also detects suffix removal. + public static bool Verify(IEnumerable rows, long expectedCount, string expectedTailHash) + { + long sequence = 0; + int? department = null; + var previous = Genesis; + foreach (var row in rows) + { + department ??= row.DepartmentId; + if (row.DepartmentId != department || row.Sequence != ++sequence || row.PreviousHash != previous || + row.Hash != ComputeHash(row)) return false; + previous = row.Hash; + } + return sequence == expectedCount && previous == expectedTailHash; + } + } +} diff --git a/Core/Resgrid.Model/AdpPermissionDefaults.cs b/Core/Resgrid.Model/AdpPermissionDefaults.cs index 350de54ca..711be6fe2 100644 --- a/Core/Resgrid.Model/AdpPermissionDefaults.cs +++ b/Core/Resgrid.Model/AdpPermissionDefaults.cs @@ -21,9 +21,9 @@ namespace Resgrid.Model /// - Protected OPERATIONAL data (logs, forms, IC content) defaults to department and group /// admins — command staff read it, the general roster does not, and departments widen it /// per role as needed. - /// - Export, egress configuration, break-glass, and ADP settings management default to - /// department admins. Break-glass additionally requires the department's policy to enable - /// it at all (plan section 12) — the permission alone is never sufficient. + /// - Export, egress configuration, and ADP settings management default to department admins. + /// The legacy break-glass value is retained for stored-row compatibility only. Staff support + /// access is authorized by BackOffice MFA, independent approval and customer consent. /// public static class AdpPermissionDefaults { diff --git a/Core/Resgrid.Model/AdpSupportConsent.cs b/Core/Resgrid.Model/AdpSupportConsent.cs new file mode 100644 index 000000000..87fccc329 --- /dev/null +++ b/Core/Resgrid.Model/AdpSupportConsent.cs @@ -0,0 +1,13 @@ +using System; + +namespace Resgrid.Model +{ + /// Department opt-in; every change advances the store version and revokes existing support sessions. + public sealed class AdpSupportConsent + { + public bool Enabled { get; set; } + public string UserId { get; set; } + public DateTime UpdatedUtc { get; set; } + public static string Key(int departmentId) => "support-consent:" + departmentId; + } +} diff --git a/Core/Resgrid.Model/AuditLogTypes.cs b/Core/Resgrid.Model/AuditLogTypes.cs index d381ba4ab..0360278ee 100644 --- a/Core/Resgrid.Model/AuditLogTypes.cs +++ b/Core/Resgrid.Model/AuditLogTypes.cs @@ -343,6 +343,8 @@ public enum AuditLogTypes FieldCostRunFrozen, // Member removal lifecycle (2026-09-22): a removed membership brought back; it returns without its old admin standing. Append-only. - UserReactivated + UserReactivated, + DepartmentConfigurationChanged, + AdminAssistReviewChanged } } diff --git a/Core/Resgrid.Model/Checklists/ChecklistWorkflowPayload.cs b/Core/Resgrid.Model/Checklists/ChecklistWorkflowPayload.cs index 46abfbc96..cf6beb4f6 100644 --- a/Core/Resgrid.Model/Checklists/ChecklistWorkflowPayload.cs +++ b/Core/Resgrid.Model/Checklists/ChecklistWorkflowPayload.cs @@ -16,7 +16,7 @@ public static class ChecklistWorkflowPayload (int)WorkflowTriggerEventType.ChecklistMissed, (int)WorkflowTriggerEventType.WorkOrderCreated, (int)WorkflowTriggerEventType.WorkOrderStatusChanged, (int)WorkflowTriggerEventType.WorkOrderAssigned, (int)WorkflowTriggerEventType.ChecklistScheduleChanged, (int)WorkflowTriggerEventType.ChecklistOccurrenceSkipped - }.Concat(Resgrid.Model.WorkOrders.WorkOrderWorkflowPayload.Triggers).Distinct().Concat(Resgrid.Model.Inventories.InventoryWorkflowPayload.Triggers).ToArray()); + }.Concat(Resgrid.Model.WorkOrders.WorkOrderWorkflowPayload.Triggers).Distinct().Concat(Resgrid.Model.Inventories.InventoryWorkflowPayload.Triggers).Concat(AdminAssist.AdminAssistWorkflowPayload.Triggers).ToArray()); private static readonly string[] Identifiers = { "CompletionId", "DefinitionId", "VersionId", "ItemId", "ScheduleId", "OccurrenceId" }; private static bool IsStructuralTarget(int type, string target) => type >= 0 && type <= 2 && int.TryParse(target, out var numeric) && numeric > 0 @@ -31,6 +31,7 @@ private static void Timing(JObject source, JObject target) public static string Routing(string payloadJson, string aggregateId) { var source = Resgrid.Model.Inventories.InventoryWorkflowPayload.Parse(payloadJson); + if (source["FindingId"] != null) return AdminAssist.AdminAssistWorkflowPayload.Routing(source); if (Resgrid.Model.Inventories.InventoryWorkflowPayload.IsInventory(source)) return Resgrid.Model.Inventories.InventoryWorkflowPayload.Routing(source); if (source["WorkOrderId"] != null || source["RecurrenceId"] != null) return Resgrid.Model.WorkOrders.WorkOrderWorkflowPayload.Routing(source); var safe = new JObject(); @@ -49,13 +50,14 @@ public static string Routing(string payloadJson, string aggregateId) return safe.ToString(Newtonsoft.Json.Formatting.None); } public static bool IsChecklist(int trigger) => Triggers.Contains(trigger); - public static readonly IReadOnlyList ReadinessProducers = Array.AsReadOnly(new[] { "Checklists", "WorkOrders", "Inventory" }); + public static readonly IReadOnlyList ReadinessProducers = Array.AsReadOnly(new[] { "Checklists", "WorkOrders", "Inventory", "AdminAssist" }); public static bool IsReadinessProducer(string producer) => ReadinessProducers.Contains(producer); public static async Task ProjectAsync(int departmentId, object value, IProtectedProjectionService protection, bool wrapped = false) { if (protection == null) throw new InvalidOperationException("Checklist workflow protection is unavailable."); var source = value as JObject ?? (value == null ? new JObject() : JObject.FromObject(value)); var payload = wrapped ? source["Payload"] as JObject ?? new JObject() : source; + if (payload["FindingId"] != null) return await AdminAssist.AdminAssistWorkflowPayload.ProjectAsync(departmentId, payload, protection, wrapped); if (Resgrid.Model.Inventories.InventoryWorkflowPayload.IsInventory(payload)) return await Resgrid.Model.Inventories.InventoryWorkflowPayload.ProjectAsync(departmentId, source, protection, wrapped); if (payload["WorkOrderId"] != null || payload["RecurrenceId"] != null) return await Resgrid.Model.WorkOrders.WorkOrderWorkflowPayload.ProjectAsync(departmentId, payload, protection, wrapped); var safe = new JObject(); diff --git a/Core/Resgrid.Model/DepartmentSecurityPolicyDecisions.cs b/Core/Resgrid.Model/DepartmentSecurityPolicyDecisions.cs new file mode 100644 index 000000000..887cd271a --- /dev/null +++ b/Core/Resgrid.Model/DepartmentSecurityPolicyDecisions.cs @@ -0,0 +1,24 @@ +using System; +using System.Globalization; + +namespace Resgrid.Model +{ + /// Pure decisions shared by the owning sign-in/session consumers and administrative previews. + public static class DepartmentSecurityPolicyDecisions + { + public static bool BlocksPasswordLogin(bool requireSso, bool enabledProvider, bool loginViaSso) => requireSso && enabledProvider && !loginViaSso; + public static bool RequiresMfaCompletion(bool requireMfa, bool completed) => requireMfa && !completed; + public static int MinimumPasswordLength(int configured) => Math.Max(8, configured); + public static bool PasswordExpired(int days, DateTime? lastSetOn, DateTime nowUtc) => + days > 0 && lastSetOn.HasValue && nowUtc > lastSetOn.Value.AddDays(days); + public static bool TryGetSessionGate(string configured, out DateTime gateUtc) + { + if (DateTimeOffset.TryParse(configured, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed)) + { + gateUtc = parsed.UtcDateTime; return true; + } + gateUtc = default; return false; + } + public static bool IdleExpired(int minutes, DateTime lastActiveOn, DateTime nowUtc) => minutes > 0 && lastActiveOn <= nowUtc.AddMinutes(-minutes); + } +} diff --git a/Core/Resgrid.Model/DepartmentSettingTypes.cs b/Core/Resgrid.Model/DepartmentSettingTypes.cs index ced29eb73..f92715556 100644 --- a/Core/Resgrid.Model/DepartmentSettingTypes.cs +++ b/Core/Resgrid.Model/DepartmentSettingTypes.cs @@ -114,5 +114,8 @@ public enum DepartmentSettingTypes /// user's group subtree, and which personnel roles stay department-wide while it is on. /// GroupDispatchScopeConfig = 78, + + /// Declared, reviewed administrative operating profile; registry section 4G. + DepartmentOperatingProfile = 110, } } diff --git a/Core/Resgrid.Model/FeatureFlagKeys.cs b/Core/Resgrid.Model/FeatureFlagKeys.cs index 7e06015bd..b33f2d259 100644 --- a/Core/Resgrid.Model/FeatureFlagKeys.cs +++ b/Core/Resgrid.Model/FeatureFlagKeys.cs @@ -7,6 +7,9 @@ namespace Resgrid.Model /// public static class FeatureFlagKeys { + public const string AdminSetup = "Admin.Setup"; + public const string AdminAssist = "Admin.Assist"; + public const string AiAdminAssist = "Ai.AdminAssist"; /// Free checklists rollout gate. Independent of paid plans and Maintenance.WorkOrders. Seeded off by M0189. public const string ChecklistsSystem = "Checklists.System"; diff --git a/Core/Resgrid.Model/MappingMarkerSource.cs b/Core/Resgrid.Model/MappingMarkerSource.cs new file mode 100644 index 000000000..3f69736f8 --- /dev/null +++ b/Core/Resgrid.Model/MappingMarkerSource.cs @@ -0,0 +1,27 @@ +using System; + +namespace Resgrid.Model +{ + public enum MappingMarkerSource { None, LocationPing, Status } + + /// Shared v4 map marker selection. TTL expires pings; a status location remains a fallback. + public static class MappingMarkerSelection + { + public static MappingMarkerSource Select(DateTime? pingOn, DateTime? statusOn, bool statusHasLocation, + int pingTtlMinutes, bool allowStatusWithoutLocationToOverwrite, DateTime nowUtc) + => Select(pingOn, statusOn, () => statusHasLocation, pingTtlMinutes, allowStatusWithoutLocationToOverwrite, nowUtc); + + public static MappingMarkerSource Select(DateTime? pingOn, DateTime? statusOn, Func statusHasLocation, + int pingTtlMinutes, bool allowStatusWithoutLocationToOverwrite, DateTime nowUtc) + { + if (pingOn.HasValue && pingTtlMinutes > 0 && nowUtc.AddMinutes(-pingTtlMinutes) > pingOn.Value) pingOn = null; + if (pingOn.HasValue) + { + if (!statusOn.HasValue || pingOn.Value > statusOn.Value) return MappingMarkerSource.LocationPing; + if (statusHasLocation()) return MappingMarkerSource.Status; + return allowStatusWithoutLocationToOverwrite ? MappingMarkerSource.None : MappingMarkerSource.LocationPing; + } + return statusOn.HasValue && statusHasLocation() ? MappingMarkerSource.Status : MappingMarkerSource.None; + } + } +} diff --git a/Core/Resgrid.Model/NotificationChannelSelection.cs b/Core/Resgrid.Model/NotificationChannelSelection.cs new file mode 100644 index 000000000..09f2fdf49 --- /dev/null +++ b/Core/Resgrid.Model/NotificationChannelSelection.cs @@ -0,0 +1,13 @@ +namespace Resgrid.Model +{ + /// Preference/contact gates used by the ordinary notification sender, before provider/device checks. + public sealed record NotificationChannelSelection(bool Sms, bool Email, bool Push) + { + public static NotificationChannelSelection From(UserProfile profile) => profile == null + ? new(true, true, true) + : From(profile.SendNotificationSms, profile.MobileNumberVerified, profile.SendNotificationEmail, + profile.EmailVerified, profile.SendNotificationPush); + public static NotificationChannelSelection From(bool sms, bool? mobileVerified, bool email, bool? emailVerified, bool push) => + new(sms && mobileVerified.IsContactMethodAllowedForSending(), email && emailVerified.IsContactMethodAllowedForSending(), push); + } +} diff --git a/Core/Resgrid.Model/PermissionTypes.cs b/Core/Resgrid.Model/PermissionTypes.cs index b97d79581..36a784748 100644 --- a/Core/Resgrid.Model/PermissionTypes.cs +++ b/Core/Resgrid.Model/PermissionTypes.cs @@ -77,8 +77,8 @@ public enum PermissionTypes ConfigureProtectedDataEgress = 38, /// - /// Emergency break-glass access to protected data. Off by default; every use requires a reason, - /// produces notifications, and is subject to review (ADP plan section 12). + /// Legacy stored permission identifier. Does not authorize staff support access; that flow + /// is managed by BackOffice with customer consent, fresh MFA and independent approval. /// BreakGlassProtectedData = 39, diff --git a/Core/Resgrid.Model/ProtectedWorkflows/ProtectedPayloadValidator.cs b/Core/Resgrid.Model/ProtectedWorkflows/ProtectedPayloadValidator.cs index 5741e03c5..425591ce6 100644 --- a/Core/Resgrid.Model/ProtectedWorkflows/ProtectedPayloadValidator.cs +++ b/Core/Resgrid.Model/ProtectedWorkflows/ProtectedPayloadValidator.cs @@ -100,14 +100,22 @@ private static ProtectedPayloadCheck ValidateJson(string body, bool requireResou JToken token; try { - using var reader = new JsonTextReader(new StringReader(body)) { DateParseHandling = DateParseHandling.None, FloatParseHandling = FloatParseHandling.Decimal }; - token = JToken.ReadFrom(reader); - // Anything after the first value (a second object, stray text) is malformed. - while (reader.Read()) + // Comments are not JSON, and the body leaves exactly as rendered. Json.NET would skip them wherever they + // sit (inside the value as well as after it), so look at every token before parsing. + using (var scan = NewJsonReader(body)) { - if (reader.TokenType != JsonToken.Comment) - return ProtectedPayloadCheck.Invalid(RuleJsonParse, reader.LineNumber, reader.LinePosition); + while (scan.Read()) + { + if (scan.TokenType == JsonToken.Comment) + return ProtectedPayloadCheck.Invalid(RuleJsonParse, scan.LineNumber, scan.LinePosition); + } } + + using var reader = NewJsonReader(body); + token = JToken.ReadFrom(reader); + // Anything after the first value (a second object, stray text) is malformed. + if (reader.Read()) + return ProtectedPayloadCheck.Invalid(RuleJsonParse, reader.LineNumber, reader.LinePosition); } catch (JsonReaderException ex) { @@ -128,6 +136,9 @@ private static ProtectedPayloadCheck ValidateJson(string body, bool requireResou return ProtectedPayloadCheck.Valid; } + private static JsonTextReader NewJsonReader(string body) => + new JsonTextReader(new StringReader(body)) { DateParseHandling = DateParseHandling.None, FloatParseHandling = FloatParseHandling.Decimal }; + private static ProtectedPayloadCheck ValidateXml(string body) { var settings = new XmlReaderSettings diff --git a/Core/Resgrid.Model/ProtectedWorkflows/ProtectedStepOptions.cs b/Core/Resgrid.Model/ProtectedWorkflows/ProtectedStepOptions.cs index 91049fbc7..7c405a447 100644 --- a/Core/Resgrid.Model/ProtectedWorkflows/ProtectedStepOptions.cs +++ b/Core/Resgrid.Model/ProtectedWorkflows/ProtectedStepOptions.cs @@ -86,7 +86,8 @@ public sealed class ProtectedStepOptions public static readonly Regex SubjectKeyPattern = new Regex("^[a-z0-9_]{1,64}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); private static readonly Regex HeaderToken = new Regex("^[A-Za-z0-9!#$%&'*+.^_`|~-]{1,64}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex Hl7FieldReference = new Regex(@"^[A-Z0-9]{3}-\d{1,3}(\.\d{1,3}){0,2}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); + // [0-9], not \d: \d also matches non-ASCII digits, which the response rules' int.Parse would throw on after the send. + private static readonly Regex Hl7FieldReference = new Regex(@"^[A-Z0-9]{3}-[0-9]{1,3}(\.[0-9]{1,3}){0,2}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); /// The declared content type exactly as configured (null when absent). public string ContentType { get; set; } diff --git a/Core/Resgrid.Model/Records/RecordsRetentionWindow.cs b/Core/Resgrid.Model/Records/RecordsRetentionWindow.cs new file mode 100644 index 000000000..bca45d2b5 --- /dev/null +++ b/Core/Resgrid.Model/Records/RecordsRetentionWindow.cs @@ -0,0 +1,11 @@ +using System; + +namespace Resgrid.Model +{ + /// Shared calendar-year boundary. Zero, unknown or unrepresentable dates never expire. + public static class RecordsRetentionWindow + { + public static bool HasExpired(DateTime? finalized, int years, DateTime now) => + finalized.HasValue && years > 0 && years <= 9999 - finalized.Value.Year && finalized.Value.AddYears(years) <= now; + } +} diff --git a/Core/Resgrid.Model/Repositories/IActionLogsRepository.cs b/Core/Resgrid.Model/Repositories/IActionLogsRepository.cs index d545c1bc5..2329d5814 100644 --- a/Core/Resgrid.Model/Repositories/IActionLogsRepository.cs +++ b/Core/Resgrid.Model/Repositories/IActionLogsRepository.cs @@ -11,6 +11,8 @@ namespace Resgrid.Model.Repositories /// public interface IActionLogsRepository: IRepository { + /// Bounded latest status metadata for administrative previews; no ETA/provider enrichment. Throws on truncation. + Task> ReadLatestForAdministrationAsync(int departmentId, bool disableAutoAvailable, DateTime asOfUtc, int maximumRows, System.Threading.CancellationToken cancellationToken); /// /// Gets the last action logs for department asynchronous. /// diff --git a/Core/Resgrid.Model/Repositories/IAdpAccessStore.cs b/Core/Resgrid.Model/Repositories/IAdpAccessStore.cs new file mode 100644 index 000000000..ef8cbf08e --- /dev/null +++ b/Core/Resgrid.Model/Repositories/IAdpAccessStore.cs @@ -0,0 +1,19 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Repositories +{ + /// Durable compare-and-swap state. No plaintext PIN, grant token or customer content is stored. + public interface IAdpAccessStore + { + Task GetAsync(string id, CancellationToken cancellationToken = default); + Task SaveAsync(string id, string json, long expectedVersion, CancellationToken cancellationToken = default); + } + + public sealed class AdpAccessState + { + public string StateId { get; set; } + public string Json { get; set; } + public long Version { get; set; } + } +} diff --git a/Core/Resgrid.Model/Repositories/IAdpAuditRepository.cs b/Core/Resgrid.Model/Repositories/IAdpAuditRepository.cs new file mode 100644 index 000000000..bbba3d5bc --- /dev/null +++ b/Core/Resgrid.Model/Repositories/IAdpAuditRepository.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Repositories +{ + public interface IAdpAuditRepository + { + Task AppendAsync(AdpAuditEvent record, CancellationToken cancellationToken = default); + Task> ReadAsync(int departmentId, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/ResourceVisibilityPermission.cs b/Core/Resgrid.Model/ResourceVisibilityPermission.cs new file mode 100644 index 000000000..516adec4a --- /dev/null +++ b/Core/Resgrid.Model/ResourceVisibilityPermission.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Resgrid.Model +{ + /// Shared resource visibility decision used by authorization and request-local administrative previews. + public static class ResourceVisibilityPermission + { + public static bool Allows(Permission permission, bool departmentAdmin, bool groupAdmin, int? actorGroup, + int? targetGroup, IEnumerable roleIds, bool adminOfTargetOrAncestor) + { + if (permission == null) return true; + var sameGroup = actorGroup.HasValue && targetGroup.HasValue && actorGroup == targetGroup; + switch ((PermissionActions)permission.Action) + { + case PermissionActions.DepartmentAdminsOnly: return departmentAdmin; + case PermissionActions.DepartmentAndGroupAdmins: + return departmentAdmin || groupAdmin && (!permission.LockToGroup || adminOfTargetOrAncestor); + case PermissionActions.DepartmentAdminsAndSelectRoles: + if (departmentAdmin) return true; + if (permission.LockToGroup && !sameGroup || string.IsNullOrWhiteSpace(permission.Data)) return false; + var selected = permission.Data.Split(',').Select(int.Parse).ToHashSet(); + return roleIds.Any(selected.Contains); + case PermissionActions.Everyone: return !permission.LockToGroup || departmentAdmin || sameGroup; + // These legacy resource gates do not implement the RMS action 4; preserve their deny behavior. + default: return false; + } + } + } +} diff --git a/Core/Resgrid.Model/Services/IAdpReleaseService.cs b/Core/Resgrid.Model/Services/IAdpReleaseService.cs new file mode 100644 index 000000000..8606c6b55 --- /dev/null +++ b/Core/Resgrid.Model/Services/IAdpReleaseService.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Providers; + +namespace Resgrid.Model.Services +{ + public interface IAdpReleaseService + { + Task EnrollPinAsync(int departmentId, string userId, string grantToken, string pin, CancellationToken cancellationToken = default); + Task CreateChallengeAsync(int departmentId, int callId, string userId, string phone, ProtectedDataEgressChannel channel, CancellationToken cancellationToken = default); + Task ReleaseAsync(string challengeId, string phone, string pin, ProtectedDataEgressChannel channel, CancellationToken cancellationToken = default); + } + + /// One-use, exact-field capabilities checked at the broker in addition to workload authentication. + public interface IAdpReleaseReceiptService + { + Task IssueAsync(int departmentId, long epoch, string actorId, string purpose, + IReadOnlyList fields, CancellationToken cancellationToken = default); + Task ConsumeAsync(string token, int departmentId, long epoch, + IReadOnlyList fields, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs b/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs index 3e7c7471d..c38beef6e 100644 --- a/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs +++ b/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs @@ -8,6 +8,8 @@ namespace Resgrid.Model.Services { public interface IDepartmentSettingsService { + Task GetOperatingProfileAsync(int departmentId); + Task SetOperatingProfileAsync(int departmentId, AdminAssist.DepartmentOperatingProfile profile, string actingUserId, CancellationToken cancellationToken = default); /// /// Saves the or update setting asynchronous. /// diff --git a/Core/Resgrid.Model/Services/IProtectedProjectionService.cs b/Core/Resgrid.Model/Services/IProtectedProjectionService.cs index 77efc07e7..2c1a003b6 100644 --- a/Core/Resgrid.Model/Services/IProtectedProjectionService.cs +++ b/Core/Resgrid.Model/Services/IProtectedProjectionService.cs @@ -26,7 +26,7 @@ public interface IProtectedProjectionService /// sanitized clone: the system-generated call number and structural/routing fields survive; /// every cataloged user-authored field is nulled and the nature reads the generic /// "sign in to Resgrid" line — safe to hand to any template, provider DTO, or TTS builder. - /// ProtectedAfterPin behaves as GenericOnly until the PIN-release flow ships. + /// ProtectedAfterPin remains generic on unattended projections; AdpReleaseService handles verified releases. /// /// /// The safe view of a member message for one outbound channel (catalog v7). A protected diff --git a/Core/Resgrid.Model/Services/IShiftsService.cs b/Core/Resgrid.Model/Services/IShiftsService.cs index 13b163084..07ff78963 100644 --- a/Core/Resgrid.Model/Services/IShiftsService.cs +++ b/Core/Resgrid.Model/Services/IShiftsService.cs @@ -15,6 +15,10 @@ public interface IShiftsService /// Task<List<Shift>>. Task> GetAllShiftsByDepartmentAsync(int departmentId); + /// Fresh administrative schedule evidence. Missing sources throw instead of becoming empty rosters. + Task> ReadSchedulesForAdministrationAsync(int departmentId, DateTime localStart, DateTime localEnd, + DateTime asOfUtc, int maximumRows, CancellationToken cancellationToken); + /// /// Gets the shift by identifier asynchronous. diff --git a/Core/Resgrid.Model/Services/ISmsService.cs b/Core/Resgrid.Model/Services/ISmsService.cs index d76fa45ce..d3db13155 100644 --- a/Core/Resgrid.Model/Services/ISmsService.cs +++ b/Core/Resgrid.Model/Services/ISmsService.cs @@ -29,6 +29,8 @@ Task SendMessageAsync(Message message, string departmentNumber, int depart /// The profile. /// The address. /// Task<System.Boolean>. + Task SendProtectedDispatchChallengeAsync(UserProfile profile, int departmentId, string departmentNumber, string challengeText); + Task SendCallAsync(Call call, CallDispatch dispatch, string departmentNumber, int departmentId, UserProfile profile = null, string address = null, Payment payment = null); diff --git a/Core/Resgrid.Model/Services/IUnitsService.cs b/Core/Resgrid.Model/Services/IUnitsService.cs index 618bc0eae..3bc21184d 100644 --- a/Core/Resgrid.Model/Services/IUnitsService.cs +++ b/Core/Resgrid.Model/Services/IUnitsService.cs @@ -363,5 +363,7 @@ Task AddUnitLocationAsync(UnitsLocation location, int d Task> GetUnitStaffingForDepartmentAsync(int departmentId); Task> GetLatestUnitLocationsAsync(int departmentId); + /// Uncached location read that propagates failure instead of returning an empty set. + Task> ReadLatestLocationsForAdministrationAsync(int departmentId); } } diff --git a/Core/Resgrid.Model/Services/IUsersService.cs b/Core/Resgrid.Model/Services/IUsersService.cs index 560aa9949..c4c758800 100644 --- a/Core/Resgrid.Model/Services/IUsersService.cs +++ b/Core/Resgrid.Model/Services/IUsersService.cs @@ -27,6 +27,8 @@ public interface IUsersService Task GetUserByNameAsync(string userName); Task SavePersonnelLocationAsync(PersonnelLocation personnelLocation, System.Threading.CancellationToken cancellationToken = default); Task> GetLatestLocationsForDepartmentPersonnelAsync(int departmentId); + /// Uncached location read that propagates failure instead of returning an empty set. + Task> ReadLatestLocationsForAdministrationAsync(int departmentId); Task GetPersonnelLocationByIdAsync(string id); Task ClearOutUserLoginAsync(string userId); Task> GetUserGroupAndRolesByDepartmentIdInLimitAsync(int deparmentId, bool retrieveHidden, bool retrieveDisabled, bool retrieveDeleted); diff --git a/Core/Resgrid.Model/ShiftRosterGroups.cs b/Core/Resgrid.Model/ShiftRosterGroups.cs new file mode 100644 index 000000000..9b724507e --- /dev/null +++ b/Core/Resgrid.Model/ShiftRosterGroups.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Resgrid.Model +{ + /// Shared projection for the broadcaster and administrative simulation, after trades and approvals resolve. + public static class ShiftRosterGroups + { + public static List Select(int groupId, IEnumerable roster, IEnumerable groupMembers) + { + if (roster == null || groupMembers == null) throw new ArgumentException("Complete roster and membership evidence is required."); + var entries = roster.Where(r => r.IsOnDuty()).ToArray(); + var people = new HashSet(entries.Where(r => r.DepartmentGroupId == groupId).Select(r => r.UserId), StringComparer.OrdinalIgnoreCase); + var members = new HashSet(groupMembers, StringComparer.OrdinalIgnoreCase); + foreach (var entry in entries.Where(r => !r.DepartmentGroupId.HasValue && members.Contains(r.UserId))) people.Add(entry.UserId); + return people.ToList(); + } + } +} diff --git a/Core/Resgrid.Model/TextIntakeRouting.cs b/Core/Resgrid.Model/TextIntakeRouting.cs new file mode 100644 index 000000000..e75e6cb8f --- /dev/null +++ b/Core/Resgrid.Model/TextIntakeRouting.cs @@ -0,0 +1,23 @@ +using System; + +namespace Resgrid.Model +{ + public enum TextIntakePath { TwilioLegacy, SignalWire } + public sealed record TextIntakeDecision(bool DispatchSource, bool CallBranch, bool CommandBranch); + + /// Routing only, after department resolution and the provider's plan gate. Not sender authentication. + public static class TextIntakeRouting + { + public static TextIntakeDecision Decide(TextIntakePath path, bool patternMatched, bool callsEnabled, bool commandsEnabled) + { + return path switch + { + // The legacy Twilio consumer intentionally does not consult these switches. + TextIntakePath.TwilioLegacy => new(patternMatched, patternMatched, !patternMatched), + TextIntakePath.SignalWire => new(patternMatched || !commandsEnabled, + (patternMatched || !commandsEnabled) && callsEnabled, !patternMatched && commandsEnabled), + _ => throw new ArgumentOutOfRangeException(nameof(path)) + }; + } + } +} diff --git a/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs b/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs index 548831503..c3b300bbe 100644 --- a/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs +++ b/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs @@ -446,6 +446,13 @@ pair.Variable is "status" or "old_status" or "contract_type" or "days_until_end" : pair.Variable.EndsWith("_on", System.StringComparison.Ordinal) ? "datetime" : "string", false)); list.Add(new TemplateVariableDescriptor("contract.url", "Authenticated contract link", "string", false)); break; + case WorkflowTriggerEventType.AdminAssistFindingOpened: + case WorkflowTriggerEventType.AdminAssistFindingResolved: + case WorkflowTriggerEventType.AdminAssistFindingReopened: + foreach (var pair in AdminAssist.AdminAssistWorkflowPayload.Variables) + list.Add(new TemplateVariableDescriptor("admin_assist." + pair.Variable, "Admin Assist " + pair.Variable.Replace('_', ' '), pair.Variable is "id" or "rule_id" ? "string" : "int", false)); + list.Add(new TemplateVariableDescriptor("admin_assist.url", "Authenticated Admin Assist link", "string", false)); + break; case WorkflowTriggerEventType.WorkOrderCreated: case WorkflowTriggerEventType.WorkOrderStatusChanged: case WorkflowTriggerEventType.WorkOrderAssigned: diff --git a/Core/Resgrid.Model/WorkflowTriggerEventType.cs b/Core/Resgrid.Model/WorkflowTriggerEventType.cs index 90a41eed3..4f7cfd59c 100644 --- a/Core/Resgrid.Model/WorkflowTriggerEventType.cs +++ b/Core/Resgrid.Model/WorkflowTriggerEventType.cs @@ -245,7 +245,10 @@ public enum WorkflowTriggerEventType DeploymentAttachmentAdded = 187, // Inventory held by a removed, disabled or hidden member (registry 188, 2026-09-22): raised with the DepartedHolder alert. - InventoryDepartedHolder = 188 + InventoryDepartedHolder = 188, + AdminAssistFindingOpened = 189, + AdminAssistFindingResolved = 190, + AdminAssistFindingReopened = 191 } public static class WorkflowTriggerEventTypes diff --git a/Core/Resgrid.Search/AdminAssistReferenceSearch.cs b/Core/Resgrid.Search/AdminAssistReferenceSearch.cs new file mode 100644 index 000000000..16ed27a61 --- /dev/null +++ b/Core/Resgrid.Search/AdminAssistReferenceSearch.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Lucene.Net.Analysis.Standard; +using Lucene.Net.Analysis.TokenAttributes; +using Lucene.Net.Documents; +using Lucene.Net.Index; +using Lucene.Net.Search; +using Lucene.Net.Search.Similarities; +using Lucene.Net.Store; +using Lucene.Net.Util; +using Resgrid.Model.AdminAssist; + +namespace Resgrid.Search +{ + /// CPU BM25 over the immutable release-pinned public reference corpus only. Never queries tenant records. + public sealed class AdminAssistReferenceSearch : IAdminAssistReferenceSearch, IDisposable + { + private readonly Lazy _index; + public AdminAssistReferenceSearch(IAdminAssistCatalog catalog) => _index = new Lazy(() => new ReferenceIndex(catalog)); + public IReadOnlyList Search(string query, string locale, int take = 20) + { + if (string.IsNullOrWhiteSpace(query)) return Array.Empty(); + if (query.Length > 256) throw new ArgumentException("Reference query too long."); + return _index.Value.Search(query, locale, Math.Clamp(take, 1, 25)); + } + public void Dispose() { if (_index.IsValueCreated) _index.Value.Dispose(); } + private sealed class ReferenceIndex : IDisposable + { + private const LuceneVersion Version = LuceneVersion.LUCENE_48; + private readonly RAMDirectory _directory = new(); + private readonly StandardAnalyzer _analyzer = new(Version); + private readonly DirectoryReader _reader; + private readonly IReadOnlyDictionary _articles; + public ReferenceIndex(IAdminAssistCatalog catalog) + { + _articles = catalog.Articles.ToDictionary(a => a.Locale + ":" + a.Id, StringComparer.Ordinal); + using (var writer = new IndexWriter(_directory, new IndexWriterConfig(Version, _analyzer) { Similarity = new BM25Similarity() })) + { + foreach (var (id, article) in _articles) + { + if (article.PackVersion != catalog.Version || string.IsNullOrWhiteSpace(article.Anchor) || !article.SourcePath.StartsWith("docs/admin-assist/", StringComparison.Ordinal)) + throw new InvalidOperationException("Unreviewed reference source."); + writer.AddDocument(new Document { new StringField("id", id, Field.Store.YES), new StringField("locale", article.Locale, Field.Store.NO), + new TextField("body", article.Body, Field.Store.NO) }); + } + writer.Commit(); + } + _reader = DirectoryReader.Open(_directory); + } + public IReadOnlyList Search(string query, string locale, int take) + { + var selectedLocale = (locale ?? "en").Split('-')[0].ToLowerInvariant(); + if (!_articles.Values.Any(a => a.Locale == selectedLocale)) selectedLocale = "en"; + // Analyze literal text rather than exposing Lucene query syntax. Escaping punctuation alone + // still interprets words such as AND/OR/NOT as operators and can throw on normal questions. + var words = new BooleanQuery(); + using (var tokens = _analyzer.GetTokenStream("body", new StringReader(query))) + { + var term = tokens.AddAttribute(); tokens.Reset(); + while (tokens.IncrementToken()) words.Add(new TermQuery(new Term("body", term.ToString())), Occur.SHOULD); + tokens.End(); + } + if (words.Clauses.Count == 0) return Array.Empty(); + var filter = new BooleanQuery { { words, Occur.MUST }, { new TermQuery(new Term("locale", selectedLocale)), Occur.MUST } }; + var searcher = new IndexSearcher(_reader) { Similarity = new BM25Similarity() }; + return searcher.Search(filter, take).ScoreDocs.Select(hit => _articles[searcher.Doc(hit.Doc).Get("id")]).Select(a => + new AdminAssistSearchHit(a.Id, a.TitleKey, a.Body.Length > 500 ? a.Body[..500] + "…" : a.Body, a.SourcePath, a.Anchor, a.PackVersion, a.Locale)).ToArray(); + } + public void Dispose() { _reader.Dispose(); _analyzer.Dispose(); _directory.Dispose(); } + } + } +} diff --git a/Core/Resgrid.Search/SearchModule.cs b/Core/Resgrid.Search/SearchModule.cs index f8d4aba53..85cf29e06 100644 --- a/Core/Resgrid.Search/SearchModule.cs +++ b/Core/Resgrid.Search/SearchModule.cs @@ -13,6 +13,7 @@ public class SearchModule : Module { protected override void Load(ContainerBuilder builder) { + builder.RegisterType().As().SingleInstance(); builder.Register(c => { var s3 = new S3SearchIndexStore(); diff --git a/Core/Resgrid.Services/AdminAssist/AdminAssistAccessService.cs b/Core/Resgrid.Services/AdminAssist/AdminAssistAccessService.cs new file mode 100644 index 000000000..fc0cfa85f --- /dev/null +++ b/Core/Resgrid.Services/AdminAssist/AdminAssistAccessService.cs @@ -0,0 +1,170 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Services; + +namespace Resgrid.Services.AdminAssist +{ + public sealed class AdminAssistAccessService(IRecordsAuthorizationService authorization, IFeatureToggleService flags, + IDepartmentSettingsService settings, ISubscriptionsService subscriptions, IDepartmentDataProtectionService protection, + IReadinessAccessService readiness, IBusinessOperationsAccessService business, IAdminAssistCatalog catalog, + TimeProvider clock, IAuthorizationService sourceAuthorization, IPermissionsService permissions = null) : IAdminAssistAccessService + { + public async Task CanAccessAsync(AdminAssistActor actor, bool setup, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + if (actor == null || actor.DepartmentId <= 0 || string.IsNullOrWhiteSpace(actor.UserId)) return false; + if (!await authorization.IsActiveMemberAsync(actor.UserId, actor.DepartmentId) || + !await authorization.IsDepartmentAdminAsync(actor.UserId, actor.DepartmentId)) return false; + return await AdminAssistFeatureAvailability.IsEnabledAsync(flags, actor.DepartmentId, setup, ct); + } + + public Task> GetCapabilitiesAsync(AdminAssistActor actor, CancellationToken ct = default) => ReadCapabilitiesAsync(actor, catalog.Capabilities, ct); + public async Task GetCapabilityAsync(AdminAssistActor actor, string capabilityId, CancellationToken ct = default) => + (await ReadCapabilitiesAsync(actor, new[] { catalog.Capabilities.Single(c => c.Id == capabilityId) }, ct)).Single(); + + private async Task> ReadCapabilitiesAsync(AdminAssistActor actor, IEnumerable capabilities, CancellationToken ct) + { + if (!await authorization.IsActiveMemberAsync(actor.UserId, actor.DepartmentId) || + !await authorization.IsDepartmentAdminAsync(actor.UserId, actor.DepartmentId)) throw new UnauthorizedAccessException(); + var observed = new Dictionary(); + var result = new List(); + bool canManageSubscription; + try { canManageSubscription = await sourceAuthorization.CanUserManageSubscriptionAsync(actor.UserId, actor.DepartmentId); } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch (Exception) { canManageSubscription = false; } + foreach (var capability in capabilities) + { + ct.ThrowIfCancellationRequested(); + var reasons = new List(); + var commercial = new List(); + var state = EvidenceState.Known; + if (capability.ReleaseStatus != "available" && capability.ReleaseStatus != "preview") + { + result.Add(new CapabilityAccess(capability.Id, EvidenceState.Unavailable, new[] { "Feature" + capability.ReleaseStatus }, false, null, clock.GetUtcNow().UtcDateTime)); + continue; + } + foreach (var requirement in capability.Requirements) + { + var key = requirement.Kind + ":" + requirement.Id; + if (!observed.TryGetValue(key, out var requirementState)) + { + requirementState = await EvaluateAsync(actor, requirement, ct); + observed[key] = requirementState; + } + if (requirement.Kind == "addon") commercial.Add(requirementState.State); + if (requirementState.State != EvidenceState.Known) + { + reasons.Add(requirementState.Reason); + state = state == EvidenceState.Unknown || requirementState.State == EvidenceState.Unknown ? EvidenceState.Unknown : requirementState.State; + } + } + // The source gate is authoritative even when diagnostic subscription metadata appears available. + if (state == EvidenceState.Known && capability.Location.Controller == "Subscription" && + !canManageSubscription) + { + state = EvidenceState.Unavailable; + reasons.Add("ManagingMemberRequired"); + } + try + { + if (state == EvidenceState.Known && !await OwningGateAsync(actor.DepartmentId, capability)) + { + state = EvidenceState.Unavailable; + reasons.Add("SourceAccessUnavailable"); + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch (Exception) { state = EvidenceState.Unknown; reasons.Add("AvailabilityUnknown"); } + result.Add(new CapabilityAccess(capability.Id, state, reasons.Distinct().ToArray(), state == EvidenceState.Known, + state == EvidenceState.Known ? capability.Location.Url : null, clock.GetUtcNow().UtcDateTime, + canManageSubscription && capability.Requirements.Any(r => r.Kind == "addon") ? "/User/Subscription/Index" : null, + commercial.Count == 0 ? EvidenceState.NotApplicable : commercial.Any(s => s == EvidenceState.Unknown || s == EvidenceState.Redacted) ? EvidenceState.Unknown : + commercial.Any(s => s == EvidenceState.Unavailable) ? EvidenceState.Unavailable : EvidenceState.Known)); + } + if (!await authorization.IsActiveMemberAsync(actor.UserId, actor.DepartmentId) || + !await authorization.IsDepartmentAdminAsync(actor.UserId, actor.DepartmentId)) throw new UnauthorizedAccessException(); + return result; + } + + private async Task<(EvidenceState, string)> EvaluateAsync(AdminAssistActor actor, CapabilityRequirement requirement, CancellationToken ct) + { + var departmentId = actor.DepartmentId; + ct.ThrowIfCancellationRequested(); + try + { + switch (requirement.Kind) + { + case "permission": + if (!Enum.TryParse(requirement.Id, out var permissionType) || !Enum.IsDefined(permissionType)) + return (EvidenceState.Unknown, "AvailabilityUnknown"); + bool allowed; + if (RecordPermissionCatalog.Get(permissionType) != null) + allowed = await authorization.HasPermissionAsync(actor.UserId, departmentId, permissionType); + else + { + if (permissions == null) return (EvidenceState.Unknown, "AvailabilityUnknown"); + var permission = await permissions.GetPermissionByDepartmentTypeAsync(departmentId, permissionType); + // This endpoint is exclusively for fresh department admins; do not infer other actors' rights. + allowed = permissions.IsUserAllowed(permission, await authorization.IsDepartmentAdminAsync(actor.UserId, departmentId), false, new List()); + } + return allowed ? (EvidenceState.Known, null) : (EvidenceState.Unavailable, "SourceAccessUnavailable"); + case "flag": + var flag = await flags.EvaluateFreshAsync(requirement.Id, departmentId); + return flag == null ? (EvidenceState.Unknown, "AvailabilityUnknown") : flag.IsEnabled ? (EvidenceState.Known, null) : (EvidenceState.Unavailable, "FeatureNotEnabled"); + case "module": + var module = await settings.GetDepartmentModuleSettingsAsync(departmentId, true); + if (module == null) return (EvidenceState.Unknown, "AvailabilityUnknown"); + bool? disabled = requirement.Id switch + { + "Messaging" => module.MessagingDisabled, "Mapping" => module.MappingDisabled, "Shifts" => module.ShiftsDisabled, + "Logs" => module.LogsDisabled, "Reports" => module.ReportsDisabled, "Documents" => module.DocumentsDisabled, + "Calendar" => module.CalendarDisabled, "Notes" => module.NotesDisabled, "Training" => module.TrainingDisabled, + "Inventory" => module.InventoryDisabled, "Maintenance" => module.MaintenanceDisabled, + "Checklists" => module.ChecklistsDisabled, "BusinessOperations" => module.BusinessOperationsDisabled, _ => null + }; + return disabled == null ? (EvidenceState.Unknown, "AvailabilityUnknown") : disabled.Value ? (EvidenceState.Unavailable, "ModuleDisabled") : (EvidenceState.Known, null); + case "protection": + return await protection.GetStateAsync(departmentId, true) == DepartmentDataProtectionState.Enabled + ? (EvidenceState.Known, null) : (EvidenceState.Unavailable, "ProtectionEnrollmentRequired"); + case "addon": + if (!Enum.TryParse(requirement.Id, out var addon) || !Enum.IsDefined(addon) || + string.IsNullOrWhiteSpace(Config.SystemBehaviorConfig.BillingApiBaseUrl) || string.IsNullOrWhiteSpace(Config.ApiConfig.BackendInternalApikey)) + return (EvidenceState.Unknown, "SubscriptionStatusUnavailable"); + var plans = await subscriptions.GetAllAddonPlansByTypeAsync(addon); + var ids = plans?.Where(p => p != null && p.AddonType == (int)addon && !string.IsNullOrWhiteSpace(p.PlanAddonId)).Select(p => p.PlanAddonId).Distinct().ToList(); + if (ids == null || ids.Count == 0) return (EvidenceState.Unknown, "SubscriptionStatusUnavailable"); + var payments = await subscriptions.GetCurrentPaymentAddonsForDepartmentAsync(departmentId, ids); + if (payments == null) return (EvidenceState.Unknown, "SubscriptionStatusUnavailable"); + var now = clock.GetUtcNow().UtcDateTime; + return payments.Any(p => p != null && p.DepartmentId == departmentId && ids.Contains(p.PlanAddonId) && p.EffectiveOn != default && + p.EffectiveOn <= now && p.EndingOn > now && p.EndingOn != DateTime.MaxValue && !string.Equals(p.TransactionId, "SYSTEM", StringComparison.OrdinalIgnoreCase)) + ? (EvidenceState.Known, null) : (EvidenceState.Unavailable, "AddonRequired." + requirement.Id); + default: return (EvidenceState.Unknown, "AvailabilityUnknown"); + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch (Exception) + { + // Diagnostic fallback carries no exception message or source payload to logs/the browser. + return (EvidenceState.Unknown, "AvailabilityUnknown"); + } + } + + private Task OwningGateAsync(int departmentId, ProductCapability capability) => capability.Location.Controller switch + { + "Checklists" => readiness.CanUseChecklistsAsync(departmentId), + "WorkOrders" => readiness.CanUseMaintenanceAsync(departmentId), + "Invoicing" => business.CanUseInvoicingAsync(departmentId), + "Bids" or "Contracts" or "RateSchedules" => business.CanUseContractorBillingAsync(departmentId), + "CalOesMars" => business.CanUseCostRecoveryAsync(departmentId), + "Workforce" when capability.Id == "pay-data-reporting" => business.CanUsePayDataReportingAsync(departmentId), + "Workforce" => business.CanUseWorkforceAsync(departmentId), + _ => Task.FromResult(true) + }; + } +} diff --git a/Core/Resgrid.Services/AdminAssist/AdminAssistFeatureAvailability.cs b/Core/Resgrid.Services/AdminAssist/AdminAssistFeatureAvailability.cs new file mode 100644 index 000000000..6144a4d91 --- /dev/null +++ b/Core/Resgrid.Services/AdminAssist/AdminAssistFeatureAvailability.cs @@ -0,0 +1,27 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Services; + +namespace Resgrid.Services.AdminAssist +{ + /// Fresh rollout checks. Missing flags and evaluation failures disable the optional feature. + public static class AdminAssistFeatureAvailability + { + public static async Task IsEnabledAsync(IFeatureToggleService flags, int departmentId, bool setup, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + if (departmentId <= 0) return false; + try + { + return (await flags.EvaluateFreshAsync(setup ? FeatureFlagKeys.AdminSetup : FeatureFlagKeys.AdminAssist, departmentId).WaitAsync(ct))?.IsEnabled == true; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch (Exception) { return false; } + } + + public static async Task CanConfigureOperatingProfileAsync(IFeatureToggleService flags, int departmentId, CancellationToken ct = default) => + await IsEnabledAsync(flags, departmentId, true, ct) || await IsEnabledAsync(flags, departmentId, false, ct); + } +} diff --git a/Core/Resgrid.Services/AdminAssist/AdminAssistMaintenanceService.cs b/Core/Resgrid.Services/AdminAssist/AdminAssistMaintenanceService.cs new file mode 100644 index 000000000..bb3d2de0d --- /dev/null +++ b/Core/Resgrid.Services/AdminAssist/AdminAssistMaintenanceService.cs @@ -0,0 +1,80 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Resources; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Services; + +namespace Resgrid.Services.AdminAssist +{ + /// Bounded metadata refresh and explicitly opted-in, generic weekly admin follow-up. No inference. + public sealed class AdminAssistMaintenanceService(IAdminAssistMaintenanceStore store, IAdminAssistAccessService access, + IAdminAssistWorklistService worklist, IDepartmentsService departments, IRecordsAuthorizationService membership, + ICommunicationService communication, IDepartmentSettingsService settings, IUserProfileService profiles, + TimeProvider clock) : IAdminAssistMaintenanceService + { + private static readonly ResourceManager Labels = new(typeof(Resgrid.Localization.Areas.User.AdminAssist.AdminAssist)); + public async Task RunDepartmentAsync(int departmentId, CancellationToken ct) + { + var now = clock.GetUtcNow().UtcDateTime; + var lease = Guid.NewGuid().ToString("D"); + if (!await store.TryLeaseAsync(departmentId, lease, now, ct)) return; + DateTime? evaluated = null; + try + { + // Privacy/retention cleanup also runs for departments with no remaining administrator or + // whose UI rollout was disabled; neither condition should preserve departed-user preferences. + await store.PurgeExpiredMetadataAsync(departmentId, now, ct); + // The workload has no decryption grant. Sources retain current member/permission checks; + // protected or incomplete evidence stays unknown and cannot resolve an existing failure. + var admins = await departments.GetActiveAdminsForDepartmentAsync(departmentId); + var admin = admins?.OrderBy(a => a.UserId, StringComparer.Ordinal).FirstOrDefault(); + if (admin == null) return; + var actor = new AdminAssistActor(departmentId, admin.UserId); + if (!await access.CanAccessAsync(actor, false, ct)) return; + await worklist.VerifyAsync(actor, ct); + evaluated = clock.GetUtcNow().UtcDateTime; + if (!Config.AdminAssistConfig.SendAdminDigests) return; + var department = await departments.GetDepartmentByIdAsync(departmentId, true); + if (department == null) return; + var zone = TimeZoneInfo.FindSystemTimeZoneById(department.TimeZone); + foreach (var preference in await store.GetDigestPreferencesAsync(departmentId, ct)) + { + try + { + ct.ThrowIfCancellationRequested(); now = clock.GetUtcNow().UtcDateTime; + var recipient = new AdminAssistActor(departmentId, preference.UserId, preference.Locale); + if (!await access.CanAccessAsync(recipient, false, ct) || !await membership.IsAssignableMemberAsync(preference.UserId, departmentId)) continue; + if (AdminAssistDigestSchedule.IsQuiet(now, zone, preference.QuietStartHour, preference.QuietEndHour)) continue; + var week = AdminAssistDigestSchedule.Week(now, zone); + if (preference.LastAttemptWeek == week) continue; + var profile = await profiles.GetProfileByUserIdAsync(preference.UserId, true); + if (profile == null) continue; + var number = await settings.GetTextToCallNumberForDepartmentAsync(departmentId); + if (!await store.ClaimDigestAsync(preference, week, now, ct)) continue; + var current = await store.GetPreferencesAsync(departmentId, preference.UserId, ct); + if (!current.DigestEnabled || current.Revision != preference.Revision || !await access.CanAccessAsync(recipient, false, ct) || + !await membership.IsAssignableMemberAsync(preference.UserId, departmentId)) + { + await store.CompleteDigestAsync(departmentId, preference.UserId, week, "Suppressed", now, ct); continue; + } + var outcome = "HandoffUnconfirmed"; + try + { + var locale = CultureInfo.GetCultureInfo(preference.Locale); + var message = Labels.GetString("Ui.DigestMessage", locale) + " " + (Config.SystemBehaviorConfig.ResgridBaseUrl ?? string.Empty).TrimEnd('/') + "/User/AdminAssist/Index"; + if (await communication.SendNotificationAsync(preference.UserId, departmentId, message, number, department, + Labels.GetString("Ui.Title", locale), profile)) outcome = "HandedOff"; + } + catch (Exception) { /* The provider may have accepted the request. Preserve the weekly claim. */ } + await store.CompleteDigestAsync(departmentId, preference.UserId, week, outcome, clock.GetUtcNow().UtcDateTime, ct); + } + finally { await store.AdvanceDigestCursorAsync(departmentId, preference.UserId, ct); } + } + } + finally { await store.CompleteLeaseAsync(departmentId, lease, evaluated, ct); } + } + } +} diff --git a/Core/Resgrid.Services/AdminAssist/AdminAssistService.cs b/Core/Resgrid.Services/AdminAssist/AdminAssistService.cs new file mode 100644 index 000000000..922db657e --- /dev/null +++ b/Core/Resgrid.Services/AdminAssist/AdminAssistService.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.AdminAssist; +using Resgrid.Config; +using Resgrid.Model.AdminAssist; + +namespace Resgrid.Services.AdminAssist +{ + public sealed class AdminAssistService(IAdminAssistAccessService access, IAdminAssistCatalog catalog, + IConfigurationSnapshotProvider snapshots, IAdminAssistRepository repository, TimeProvider clock) : IAdminAssistService + { + public async Task GetOverviewAsync(AdminAssistActor actor, bool setup, CancellationToken ct = default) + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(TimeSpan.FromSeconds(Math.Clamp(AdminAssistConfig.SnapshotTimeoutSeconds, 1, 60))); + ct = timeout.Token; + await RequireAccessAsync(actor, setup, ct); + var workspace = await repository.GetWorkspaceAsync(actor.DepartmentId, actor.UserId, catalog.Version, ct).WaitAsync(ct); + var snapshot = await snapshots.ReadAsync(actor, ct).WaitAsync(ct); + var now = clock.GetUtcNow().UtcDateTime; + var findings = catalog.Rules.Select(r => new ConfigurationRule(r).Evaluate(snapshot, now, + TimeSpan.FromSeconds(Math.Clamp(AdminAssistConfig.EvidenceFreshnessSeconds, 1, 300)))).ToArray(); + var availability = await access.GetCapabilitiesAsync(actor, ct).WaitAsync(ct); + await RequireAccessAsync(actor, setup, ct); + var report = new ConfigurationReport(snapshot, findings, workspace.Areas.Where(a => a.Value == SetupAreaChoice.UseNow).Select(a => a.Key).ToArray()); + return new AdminAssistOverview(catalog.Version, workspace, report, availability, + catalog.Capabilities.Select(capability => CapabilitySetupEvaluator.Evaluate(capability, availability.SingleOrDefault(a => a.CapabilityId == capability.Id), report, + clock.GetUtcNow().UtcDateTime, TimeSpan.FromSeconds(Math.Clamp(AdminAssistConfig.EvidenceFreshnessSeconds, 1, 300)))).ToArray()); + } + + public async Task UpdateSetupAsync(AdminAssistActor actor, SetupProgressCommand command, CancellationToken ct = default) + { + await RequireAccessAsync(actor, true, ct); + if (command == null || command.ExpectedRevision < 0 || command.ExpectedRevision == long.MaxValue || command.CatalogVersion != catalog.Version) + throw new ArgumentException("A current catalog and workspace revision are required."); + command = command with { ReviewEvidence = null }; // Never trust review counts supplied by a caller. + bool valid = command.Operation switch + { + "area" => catalog.Areas.Any(a => a.Id == command.TargetId) && Enum.GetNames().Contains(command.Choice) && + (command.Choice != nameof(SetupAreaChoice.NotApplicable) ? command.ReasonCode == null : Enum.GetNames().Contains(command.ReasonCode)), + "mode" => command.TargetId == null && Enum.GetNames().Contains(command.Choice), + "learn" or "interest" => catalog.Capabilities.Any(c => c.Id == command.TargetId) && (command.Choice == "true" || command.Choice == "false"), + "review" => command.TargetId == null && command.Choice == null && !string.IsNullOrWhiteSpace(command.EvidenceRevision), + "revisit" => command.TargetId == null && command.Choice == null && (!command.RevisitOnUtc.HasValue || + command.RevisitOnUtc.Value.Kind == DateTimeKind.Utc && command.RevisitOnUtc > clock.GetUtcNow().UtcDateTime && command.RevisitOnUtc <= clock.GetUtcNow().UtcDateTime.AddDays(365)), + "dismiss" => command.TargetId == null && (command.Choice == "true" || command.Choice == "false"), + _ => false + }; + if (!valid) throw new ArgumentException("Invalid setup choice."); + // Baseline security applies regardless of scope or personal orientation progress. + if (command.Operation == "area" && command.TargetId == "security" && command.Choice != nameof(SetupAreaChoice.UseNow)) + throw new ArgumentException("Baseline security cannot be removed from setup scope."); + if (command.Operation == "review") + { + var overview = await GetOverviewAsync(actor, true, ct); + if (!overview.Report.Snapshot.Consistent || overview.Report.Snapshot.Revision != command.EvidenceRevision) throw new AdminAssistConcurrencyException(); + command = command with { ReviewEvidence = new SetupReviewEvidence(catalog.Version, overview.Report.Snapshot.Revision, + overview.Report.Snapshot.AsOfUtc, overview.Report.Required, overview.Report.Verified, overview.Report.Failed, overview.Report.Unknown, overview.Workspace.ScopeRevision) }; + } + await RequireAccessAsync(actor, true, ct); + return await repository.UpdateWorkspaceAsync(actor, command, ct); + } + + public async Task> GetHistoryAsync(AdminAssistActor actor, int skip, int take, CancellationToken ct = default) + { + await RequireAccessAsync(actor, false, ct); + var history = await repository.GetHistoryAsync(actor.DepartmentId, actor.UserId, Math.Max(0, skip), + Math.Clamp(take, 1, Math.Clamp(AdminAssistConfig.MaxHistoryPageSize, 1, 100)), ct); + await RequireAccessAsync(actor, false, ct); + return history; + } + + private async Task RequireAccessAsync(AdminAssistActor actor, bool setup, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + if (!await access.CanAccessAsync(actor, setup, ct).WaitAsync(ct)) throw new UnauthorizedAccessException(); + } + } +} diff --git a/Core/Resgrid.Services/AdminAssist/AdminAssistTraceWriter.cs b/Core/Resgrid.Services/AdminAssist/AdminAssistTraceWriter.cs new file mode 100644 index 000000000..edd6b91d0 --- /dev/null +++ b/Core/Resgrid.Services/AdminAssist/AdminAssistTraceWriter.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Services; + +namespace Resgrid.Services.AdminAssist +{ + public sealed class AdminAssistTraceWriter(IAdminAssistTraceStore store, IProtectedWriteService write, + IDepartmentDataProtectionService protection, IFeatureToggleService flags) : IAdminAssistTraceWriter + { + private static readonly IReadOnlyDictionary, Action)> Fields = + new Dictionary, Action)> + { ["adminassistdispatchtraces.content"] = (row => row.Content, (row, value) => row.Content = value) }; + public async Task PersistAsync(DispatchTraceObservation observation, CancellationToken ct) + { + var prepared = await PrepareAsync(observation, ct); + if (prepared != null) await PersistPreparedAsync(prepared, ct); + } + public async Task PrepareAsync(DispatchTraceObservation observation, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(observation); + // This check runs in the background, never on the emergency sender's critical path. + if (!Config.AdminAssistConfig.CaptureDispatchTraces || + !await AdminAssistFeatureAvailability.IsEnabledAsync(flags, observation.DepartmentId, false, ct)) return null; + var row = new AdminAssistDispatchTraceRow { AdminAssistDispatchTraceId = observation.Id, DepartmentId = observation.DepartmentId, + CallId = observation.CallId, AttemptId = observation.AttemptId, Stage = observation.Stage.ToString(), ResolverVersion = observation.ResolverVersion ?? "legacy-unrecorded", + OccurredOn = observation.OccurredOnUtc, Content = JsonSerializer.Serialize(observation) }; + DispatchTraceEnvelope.Validate(row); + await ProtectAsync(row, ct); + return row; + } + public async Task PersistPreparedAsync(AdminAssistDispatchTraceRow row, CancellationToken ct) + { + DispatchTraceEnvelope.Validate(row); + // A late queue replay must not resurrect a deleted department's derived evidence. A concurrent + // deletion is fenced by SaveTraceAsync's department lock and is retried without acknowledging. + if (!await store.TraceDepartmentExistsAsync(row.DepartmentId, ct)) return; + // Enrollment may have started while this envelope was queued. Recheck the owning write gate; + // existing protected envelopes are preserved even if the subscription has since expired. + await ProtectAsync(row, ct); + await store.SaveTraceAsync(row, ct); + } + private async Task ProtectAsync(AdminAssistDispatchTraceRow row, CancellationToken ct) + { + if (await protection.ShouldEncryptNewWritesAsync(row.DepartmentId).WaitAsync(ct) && await protection.GetPinnedCatalogVersionAsync(row.DepartmentId).WaitAsync(ct) < 30) + throw new InvalidOperationException("Trace protection catalog upgrade required."); + var result = await write.PrepareRecordsEntityWriteAsync(row.DepartmentId, row, null, row.AdminAssistDispatchTraceId, Fields, + () => { row.IsProtected = true; row.ProtectedCatalogVersion = 30; }, null, null, true, ct); + if (result?.Success != true || result.IsProtected && !ProtectedDataEnvelope.HasEnvelopePrefix(row.Content)) throw new UnauthorizedAccessException(); + DispatchTraceEnvelope.Validate(row); + } + } +} diff --git a/Core/Resgrid.Services/AdminAssist/AdminAssistWorklistService.cs b/Core/Resgrid.Services/AdminAssist/AdminAssistWorklistService.cs new file mode 100644 index 000000000..b1a7b6064 --- /dev/null +++ b/Core/Resgrid.Services/AdminAssist/AdminAssistWorklistService.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.AdminAssist; +using Resgrid.Model; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services.AdminAssist +{ + public sealed class AdminAssistWorklistService(IAdminAssistAccessService access, IAdminAssistService assist, + IAdminAssistRepository repository, IUnitOfWork unit, IDomainEventOutboxService outbox, + IRecordsAuthorizationService authorization, IProtectedGrantContext grant, Lazy write, + Lazy read, IDepartmentDataProtectionService protection, TimeProvider clock, + IAuditLogsRepository audits) : IAdminAssistWorklistService + { + private static readonly IReadOnlyDictionary, Action)> Fields = + new Dictionary, Action)> + { ["adminassistfindings.content"] = (row => row.Content, (row, value) => row.Content = value) }; + + public async Task> GetAsync(AdminAssistActor actor, CancellationToken ct = default) + { + await RequireAsync(actor, ct); + var rows = await repository.GetFindingsAsync(actor.DepartmentId, ct); + if (await protection.IsProtectionEnforcedAsync(actor.DepartmentId) && await protection.GetPinnedCatalogVersionAsync(actor.DepartmentId) < 30) + { + foreach (var row in rows) if (!string.IsNullOrEmpty(row.Content)) row.Content = ProtectedDataEnvelope.RedactionValue; + } + else if (rows.Any(row => !string.IsNullOrEmpty(row.Content))) + await read.Value.ResolveRecordsEntitiesForReadAsync(actor.DepartmentId, rows.Select(row => (row, row.AdminAssistFindingId)).ToArray(), Fields, grant.GrantToken, actor.UserId, ct); + await RequireAsync(actor, ct); + return rows; + } + + public async Task VerifyAsync(AdminAssistActor actor, CancellationToken ct = default) + { + await RequireAsync(actor, ct); + var overview = await assist.GetOverviewAsync(actor, false, ct); + if (!overview.Report.Snapshot.Consistent || !long.TryParse(overview.Report.Snapshot.Revision, out var revision)) throw new AdminAssistConcurrencyException(); + if (unit.Transaction != null) throw new InvalidOperationException("Verification owns its metadata transaction."); + var events = new List(); + await unit.CreateOrGetConnectionAsync(ct); + try + { + await repository.LockConfigurationAsync(actor.DepartmentId, ct); + if (await repository.GetConfigurationRevisionAsync(actor.DepartmentId, ct) != revision) throw new AdminAssistConcurrencyException(); + await RequireAsync(actor, ct); + var existing = (await repository.GetFindingsAsync(actor.DepartmentId, ct)).ToDictionary(r => r.RuleId, StringComparer.Ordinal); + var now = clock.GetUtcNow().UtcDateTime; + foreach (var finding in overview.Report.Findings) + { + if (!existing.TryGetValue(finding.RuleId, out var row)) row = new AdminAssistFindingRow + { + AdminAssistFindingId = Guid.NewGuid().ToString("D"), DepartmentId = actor.DepartmentId, + RuleId = finding.RuleId, SubjectId = "department", Result = (int)RuleResult.Unknown + }; + var expected = row.Revision; + var trigger = FindingLifecycle.Observe(row, finding, now); + await repository.SaveFindingAsync(row, expected, ct); + if (trigger.HasValue) + { + var message = await outbox.EnqueueAsync(actor.DepartmentId, "AdminAssist", new DomainEventEnvelope + { + EventName = trigger.Value.ToString(), AggregateType = "AdminAssistFinding", AggregateId = row.AdminAssistFindingId, + AggregateVersion = checked((int)row.Revision), Trigger = trigger, OccurredOn = now, + Payload = new { FindingId = row.AdminAssistFindingId, row.RuleId, row.Episode, row.Result, row.Severity, row.ReviewStatus } + }, ct); + events.Add(message.DomainEventOutboxId); + } + } + await repository.SaveDailySummaryAsync(actor.DepartmentId, overview.Report, overview.CatalogVersion, ct); + unit.CommitChanges(); + } + catch { unit.DiscardChanges(); throw; } + await outbox.DispatchAfterCommitAsync(events, ct); + } + + public async Task ReviewAsync(AdminAssistActor actor, FindingReviewCommand command, CancellationToken ct = default) + { + await RequireAsync(actor, ct); + var now = clock.GetUtcNow().UtcDateTime; + if (command == null || !Guid.TryParseExact(command.FindingId, "D", out _) || command.ExpectedRevision <= 0 || command.Note?.Length > 2000 || + (command.ReviewOnUtc.HasValue && command.ReviewOnUtc.Value.Kind != DateTimeKind.Utc) || + (command.ExceptionUntilUtc.HasValue && command.ExceptionUntilUtc.Value.Kind != DateTimeKind.Utc) || + command.ReviewOnUtc < now || command.ReviewOnUtc > now.AddYears(1)) throw new ArgumentException("Invalid review."); + if (command.OwnerId != null && (!await authorization.IsAssignableMemberAsync(command.OwnerId, actor.DepartmentId) || + !await authorization.IsDepartmentAdminAsync(command.OwnerId, actor.DepartmentId))) throw new ArgumentException("Invalid review owner."); + if (unit.Transaction != null) throw new InvalidOperationException("Review owns its metadata transaction."); + await unit.CreateOrGetConnectionAsync(ct); + try + { + await repository.LockConfigurationAsync(actor.DepartmentId, ct); + var row = (await repository.GetFindingsAsync(actor.DepartmentId, ct)).SingleOrDefault(r => r.AdminAssistFindingId == command.FindingId); + if (row == null) throw new ArgumentException("Finding not found."); + if (row.ReviewStatus == (int)FindingReviewStatus.Resolved || row.Result == (int)RuleResult.Pass || row.Result == (int)RuleResult.NotApplicable) + throw new ArgumentException("Only unresolved findings can enter review."); + if (row.Revision != command.ExpectedRevision) throw new AdminAssistConcurrencyException(); + var previous = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(row)); + var beforeCode = ((FindingReviewStatus)row.ReviewStatus).ToString(); + switch (command.Operation) + { + case "claim": row.OwnerId = actor.UserId; row.ReviewOn = command.ReviewOnUtc; row.ReviewStatus = (int)FindingReviewStatus.Assigned; row.ExceptionUntil = null; break; + case "assign": row.OwnerId = command.OwnerId; row.ReviewOn = command.ReviewOnUtc; row.ReviewStatus = (int)(row.OwnerId == null ? FindingReviewStatus.Unassigned : FindingReviewStatus.Assigned); row.ExceptionUntil = null; break; + case "review": row.ReviewStatus = (int)FindingReviewStatus.InReview; break; + case "exception": + if (row.Result != (int)RuleResult.Fail || string.IsNullOrWhiteSpace(command.Note) || !command.ExceptionUntilUtc.HasValue || command.ExceptionUntilUtc <= now || command.ExceptionUntilUtc > now.AddDays(90)) throw new ArgumentException("An exception needs a reason and an expiry within 90 days."); + row.ReviewStatus = (int)FindingReviewStatus.AcceptedException; row.ExceptionUntil = command.ExceptionUntilUtc; break; + default: throw new ArgumentException("Invalid review operation."); + } + if (command.Note != null) + { + if (await protection.ShouldEncryptNewWritesAsync(actor.DepartmentId) && await protection.GetPinnedCatalogVersionAsync(actor.DepartmentId) < 30) + throw new UnauthorizedAccessException(); + row.Content = command.Note; + var prepared = await write.Value.PrepareRecordsEntityWriteAsync(actor.DepartmentId, row, previous, row.AdminAssistFindingId, + Fields, () => { row.IsProtected = true; row.ProtectedCatalogVersion = 30; }, grant.GrantToken, actor.UserId, false, ct); + if (prepared?.Success != true || prepared.IsProtected && !ProtectedDataEnvelope.HasEnvelopePrefix(row.Content)) throw new UnauthorizedAccessException(); + } + row.Revision++; + await RequireAsync(actor, ct); + await repository.SaveFindingAsync(row, command.ExpectedRevision, ct); + await repository.AppendConfigurationChangeAsync(actor.DepartmentId, actor.UserId, "finding." + row.AdminAssistFindingId, beforeCode, + ((FindingReviewStatus)row.ReviewStatus).ToString(), Guid.NewGuid().ToString("N"), ct); + await audits.SaveOrUpdateAsync(new AuditLog + { + DepartmentId = actor.DepartmentId, ObjectDepartmentId = actor.DepartmentId, UserId = actor.UserId, + LogType = (int)AuditLogTypes.AdminAssistReviewChanged, LoggedOn = now, Successful = true, + ObjectId = row.AdminAssistFindingId, Message = "AdminAssistReviewChanged", + Data = JsonConvert.SerializeObject(new { row.RuleId, row.Revision, operation = command.Operation, + before = beforeCode, after = ((FindingReviewStatus)row.ReviewStatus).ToString(), noteChanged = command.Note != null }) + }, ct); + unit.CommitChanges(); + } + catch { unit.DiscardChanges(); throw; } + } + private async Task RequireAsync(AdminAssistActor actor, CancellationToken ct) + { + if (!await access.CanAccessAsync(actor, false, ct)) throw new UnauthorizedAccessException(); + } + } +} diff --git a/Core/Resgrid.Services/AdminAssist/AdminIdentityEvidenceSource.cs b/Core/Resgrid.Services/AdminAssist/AdminIdentityEvidenceSource.cs new file mode 100644 index 000000000..e14a674f5 --- /dev/null +++ b/Core/Resgrid.Services/AdminAssist/AdminIdentityEvidenceSource.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Helpers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services.AdminAssist +{ + /// Fresh sign-in-capable administrators, including hidden members. No identity or factor secret is projected. + public sealed class AdminIdentityEvidenceSource(IDepartmentMembersRepository members, IDepartmentGroupsRepository groups, + IDepartmentsService departments, IUsersService users, IAuthorizationService visibility) : IAdminAssistEvidenceSource + { + public string SourceId => "AdminIdentity"; + public IReadOnlyList EvidenceIds { get; } = new[] { "activeAdminCount", "adminsWithoutMfa", "groupOnlyAdminsWithoutMfa" }; + public async Task> ReadAsync(AdminAssistActor actor, DateTime now, CancellationToken ct) + { + var department = await departments.GetDepartmentByIdAsync(actor.DepartmentId, true).WaitAsync(ct) ?? throw new InvalidOperationException(); + var rows = (await members.GetAllDepartmentMembersUnlimitedAsync(actor.DepartmentId).WaitAsync(ct))?.ToList() ?? throw new InvalidOperationException(); + var groupRows = (await groups.GetAllGroupsByDepartmentIdAsync(actor.DepartmentId).WaitAsync(ct))?.ToList() ?? throw new InvalidOperationException(); + var limit = Math.Clamp(Config.AdminAssistConfig.MaxEvidenceRows, 1, 10000); + if (department.DepartmentId != actor.DepartmentId || rows.Count + groupRows.Count > limit || rows.Any(m => m.DepartmentId != actor.DepartmentId) || groupRows.Any(g => g.DepartmentId != actor.DepartmentId)) throw new InvalidOperationException(); + var current = rows.Where(m => DepartmentMemberStateHelper.IsCurrentMember(m, actor.DepartmentId)).Select(m => m.UserId).ToHashSet(StringComparer.Ordinal); + var admins = rows.Where(m => current.Contains(m.UserId) && (m.IsAdmin.GetValueOrDefault() || department.ManagingUserId == m.UserId)).Select(m => m.UserId).ToHashSet(StringComparer.Ordinal); + var groupDataComplete = groupRows.All(g => g.Members != null && g.Members.All(m => m.DepartmentId == actor.DepartmentId && m.DepartmentGroupId == g.DepartmentGroupId)); + var groupOnly = groupDataComplete ? groupRows.SelectMany(g => g.Members).Where(m => m.IsAdmin.GetValueOrDefault() && current.Contains(m.UserId) && !admins.Contains(m.UserId)).Select(m => m.UserId).ToHashSet(StringComparer.Ordinal) : new HashSet(); + int adminMissing = 0, groupMissing = 0; + foreach (var id in admins.Concat(groupOnly)) + { + ct.ThrowIfCancellationRequested(); + if (!await visibility.CanUserViewPersonAsync(actor.UserId, id, actor.DepartmentId)) throw new UnauthorizedAccessException(); + var user = users.GetUserById(id, true) ?? throw new InvalidOperationException(); + if (user.TwoFactorEnabled) continue; + if (admins.Contains(id)) adminMissing++; else groupMissing++; + } + return new[] { + new ConfigurationEvidence("activeAdminCount", EvidenceState.Known, SourceId, "1", now, Number: admins.Count), + new ConfigurationEvidence("adminsWithoutMfa", EvidenceState.Known, SourceId, "1", now, Number: adminMissing), + new ConfigurationEvidence("groupOnlyAdminsWithoutMfa", groupDataComplete ? EvidenceState.Known : EvidenceState.Unknown, SourceId, "1", now, Number: groupDataComplete ? groupMissing : null, ReasonCode: groupDataComplete ? null : "GroupMembershipUnavailable") + }; + } + } +} diff --git a/Core/Resgrid.Services/AdminAssist/AdministrativeReferenceEvidenceSource.cs b/Core/Resgrid.Services/AdminAssist/AdministrativeReferenceEvidenceSource.cs new file mode 100644 index 000000000..abb897553 --- /dev/null +++ b/Core/Resgrid.Services/AdminAssist/AdministrativeReferenceEvidenceSource.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Services; + +namespace Resgrid.Services.AdminAssist +{ + /// Metadata existence/expiry checks only. Referenced content is neither retrieved nor certified. + public sealed class AdministrativeReferenceEvidenceSource(IDepartmentSettingsService settings, IAdministrativeReferenceStore references) : IAdminAssistEvidenceSource + { + public string SourceId => "AdministrativeReferences"; + public IReadOnlyList EvidenceIds { get; } = new[] { "declaredPolicyReferences", "unavailablePolicyReferences", "policyReferencesExpiring30Days", "declaredSiteReferences", "unavailableSiteReferences", "declaredContinuityReferences" }; + public async Task> ReadAsync(AdminAssistActor actor, DateTime now, CancellationToken ct) + { + var profile = await settings.GetOperatingProfileAsync(actor.DepartmentId).WaitAsync(ct) ?? throw new InvalidOperationException(); + Validator.ValidateObject(profile, new ValidationContext(profile), true); + int[] Parse(IEnumerable values) => values.Select(v => int.Parse(v, NumberStyles.None, CultureInfo.InvariantCulture)).Distinct().ToArray(); + var documents = Parse(profile.StaffingPolicyReferences.Concat(profile.QualificationPolicyReferences).Concat(profile.ContinuityProcedureReferences)); + var groups = Parse(profile.SiteGroupReferences); + var result = await references.ReadAdministrativeReferencesAsync(actor.DepartmentId, documents, groups, now, ct) ?? throw new InvalidOperationException(); + if (result != await references.ReadAdministrativeReferencesAsync(actor.DepartmentId, documents, groups, now, ct)) throw new InvalidOperationException("Reference evidence changed."); + ConfigurationEvidence Count(string id, int value) => new(id, EvidenceState.Known, SourceId, "references-v1", now, Number: value); + return new[] { Count("declaredPolicyReferences", result.PolicyReferences), Count("unavailablePolicyReferences", result.UnavailablePolicies), + Count("policyReferencesExpiring30Days", result.ExpiringPolicies), Count("declaredSiteReferences", result.SiteReferences), + Count("unavailableSiteReferences", result.UnavailableSites), Count("declaredContinuityReferences", profile.ContinuityProcedureReferences.Distinct().Count()) }; + } + } +} diff --git a/Core/Resgrid.Services/AdminAssist/CapabilityEvidenceSource.cs b/Core/Resgrid.Services/AdminAssist/CapabilityEvidenceSource.cs new file mode 100644 index 000000000..5fbf6c4f6 --- /dev/null +++ b/Core/Resgrid.Services/AdminAssist/CapabilityEvidenceSource.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.AdminAssist; + +namespace Resgrid.Services.AdminAssist +{ + /// Distinguishes a known unavailable optional capability from an availability-read failure. + public sealed class CapabilityEvidenceSource(IAdminAssistAccessService access, IAdminAssistCatalog catalog) : IAdminAssistEvidenceSource + { + public string SourceId => "CapabilityAvailability"; + public IReadOnlyList EvidenceIds { get; } = new[] { "maintenanceAvailable" }; + public async Task> ReadAsync(AdminAssistActor actor, DateTime now, CancellationToken ct) + { + var capability = catalog.Capabilities.First(c => c.Location.Controller == "WorkOrders"); + var result = await access.GetCapabilityAsync(actor, capability.Id, ct) ?? throw new InvalidOperationException(); + var unavailable = result.State == EvidenceState.Unavailable && result.ReasonCodes.Any(r => + r == "FeatureNotEnabled" || r == "ModuleDisabled" || r == "AddonRequired.ReadinessPro"); + var known = result.State == EvidenceState.Known || unavailable; + return new[] { new ConfigurationEvidence("maintenanceAvailable", known ? EvidenceState.Known : EvidenceState.Unknown, + SourceId, catalog.Version, now, Boolean: known ? result.State == EvidenceState.Known : null, ReasonCode: known ? null : "AvailabilityUnknown") }; + } + } +} diff --git a/Core/Resgrid.Services/AdminAssist/CapacityEvidenceSource.cs b/Core/Resgrid.Services/AdminAssist/CapacityEvidenceSource.cs new file mode 100644 index 000000000..20d58b704 --- /dev/null +++ b/Core/Resgrid.Services/AdminAssist/CapacityEvidenceSource.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Services; + +namespace Resgrid.Services.AdminAssist +{ + public sealed class CapacityEvidenceSource(ISubscriptionsService subscriptions, ILimitsService limits) : IAdminAssistEvidenceSource + { + public string SourceId => "SubscriptionCapacity"; + public IReadOnlyList EvidenceIds { get; } = new[] { "personnelUtilization", "unitUtilization", "personnelHeadroom", "unitHeadroom", "capacityKind", "capacityPersonnelCount", "capacityUnitCount", "capacityPersonnelLimit", "capacityUnitLimit", "capacityEntityLimit" }; + public async Task> ReadAsync(AdminAssistActor actor, DateTime now, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(Config.SystemBehaviorConfig.BillingApiBaseUrl) || string.IsNullOrWhiteSpace(Config.ApiConfig.BackendInternalApikey)) + throw new InvalidOperationException("Subscription metadata unavailable."); + var plan = await subscriptions.GetCurrentPlanForDepartmentAsync(actor.DepartmentId, true).WaitAsync(ct); + var counts = await subscriptions.GetPlanCountsForDepartmentAsync(actor.DepartmentId).WaitAsync(ct); + if (plan?.PlanLimits == null || plan.PlanLimits.Count == 0 || counts == null) throw new InvalidOperationException("Subscription metadata unavailable."); + var current = await limits.GetLimitsForEntityPlanWithFallbackAsync(actor.DepartmentId, true).WaitAsync(ct) + ?? throw new InvalidOperationException("Subscription limits unavailable."); + // Independent billing reads must agree; a moving count or stale plan is not a verified capacity snapshot. + if (current.PersonnelCount != counts.UsersCount || current.UnitsCount != counts.UnitsCount || current.IsEntityPlan != (plan.PlanId >= 36)) + throw new InvalidOperationException("Subscription metadata changed during the read."); + if (current.IsEntityPlan ? current.EntityTotal != plan.GetLimitForTypeAsInt(PlanLimitTypes.Entities) : + current.PersonnelLimit != plan.GetLimitForTypeAsInt(PlanLimitTypes.Personnel) || current.UnitsLimit != plan.GetLimitForTypeAsInt(PlanLimitTypes.Units)) + throw new InvalidOperationException("Subscription limits changed during the read."); + int personnelCap = current.IsEntityPlan ? current.EntityTotal : current.PersonnelLimit; + int unitCap = current.IsEntityPlan ? current.EntityTotal : current.UnitsLimit; + int personnel = current.IsEntityPlan ? counts.GetEntitiesCount() : counts.UsersCount; + int units = current.IsEntityPlan ? counts.GetEntitiesCount() : counts.UnitsCount; + ConfigurationEvidence Fact(string id, int cap, int count, bool headroom) => cap > 0 && count >= 0 + ? new(id, EvidenceState.Known, SourceId, plan.PlanId.ToString(), now, Number: headroom ? cap - count : (decimal)count / cap) + : new(id, EvidenceState.Unknown, SourceId, "1", now, ReasonCode: "LimitNotQuantified"); + var version = plan.PlanId.ToString(System.Globalization.CultureInfo.InvariantCulture); + ConfigurationEvidence Count(string id, int value) => new(id, value >= 0 ? EvidenceState.Known : EvidenceState.Unknown, SourceId, version, now, Number: value >= 0 ? value : null); + return new[] { + new ConfigurationEvidence("capacityKind", EvidenceState.Known, SourceId, version, now, Code: current.IsEntityPlan ? "entities" : "separate"), + Count("capacityPersonnelCount", counts.UsersCount), Count("capacityUnitCount", counts.UnitsCount), + Count("capacityPersonnelLimit", current.PersonnelLimit), Count("capacityUnitLimit", current.UnitsLimit), Count("capacityEntityLimit", current.EntityTotal), Fact("personnelUtilization", personnelCap, personnel, false), Fact("unitUtilization", unitCap, units, false), + Fact("personnelHeadroom", personnelCap, personnel, true), Fact("unitHeadroom", unitCap, units, true) }; + } + } +} diff --git a/Core/Resgrid.Services/AdminAssist/ConfigurationChangeJournal.cs b/Core/Resgrid.Services/AdminAssist/ConfigurationChangeJournal.cs new file mode 100644 index 000000000..ffe3929eb --- /dev/null +++ b/Core/Resgrid.Services/AdminAssist/ConfigurationChangeJournal.cs @@ -0,0 +1,50 @@ +using System; +using System.Diagnostics; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Services; + +namespace Resgrid.Services.AdminAssist +{ + /// Configuration write, safe audit and revision are one transaction; failures never produce a false successful audit. + public sealed class ConfigurationChangeJournal(IUnitOfWork unit, IAdminAssistRepository repository, + IAuditLogsRepository audits, IProtectedGrantContext principal, TimeProvider clock) : IConfigurationChangeJournal + { + public async Task ExecuteAsync(int departmentId, string binding, Func> read, + Func> write, CancellationToken ct) + { + if (departmentId <= 0 || string.IsNullOrWhiteSpace(binding) || binding.Length > 192) throw new ArgumentException("Invalid configuration scope."); + var owns = unit.Transaction == null; + await unit.CreateOrGetConnectionAsync(ct); + try + { + await repository.LockConfigurationAsync(departmentId, ct); + var before = await read(); + var result = await write(); + var after = await read(); + if (before?.Fingerprint != after?.Fingerprint) + { + var correlation = Activity.Current?.TraceId.ToString() ?? Guid.NewGuid().ToString("N"); + var actor = principal.IsWorkloadCaller ? null : principal.UserId; + var revision = await repository.AppendConfigurationChangeAsync(departmentId, actor, binding, before?.Values, after?.Values, correlation, ct); + await audits.SaveOrUpdateAsync(new AuditLog + { + DepartmentId = departmentId, ObjectDepartmentId = departmentId, UserId = actor, + LogType = (int)AuditLogTypes.DepartmentConfigurationChanged, LoggedOn = clock.GetUtcNow().UtcDateTime, + Successful = true, ObjectId = binding, Message = "ConfigurationChanged", + Data = JsonSerializer.Serialize(new { binding, revision, correlation, before = before?.Values, after = after?.Values, + secretChange = before?.Values == after?.Values, source = principal.IsWorkloadCaller ? "workload" : "attended" }) + }, ct); + } + if (owns) unit.CommitChanges(); + return result; + } + catch { if (owns) unit.DiscardChanges(); throw; } + } + } +} diff --git a/Core/Resgrid.Services/AdminAssist/ConfigurationImpactService.cs b/Core/Resgrid.Services/AdminAssist/ConfigurationImpactService.cs new file mode 100644 index 000000000..c3f3751ba --- /dev/null +++ b/Core/Resgrid.Services/AdminAssist/ConfigurationImpactService.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.AdminAssist; +using Resgrid.Model.AdminAssist; + +namespace Resgrid.Services.AdminAssist +{ + public sealed class ConfigurationImpactService(IAdminAssistAccessService access, IConfigurationSnapshotProvider snapshots, + IAdminAssistCatalog catalog, IAdminAssistRepository repository, TimeProvider clock, IEnumerable providers = null) : IConfigurationImpactService + { + public async Task PreviewCapacityAsync(AdminAssistActor actor, CapacityImpactRequest request, CancellationToken ct = default) + { + if (!await access.CanAccessAsync(actor, false, ct)) throw new UnauthorizedAccessException(); + var snapshot = await snapshots.ReadAsync(actor, ct); + var result = CapacityImpactEvaluator.Evaluate(snapshot, request, clock.GetUtcNow().UtcDateTime, + TimeSpan.FromSeconds(Math.Clamp(Config.AdminAssistConfig.EvidenceFreshnessSeconds, 1, 300))); + if ((await repository.GetConfigurationRevisionAsync(actor.DepartmentId, ct)).ToString(System.Globalization.CultureInfo.InvariantCulture) != snapshot.Revision) throw new AdminAssistConcurrencyException(); + if (!await access.CanAccessAsync(actor, false, ct)) throw new UnauthorizedAccessException(); + return result; + } + + public async Task PreviewAsync(AdminAssistActor actor, ConfigurationImpactRequest request, CancellationToken ct = default) + { + if (!await access.CanAccessAsync(actor, false, ct)) throw new UnauthorizedAccessException(); + using var bounded = CancellationTokenSource.CreateLinkedTokenSource(ct); + bounded.CancelAfter(TimeSpan.FromSeconds(Math.Clamp(Config.AdminAssistConfig.SnapshotTimeoutSeconds, 1, 60))); + ct = bounded.Token; + var snapshot = await snapshots.ReadAsync(actor, ct); + var result = new ConfigurationImpactEvaluator(catalog).Evaluate(snapshot, request, clock.GetUtcNow().UtcDateTime, + TimeSpan.FromSeconds(Math.Clamp(Config.AdminAssistConfig.EvidenceFreshnessSeconds, 1, 300))); + foreach (var provider in providers ?? Array.Empty()) + { + if (!provider.Supports(request.SettingId)) continue; + var operational = await provider.EvaluateAsync(actor, snapshot, request, ct); + result = result with { Metrics = result.Metrics.Concat(operational.Metrics).ToArray(), + LimitKeys = result.LimitKeys.Concat(operational.LimitKeys).Distinct().ToArray(), EvaluatorVersion = result.EvaluatorVersion + ";" + operational.Version }; + } + if ((await repository.GetConfigurationRevisionAsync(actor.DepartmentId, ct)).ToString(System.Globalization.CultureInfo.InvariantCulture) != snapshot.Revision) + throw new AdminAssistConcurrencyException(); + if (!await access.CanAccessAsync(actor, false, ct)) throw new UnauthorizedAccessException(); + return result; + } + } +} diff --git a/Core/Resgrid.Services/AdminAssist/ConfigurationSnapshotProvider.cs b/Core/Resgrid.Services/AdminAssist/ConfigurationSnapshotProvider.cs new file mode 100644 index 000000000..f1faec9f6 --- /dev/null +++ b/Core/Resgrid.Services/AdminAssist/ConfigurationSnapshotProvider.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Config; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Services; + +namespace Resgrid.Services.AdminAssist +{ + /// Request-scoped metadata, with no shared plaintext cache and no inference/provider calls. + public sealed class ConfigurationSnapshotProvider(IEnumerable sources, + IAdminAssistRepository repository, IRecordsAuthorizationService authorization, IAdminAssistCatalog catalog, + TimeProvider clock) : IConfigurationSnapshotProvider + { + public async Task ReadAsync(AdminAssistActor actor, CancellationToken ct = default) + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(TimeSpan.FromSeconds(Math.Clamp(AdminAssistConfig.SnapshotTimeoutSeconds, 1, 60))); + var token = timeout.Token; + await AuthorizeAsync(actor, token); + var now = clock.GetUtcNow().UtcDateTime; + var before = await repository.GetConfigurationRevisionAsync(actor.DepartmentId, token); + var evidence = new Dictionary(StringComparer.Ordinal); + foreach (var source in sources) + { + token.ThrowIfCancellationRequested(); + try + { + var values = await source.ReadAsync(actor, now, token).WaitAsync(token); + foreach (var value in values) + { + if (!source.EvidenceIds.Contains(value.Id) || evidence.ContainsKey(value.Id)) + throw new InvalidOperationException("Duplicate or undeclared evidence id."); + evidence.Add(value.Id, value); + } + } + catch (OperationCanceledException) { throw; } + catch (UnauthorizedAccessException) + { + foreach (var id in source.EvidenceIds) + evidence[id] = new ConfigurationEvidence(id, EvidenceState.Redacted, source.SourceId, + before.ToString(CultureInfo.InvariantCulture), now, ReasonCode: "SourceAccessUnavailable"); + } + catch (Exception) + { + // Source failures must not convert an inaccessible or failed query into a zero count. + foreach (var id in source.EvidenceIds) + evidence[id] = new ConfigurationEvidence(id, EvidenceState.Unknown, source.SourceId, + before.ToString(CultureInfo.InvariantCulture), now, ReasonCode: "SourceUnavailable"); + } + } + var after = await repository.GetConfigurationRevisionAsync(actor.DepartmentId, token); + await AuthorizeAsync(actor, token); + foreach (var id in catalog.Rules.SelectMany(r => r.AppliesWhen.Concat(r.FailsWhen)).Select(c => c.EvidenceId).Distinct()) + if (!evidence.ContainsKey(id)) evidence[id] = new ConfigurationEvidence(id, EvidenceState.Unknown, + "capability-gap", after.ToString(CultureInfo.InvariantCulture), now, ReasonCode: "EvidenceNotObserved"); + return new ConfigurationSnapshot(actor.DepartmentId, actor.UserId, + after.ToString(CultureInfo.InvariantCulture), now, before == after, + new ReadOnlyDictionary(evidence)); + } + + private async Task AuthorizeAsync(AdminAssistActor actor, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + if (actor == null || actor.DepartmentId <= 0 || string.IsNullOrWhiteSpace(actor.UserId) || + !await authorization.IsActiveMemberAsync(actor.UserId, actor.DepartmentId).WaitAsync(ct) || + !await authorization.IsDepartmentAdminAsync(actor.UserId, actor.DepartmentId).WaitAsync(ct)) throw new UnauthorizedAccessException(); + } + } +} diff --git a/Core/Resgrid.Services/AdminAssist/DepartmentSettingsService.OperatingProfile.cs b/Core/Resgrid.Services/AdminAssist/DepartmentSettingsService.OperatingProfile.cs new file mode 100644 index 000000000..f96dc5d67 --- /dev/null +++ b/Core/Resgrid.Services/AdminAssist/DepartmentSettingsService.OperatingProfile.cs @@ -0,0 +1,57 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.AdminAssist; + +namespace Resgrid.Services +{ + public partial class DepartmentSettingsService + { + public async Task GetOperatingProfileAsync(int departmentId) + { + var row = await _departmentSettingsRepository.GetDepartmentSettingByIdTypeAsync(departmentId, DepartmentSettingTypes.DepartmentOperatingProfile); + return row == null ? new DepartmentOperatingProfile() : ObjectSerialization.Deserialize(row.Setting) + ?? throw new InvalidOperationException("Operating profile could not be read."); + } + + public async Task SetOperatingProfileAsync(int departmentId, DepartmentOperatingProfile profile, string actingUserId, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(profile); + Validator.ValidateObject(profile, new ValidationContext(profile), true); + if (_moduleUnit == null || _operatingProfileRepository == null || _operatingProfileAuthorization == null) + throw new InvalidOperationException("Operating profile persistence is not configured."); + async Task RequireAdminAsync() + { + cancellationToken.ThrowIfCancellationRequested(); + if (departmentId <= 0 || string.IsNullOrWhiteSpace(actingUserId) || + _moduleFlags == null || !await AdminAssist.AdminAssistFeatureAvailability.CanConfigureOperatingProfileAsync(_moduleFlags.Value, departmentId, cancellationToken) || + !await _operatingProfileAuthorization.Value.IsActiveMemberAsync(actingUserId, departmentId) || + !await _operatingProfileAuthorization.Value.IsDepartmentAdminAsync(actingUserId, departmentId)) throw new UnauthorizedAccessException(); + } + await RequireAdminAsync(); + // Keep the caller's proposed values/revision intact on a validation or concurrency failure. + var proposed = ObjectSerialization.Deserialize(ObjectSerialization.Serialize(profile)); + var owns = _moduleUnit.Transaction == null; + await _moduleUnit.CreateOrGetConnectionAsync(cancellationToken); + try + { + await _operatingProfileRepository.LockConfigurationAsync(departmentId, cancellationToken); + var current = await GetOperatingProfileAsync(departmentId); + if (current.Revision != proposed.Revision) throw new AdminAssistConcurrencyException(); + var now = DateTime.UtcNow; + if (!await _operatingProfileRepository.ValidateOperatingProfileReferencesAsync(departmentId, proposed, now, cancellationToken)) + throw new ValidationException("Profile.InvalidReferences"); + proposed.Revision = checked(current.Revision + 1); + proposed.ReviewedOnUtc = now; + await RequireAdminAsync(); + var saved = await SaveOrUpdateSettingAsync(departmentId, ObjectSerialization.Serialize(proposed), DepartmentSettingTypes.DepartmentOperatingProfile, cancellationToken); + if (owns) _moduleUnit.CommitChanges(); + return saved; + } + catch { if (owns) _moduleUnit.DiscardChanges(); throw; } + } + } +} diff --git a/Core/Resgrid.Services/AdminAssist/DispatchEvidenceSource.cs b/Core/Resgrid.Services/AdminAssist/DispatchEvidenceSource.cs new file mode 100644 index 000000000..a722f68f5 --- /dev/null +++ b/Core/Resgrid.Services/AdminAssist/DispatchEvidenceSource.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Services; + +namespace Resgrid.Services.AdminAssist +{ + public sealed class DispatchEvidenceSource(IRunCardsService runCards, ICheckInTimerService timers, + IWeatherAlertService weather, ICommunicationTestService communication) : IAdminAssistEvidenceSource + { + public string SourceId => "DispatchConfiguration"; + public IReadOnlyList EvidenceIds { get; } = new[] { "runCardCount", "checkInTimerCount", "weatherZoneCount", "communicationTestAgeDays" }; + public async Task> ReadAsync(AdminAssistActor actor, DateTime now, CancellationToken ct) + { + var result = new List(); + async Task Read(string id, Func> read) + { + ct.ThrowIfCancellationRequested(); + try + { + var number = await read().WaitAsync(ct); + result.Add(new ConfigurationEvidence(id, number.HasValue ? EvidenceState.Known : EvidenceState.Unknown, SourceId, "1", now, + Number: number, ReasonCode: number.HasValue ? null : "SourceUnavailable")); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch (Exception) { result.Add(new ConfigurationEvidence(id, EvidenceState.Unknown, SourceId, "1", now, ReasonCode: "SourceUnavailable")); } + } + await Read("runCardCount", async () => (await runCards.GetAllRunCardsForDepartmentAsync(actor.DepartmentId, true))?.Count); + await Read("checkInTimerCount", async () => (await timers.GetTimerConfigsForDepartmentAsync(actor.DepartmentId))?.Count); + await Read("weatherZoneCount", async () => (await weather.GetZonesByDepartmentIdAsync(actor.DepartmentId))?.Count); + await Read("communicationTestAgeDays", async () => + { + var runs = (await communication.GetRunsByDepartmentIdAsync(actor.DepartmentId))?.ToList(); + if (runs == null || runs.Count > Math.Clamp(Config.AdminAssistConfig.MaxEvidenceRows, 1, 10000)) return null; + // An untargeted, completed run only establishes test recency, never delivery to every member. + var completed = runs.Where(r => r.DepartmentId == actor.DepartmentId && r.Status == (int)CommunicationTestRunStatus.Completed && + r.CompletedOn.HasValue && r.CompletedOn <= now && r.TargetedUserIds?.Trim() == "null").Select(r => r.CompletedOn.Value).ToList(); + return completed.Count == 0 ? 36500m : (decimal)(now - completed.Max()).TotalDays; + }); + return result; + } + } +} diff --git a/Core/Resgrid.Services/AdminAssist/DispatchImpactService.cs b/Core/Resgrid.Services/AdminAssist/DispatchImpactService.cs new file mode 100644 index 000000000..c75ebf7fc --- /dev/null +++ b/Core/Resgrid.Services/AdminAssist/DispatchImpactService.cs @@ -0,0 +1,158 @@ +using System; +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 Newtonsoft.Json; +using Resgrid.Model; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services.AdminAssist +{ + /// Request-local routing simulation. No communication, workflow or configuration write dependency. + public sealed class DispatchImpactService(IAdminAssistAccessService access, IAdminAssistRepository repository, + IAdminAssistCatalog catalog, ICallsService calls, IDepartmentsService departments, IDepartmentGroupsRepository groups, + IUnitsRepository units, IUnitStateRoleRepository crews, IPersonnelRolesRepository roles, IPersonnelRoleUsersRepository roleMembers, + IDepartmentSettingsRepository settings, IShiftsService shifts, IAuthorizationService visibility, + IRecordsAuthorizationService authorization, TimeProvider clock, ICallDispatchesRepository directRoutes, + ICallDispatchGroupRepository groupRoutes, ICallDispatchUnitRepository unitRoutes, ICallDispatchRoleRepository roleRoutes) : IDispatchImpactService + { + private sealed record Inputs(IReadOnlyList Routes, bool Shift, bool Crew, bool UnitGroup); + private static readonly string[] Limits = { "Impact.NoMutation", "Impact.DispatchScope", "Impact.DispatchTime", "Impact.DispatchDelivery", "Impact.Window" }; + public async Task PreviewAsync(AdminAssistActor actor, DispatchImpactRequest request, CancellationToken ct = default) + { + if (!await access.CanAccessAsync(actor, false, ct)) throw new UnauthorizedAccessException(); + var now = clock.GetUtcNow().UtcDateTime; + if (request == null || request.CallId <= 0 || request.SimulationTimeUtc.Kind != DateTimeKind.Utc || + (request.SimulationTimeUtc - now).Duration() > TimeSpan.FromDays(7)) throw new ArgumentException("Choose an explicit UTC time within seven days of now."); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(TimeSpan.FromSeconds(Math.Clamp(Config.AdminAssistConfig.SnapshotTimeoutSeconds, 1, 60))); + ct = timeout.Token; + var revision = (await repository.GetConfigurationRevisionAsync(actor.DepartmentId, ct)).ToString(CultureInfo.InvariantCulture); + if (revision != request.ExpectedRevision) throw new AdminAssistConcurrencyException(); + var entry = catalog.Settings.Single(s => s.Id == "setting.DispatchShiftInsteadOfGroup"); + var metrics = new List(); + try + { + var input = await ReadAsync(actor, request, ct); + var before = Resolve(input, request.SimulationTimeUtc, input.Shift, input.Crew, input.UnitGroup); + var after = Resolve(input, request.SimulationTimeUtc, request.ShiftInsteadOfGroup, request.UnitCrew, request.UnitGroup); + // Source routes, memberships, crews and schedules are not all in the configuration journal yet. + // Compare a second fresh source vector, rather than claim revision-only consistency. + if (Fingerprint(input) != Fingerprint(await ReadAsync(actor, request, ct))) throw new AdminAssistConcurrencyException(); + var oldPeople = before.SelectedUserIds.ToHashSet(StringComparer.Ordinal); + var newPeople = after.SelectedUserIds.ToHashSet(StringComparer.Ordinal); + void Count(string key, int oldCount, int newCount) => metrics.Add(new("Impact." + key, EvidenceState.Known, oldCount, newCount)); + Count("DispatchPeople", oldPeople.Count, newPeople.Count); + Count("DispatchAttempts", before.SelectedUserIds.Count, after.SelectedUserIds.Count); + Count("DispatchAdded", 0, newPeople.Except(oldPeople).Count()); + Count("DispatchRemoved", 0, oldPeople.Except(newPeople).Count()); + var emptyShiftGroups = input.Routes.Where(r => r.Kind == DispatchRouteKind.Group && r.OnDutyMembers.Count == 0).Select(r => r.SourceId).Distinct().Count(); + Count("DispatchFallback", input.Shift ? emptyShiftGroups : 0, request.ShiftInsteadOfGroup ? emptyShiftGroups : 0); + Count("DispatchSample", 1, 1); + } + catch (AdminAssistConcurrencyException) { throw; } + catch (UnauthorizedAccessException) { throw; } + catch (OperationCanceledException) { throw; } + catch (Exception) + { + metrics.Clear(); + metrics.Add(new("Impact.DispatchPeople", EvidenceState.Unknown, null, null, "SourceUnavailable")); + } + if ((await repository.GetConfigurationRevisionAsync(actor.DepartmentId, ct)).ToString(CultureInfo.InvariantCulture) != revision) throw new AdminAssistConcurrencyException(); + if (!await access.CanAccessAsync(actor, false, ct)) throw new UnauthorizedAccessException(); + return new(entry.Id, revision, request.SimulationTimeUtc, "dispatch-impact-v1/resolver-" + DispatchRecipientResolver.Version, + entry.Impact, metrics, Array.Empty(), Limits, entry.Location.Url); + } + + private async Task ReadAsync(AdminAssistActor actor, DispatchImpactRequest request, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + var call = await calls.GetCallByIdAsync(request.CallId, true).WaitAsync(ct); + if (call == null || call.DepartmentId != actor.DepartmentId || !await authorization.CanReadSourceCallAsync(actor.UserId, actor.DepartmentId, call)) throw new UnauthorizedAccessException(); + // Fresh route-only reads: the legacy population helper turns null sources into empty routes. + // Never mutate its returned call or expose narrative/attachment/protected payloads in this report. + call = new Call { CallId = call.CallId, DepartmentId = call.DepartmentId, + Dispatches = (await directRoutes.GetCallDispatchesByCallIdAsync(call.CallId).WaitAsync(ct))?.ToList() ?? throw new InvalidOperationException(), + GroupDispatches = (await groupRoutes.GetAllCallDispatchGroupByCallIdAsync(call.CallId).WaitAsync(ct))?.ToList() ?? throw new InvalidOperationException(), + UnitDispatches = (await unitRoutes.GetCallUnitDispatchesByCallIdAsync(call.CallId).WaitAsync(ct))?.ToList() ?? throw new InvalidOperationException(), + RoleDispatches = (await roleRoutes.GetCallRoleDispatchesByCallIdAsync(call.CallId).WaitAsync(ct))?.ToList() ?? throw new InvalidOperationException() }; + var budget = Math.Clamp(Config.AdminAssistConfig.MaxEvidenceRows, 1, 10000); + void Consume(int count) { budget -= count; if (budget < 0) throw new InvalidOperationException("Routing evidence bound exceeded."); } + Consume(call.Dispatches.Count + call.GroupDispatches.Count + call.UnitDispatches.Count + call.RoleDispatches.Count); + var options = (await settings.GetAllByDepartmentIdAsync(actor.DepartmentId).WaitAsync(ct))?.ToList() ?? throw new InvalidOperationException(); + bool Setting(DepartmentSettingTypes type) + { + var row = options.SingleOrDefault(s => s.DepartmentId == actor.DepartmentId && s.SettingType == (int)type); + return row == null ? false : bool.Parse(row.Setting); + } + var currentShift = Setting(DepartmentSettingTypes.DispatchShiftInsteadOfGroup); + var currentCrew = Setting(DepartmentSettingTypes.UnitDispatchAlsoDispatchToAssignedPersonnel); + var currentGroup = Setting(DepartmentSettingTypes.UnitDispatchAlsoDispatchToGroup); + var ownedGroups = (await groups.GetAllGroupsByDepartmentIdAsync(actor.DepartmentId).WaitAsync(ct))?.ToList() ?? throw new InvalidOperationException(); + Consume(ownedGroups.Count + ownedGroups.Sum(g => g.Members?.Count ?? 0)); + var groupMap = ownedGroups.ToDictionary(g => g.DepartmentGroupId); + string[] Members(int id) + { + if (!groupMap.TryGetValue(id, out var group) || group.DepartmentId != actor.DepartmentId) throw new UnauthorizedAccessException(); + if (group.Members == null) throw new InvalidOperationException(); + Consume(group.Members.Count); + return group.Members.Select(m => m.UserId).ToArray(); + } + var active = new List(); + if ((currentShift || request.ShiftInsteadOfGroup) && call.GroupDispatches.Count > 0) + { + var department = await departments.GetDepartmentByIdAsync(actor.DepartmentId, true).WaitAsync(ct) ?? throw new InvalidOperationException(); + var local = TimeZoneInfo.ConvertTimeFromUtc(request.SimulationTimeUtc, TimeZoneInfo.FindSystemTimeZoneById(department.TimeZone)); + var schedules = await shifts.ReadSchedulesForAdministrationAsync(actor.DepartmentId, local.Date.AddDays(-3), local.Date, request.SimulationTimeUtc, budget, ct) ?? throw new InvalidOperationException(); + active = schedules.Where(s => s.IsActive).SelectMany(s => s.Roster).ToList(); + Consume(active.Count); + } + var routes = new List(); + foreach (var dispatch in call.Dispatches) routes.Add(new(DispatchRouteKind.Direct, "direct", new[] { dispatch.UserId })); + foreach (var dispatch in call.GroupDispatches) + { + var members = Members(dispatch.DepartmentGroupId); + routes.Add(new(DispatchRouteKind.Group, dispatch.DepartmentGroupId.ToString(CultureInfo.InvariantCulture), members, + OnDutyMembers: ShiftRosterGroups.Select(dispatch.DepartmentGroupId, active, members))); + } + foreach (var dispatch in call.UnitDispatches) + { + var unit = await units.GetByIdAsync(dispatch.UnitId).WaitAsync(ct); + if (unit == null || unit.DepartmentId != actor.DepartmentId || !await visibility.CanUserViewUnitAsync(actor.UserId, unit.UnitId)) throw new UnauthorizedAccessException(); + if (currentCrew || request.UnitCrew) + { + var members = (await crews.GetCurrentRolesForUnitAsync(unit.UnitId).WaitAsync(ct))?.ToList() ?? throw new InvalidOperationException(); + Consume(members.Count); + routes.Add(new(DispatchRouteKind.UnitCrew, unit.UnitId.ToString(CultureInfo.InvariantCulture), members.Select(m => m.UserId).ToArray())); + } + if ((currentGroup || request.UnitGroup) && unit.StationGroupId.HasValue) + routes.Add(new(DispatchRouteKind.UnitGroup, unit.StationGroupId.Value.ToString(CultureInfo.InvariantCulture), Members(unit.StationGroupId.Value))); + } + foreach (var dispatch in call.RoleDispatches) + { + var role = await roles.GetRoleByRoleIdAsync(dispatch.RoleId).WaitAsync(ct); + if (role == null || role.DepartmentId != actor.DepartmentId) throw new UnauthorizedAccessException(); + var members = (await roleMembers.GetAllMembersOfRoleAsync(dispatch.RoleId).WaitAsync(ct))?.ToList() ?? throw new InvalidOperationException(); + Consume(members.Count); + routes.Add(new(DispatchRouteKind.Role, dispatch.RoleId.ToString(CultureInfo.InvariantCulture), members.Select(m => m.UserId).ToArray())); + } + foreach (var userId in routes.SelectMany(r => r.Members.Concat(r.OnDutyMembers ?? Array.Empty())).Distinct(StringComparer.Ordinal)) + { + ct.ThrowIfCancellationRequested(); + if (string.IsNullOrWhiteSpace(userId) || !await authorization.IsAssignableMemberAsync(userId, actor.DepartmentId) || + !await visibility.CanUserViewPersonAsync(actor.UserId, userId, actor.DepartmentId)) throw new UnauthorizedAccessException(); + } + return new(routes, currentShift, currentCrew, currentGroup); + } + private static DispatchResolution Resolve(Inputs input, DateTime time, bool shift, bool crew, bool group) => + DispatchRecipientResolver.Resolve(time, input.Routes.Where(r => (r.Kind != DispatchRouteKind.UnitCrew || crew) && (r.Kind != DispatchRouteKind.UnitGroup || group)) + .Select(r => r.Kind == DispatchRouteKind.Group ? r with { UseResolvedShift = shift } : r)); + private static string Fingerprint(Inputs value) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(value)))); + } +} diff --git a/Core/Resgrid.Services/AdminAssist/ImportEvidenceSource.cs b/Core/Resgrid.Services/AdminAssist/ImportEvidenceSource.cs new file mode 100644 index 000000000..0e6203342 --- /dev/null +++ b/Core/Resgrid.Services/AdminAssist/ImportEvidenceSource.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services.AdminAssist +{ + /// Mailbox polling metadata only. A quiet inbox is not a failed integration. + public sealed class ImportEvidenceSource(IDepartmentCallEmailsRepository mailboxes, IDepartmentSettingsService settings) : IAdminAssistEvidenceSource + { + public string SourceId => "EmailImportPolling"; + public IReadOnlyList EvidenceIds { get; } = new[] { "importHeartbeatExpected", "importHeartbeatMissing", "emailImportFailureCount", "emailImportSourceCount" }; + public async Task> ReadAsync(AdminAssistActor actor, DateTime now, CancellationToken ct) + { + var profile = await settings.GetOperatingProfileAsync(actor.DepartmentId).WaitAsync(ct) ?? throw new InvalidOperationException(); + Validator.ValidateObject(profile, new ValidationContext(profile), true); + var rows = (await mailboxes.GetAllByDepartmentIdAsync(actor.DepartmentId).WaitAsync(ct))?.ToList() ?? throw new InvalidOperationException(); + if (rows.Count > Math.Clamp(Config.AdminAssistConfig.MaxEvidenceRows, 1, 10000) || rows.Any(r => r.DepartmentId != actor.DepartmentId)) throw new InvalidOperationException(); + var expected = profile.ExpectedEmailPollIntervalMinutes; + // Future timestamps cannot establish a fresh poll; absent timestamps remain unobserved. + var valid = rows.Count > 0 && rows.All(r => r.LastCheck.HasValue && r.LastCheck.Value <= now); + return new[] { + new ConfigurationEvidence("importHeartbeatExpected", EvidenceState.Known, SourceId, "email-poll-v1", now, Boolean: expected.HasValue), + new ConfigurationEvidence("importHeartbeatMissing", !expected.HasValue ? EvidenceState.NotApplicable : valid ? EvidenceState.Known : EvidenceState.Unknown, + SourceId, "email-poll-v1", now, Boolean: expected.HasValue && valid ? rows.Any(r => now - r.LastCheck.Value > TimeSpan.FromMinutes(expected.Value)) : null, + ReasonCode: expected.HasValue && !valid ? "PollingNotObserved" : null), + new ConfigurationEvidence("emailImportFailureCount", EvidenceState.Known, SourceId, "email-poll-v1", now, Number: rows.Count(r => r.IsFailure)), + new ConfigurationEvidence("emailImportSourceCount", EvidenceState.Known, SourceId, "email-poll-v1", now, Number: rows.Count) + }; + } + } +} diff --git a/Core/Resgrid.Services/AdminAssist/MappingImpactProvider.cs b/Core/Resgrid.Services/AdminAssist/MappingImpactProvider.cs new file mode 100644 index 000000000..ec7ed053e --- /dev/null +++ b/Core/Resgrid.Services/AdminAssist/MappingImpactProvider.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services.AdminAssist +{ + /// Counts the same v4 marker choices at one captured time. Coordinates and identities never leave this request. + public sealed class MappingImpactProvider(IUsersService users, IUnitsService units, IUnitsRepository unitRows, + IUnitStatesRepository unitStates, IActionLogsRepository actions, IAuthorizationService authorization, + IRecordsAuthorizationService membership) : IOperationalImpactProvider + { + private static readonly string[] Ids = { "setting.MappingPersonnelLocationTTL", "setting.MappingUnitLocationTTL", + "setting.MappingPersonnelAllowStatusWithNoLocationToOverwrite", "setting.MappingUnitAllowStatusWithNoLocationToOverwrite" }; + public bool Supports(string settingId) => Ids.Contains(settingId, StringComparer.Ordinal); + private sealed record Marker(DateTime? PingOn, DateTime? StatusOn, Func HasStatusLocation); + public async Task EvaluateAsync(AdminAssistActor actor, ConfigurationSnapshot snapshot, ConfigurationImpactRequest request, CancellationToken ct) + { + if (!Supports(request.SettingId)) throw new ArgumentException("Unsupported mapping proposal."); + var label = request.SettingId.Contains("Personnel", StringComparison.Ordinal) ? "Impact.PersonnelMarkers" : "Impact.UnitMarkers"; + OperationalImpact Unknown(EvidenceState state) => new(new[] { new ConfigurationImpactMetric(label, state, null, null, "SourceUnavailable") }, new[] { "Impact.MapEvidenceUnavailable" }, "map-markers-v1"); + try + { + var personnel = request.SettingId.Contains("Personnel", StringComparison.Ordinal); + var prefix = personnel ? "MappingPersonnel" : "MappingUnit"; + var ttl = snapshot.Find(prefix + "LocationTTL"); + var overwrite = snapshot.Find(prefix + "AllowStatusWithNoLocationToOverwrite"); + var maximumAge = TimeSpan.FromSeconds(Math.Clamp(Config.AdminAssistConfig.EvidenceFreshnessSeconds, 1, 300)); + if (!ttl.IsFresh(snapshot.AsOfUtc, maximumAge) || !ttl.Number.HasValue || ttl.Number < 0 || ttl.Number > 525600 || + !overwrite.IsFresh(snapshot.AsOfUtc, maximumAge) || !overwrite.Boolean.HasValue) return Unknown(EvidenceState.Unknown); + var markers = personnel ? await ReadPersonnelAsync(actor, snapshot, ct) : await ReadUnitsAsync(actor, snapshot.AsOfUtc, ct); + var currentTtl = (int)ttl.Number.Value; + var proposedTtl = request.SettingId.EndsWith("LocationTTL", StringComparison.Ordinal) ? checked((int)request.Number.Value) : currentTtl; + var proposedOverwrite = request.Boolean ?? overwrite.Boolean.Value; + int before = 0, after = 0, added = 0, removed = 0, priorFallback = 0, fallback = 0; + foreach (var marker in markers) + { + ct.ThrowIfCancellationRequested(); + var was = MappingMarkerSelection.Select(marker.PingOn, marker.StatusOn, marker.HasStatusLocation, currentTtl, overwrite.Boolean.Value, snapshot.AsOfUtc); + var next = MappingMarkerSelection.Select(marker.PingOn, marker.StatusOn, marker.HasStatusLocation, proposedTtl, proposedOverwrite, snapshot.AsOfUtc); + if (was != MappingMarkerSource.None) before++; + if (next != MappingMarkerSource.None) after++; + if (was == MappingMarkerSource.None && next != MappingMarkerSource.None) added++; + if (was != MappingMarkerSource.None && next == MappingMarkerSource.None) removed++; + if (was == MappingMarkerSource.Status) priorFallback++; + if (next == MappingMarkerSource.Status) fallback++; + } + return new(new[] { + new ConfigurationImpactMetric(label, EvidenceState.Known, before, after), + new ConfigurationImpactMetric("Impact.MarkersAdded", EvidenceState.Known, 0, added), + new ConfigurationImpactMetric("Impact.MarkersRemoved", EvidenceState.Known, 0, removed), + new ConfigurationImpactMetric("Impact.StatusFallbackMarkers", EvidenceState.Known, priorFallback, fallback), + new ConfigurationImpactMetric("Impact.MapSample", EvidenceState.Known, markers.Count, markers.Count) + }, new[] { "Impact.MapScope", "Impact.MapFallback", "Impact.MapSampling" }, "map-markers-v1"); + } + catch (OperationCanceledException) { throw; } + catch (UnauthorizedAccessException) { return Unknown(EvidenceState.Redacted); } + catch (Exception) { return Unknown(EvidenceState.Unknown); } + } + private async Task> ReadUnitsAsync(AdminAssistActor actor, DateTime now, CancellationToken ct) + { + var owned = (await unitRows.GetAllUnitsByDepartmentIdAsync(actor.DepartmentId).WaitAsync(ct))?.ToList() ?? throw new InvalidOperationException(); + var pings = await units.ReadLatestLocationsForAdministrationAsync(actor.DepartmentId).WaitAsync(ct) ?? throw new InvalidOperationException(); + var statuses = (await unitStates.GetLatestUnitStatesForDepartmentAsync(actor.DepartmentId).WaitAsync(ct))?.ToList() ?? throw new InvalidOperationException(); + Bound(owned.Count, pings.Count, statuses.Count); + if (owned.Any(u => u.DepartmentId != actor.DepartmentId) || pings.Any(p => p.DepartmentId != actor.DepartmentId)) throw new InvalidOperationException(); + var pingByUnit = pings.ToDictionary(p => p.UnitId); var stateByUnit = statuses.ToDictionary(s => s.UnitId); + var result = new List(); + foreach (var unit in owned) + { + ct.ThrowIfCancellationRequested(); + if (!await authorization.CanUserViewUnitLocationViaMatrixAsync(unit.UnitId, actor.UserId, actor.DepartmentId)) throw new UnauthorizedAccessException(); + pingByUnit.TryGetValue(unit.UnitId, out var ping); stateByUnit.TryGetValue(unit.UnitId, out var status); + // The map's owning service supplies a current Available state when a unit has no status. + result.Add(new Marker(ping?.Timestamp, status?.Timestamp ?? now, () => status?.HasLocation() == true)); + } + return result; + } + private async Task> ReadPersonnelAsync(AdminAssistActor actor, ConfigurationSnapshot snapshot, CancellationToken ct) + { + var disableAuto = snapshot.Find("DisabledAutoAvailable"); + if (disableAuto.State != EvidenceState.Known || !disableAuto.Boolean.HasValue) throw new InvalidOperationException(); + var people = await users.GetUserGroupAndRolesByDepartmentIdAsync(actor.DepartmentId, false, false, false).WaitAsync(ct) ?? throw new InvalidOperationException(); + var pings = await users.ReadLatestLocationsForAdministrationAsync(actor.DepartmentId).WaitAsync(ct) ?? throw new InvalidOperationException(); + // Read the same status projection directly, without the map service's ETA/provider enrichment. + var statuses = (await actions.ReadLatestForAdministrationAsync(actor.DepartmentId, disableAuto.Boolean.Value, snapshot.AsOfUtc, Math.Clamp(Config.AdminAssistConfig.MaxEvidenceRows, 1, 10000), ct))?.ToList() ?? throw new InvalidOperationException(); + Bound(people.Count, pings.Count, statuses.Count); + if (pings.Any(p => p.DepartmentId != actor.DepartmentId) || statuses.Any(s => s.DepartmentId != actor.DepartmentId)) throw new InvalidOperationException(); + var pingByPerson = pings.ToDictionary(p => p.UserId, StringComparer.Ordinal); + var stateByPerson = statuses.GroupBy(s => s.UserId).ToDictionary(g => g.Key, g => g.OrderByDescending(s => s.ActionLogId).First(), StringComparer.Ordinal); + if (people.Select(p => p.UserId).Distinct(StringComparer.Ordinal).Count() != people.Count) throw new InvalidOperationException(); + var result = new List(); + foreach (var person in people) + { + ct.ThrowIfCancellationRequested(); + if (!await membership.IsAssignableMemberAsync(person.UserId, actor.DepartmentId) || + !await authorization.CanUserViewPersonLocationViaMatrixAsync(person.UserId, actor.UserId, actor.DepartmentId)) throw new UnauthorizedAccessException(); + pingByPerson.TryGetValue(person.UserId, out var ping); stateByPerson.TryGetValue(person.UserId, out var status); + result.Add(new Marker(ping?.Timestamp, status?.Timestamp, () => status?.HasLocation() == true)); + } + return result; + } + private static void Bound(params int[] counts) + { + if (counts.Any(c => c > Math.Clamp(Config.AdminAssistConfig.MaxEvidenceRows, 1, 10000))) throw new InvalidOperationException("Mapping preview row bound exceeded."); + } + } +} diff --git a/Core/Resgrid.Services/AdminAssist/ModuleImpactService.cs b/Core/Resgrid.Services/AdminAssist/ModuleImpactService.cs new file mode 100644 index 000000000..241dd792e --- /dev/null +++ b/Core/Resgrid.Services/AdminAssist/ModuleImpactService.cs @@ -0,0 +1,59 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Repositories; + +namespace Resgrid.Services.AdminAssist +{ + public sealed class ModuleImpactService(IAdminAssistAccessService access, IAdminAssistRepository repository, + IAdminAssistCatalog catalog, IDepartmentSettingsRepository settings, IModuleImpactStore counts, TimeProvider clock) : IModuleImpactService + { + public async Task PreviewAsync(AdminAssistActor actor, ModuleImpactRequest request, CancellationToken ct = default) + { + if (!await access.CanAccessAsync(actor, false, ct)) throw new UnauthorizedAccessException(); + if (request == null || !ModuleImpactSelection.Supported.Contains(request.Module, StringComparer.Ordinal)) throw new ArgumentException("Unsupported module preview."); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(TimeSpan.FromSeconds(Math.Clamp(Config.AdminAssistConfig.SnapshotTimeoutSeconds, 1, 60))); + ct = timeout.Token; + var revision = (await repository.GetConfigurationRevisionAsync(actor.DepartmentId, ct)).ToString(CultureInfo.InvariantCulture); + if (revision != request.ExpectedRevision) throw new AdminAssistConcurrencyException(); + var now = clock.GetUtcNow().UtcDateTime; + ConfigurationImpactMetric[] metrics; + try + { + var current = await ReadDisabledAsync(actor.DepartmentId, request.Module, ct); + var input = await counts.ReadModuleImpactCountsAsync(actor.DepartmentId, request.Module, Math.Clamp(Config.AdminAssistConfig.MaxEvidenceRows, 1, 10000), ct) ?? throw new InvalidOperationException(); + if (input.Members < 0 || input.ContentRows < 0) throw new InvalidOperationException(); + if (current != await ReadDisabledAsync(actor.DepartmentId, request.Module, ct) || input != await counts.ReadModuleImpactCountsAsync(actor.DepartmentId, request.Module, Math.Clamp(Config.AdminAssistConfig.MaxEvidenceRows, 1, 10000), ct)) throw new AdminAssistConcurrencyException(); + metrics = new[] { + new ConfigurationImpactMetric("Impact.ModuleMenus", EvidenceState.Known, current ? 0 : input.Members, request.Disabled ? 0 : input.Members), + new ConfigurationImpactMetric("Impact.ModuleMembersChanged", EvidenceState.Known, 0, current == request.Disabled ? 0 : input.Members), + new ConfigurationImpactMetric("Impact.ModuleDataRows", input.ContentRows.HasValue ? EvidenceState.Known : EvidenceState.Unknown, input.ContentRows, input.ContentRows, input.ContentRows.HasValue ? null : "CountAdapterUnavailable"), + new ConfigurationImpactMetric("Impact.ModuleRowsBehindHiddenEntry", input.ContentRows.HasValue ? EvidenceState.Known : EvidenceState.Unknown, input.ContentRows.HasValue ? current ? input.ContentRows : 0 : null, + input.ContentRows.HasValue ? request.Disabled ? input.ContentRows : 0 : null, input.ContentRows.HasValue ? null : "CountAdapterUnavailable") }; + } + catch (AdminAssistConcurrencyException) { throw; } + catch (UnauthorizedAccessException) { throw; } + catch (OperationCanceledException) { throw; } + catch (Exception) { metrics = new[] { new ConfigurationImpactMetric("Impact.ModuleMenus", EvidenceState.Unknown, null, null, "SourceUnavailable") }; } + if ((await repository.GetConfigurationRevisionAsync(actor.DepartmentId, ct)).ToString(CultureInfo.InvariantCulture) != revision) throw new AdminAssistConcurrencyException(); + if (!await access.CanAccessAsync(actor, false, ct)) throw new UnauthorizedAccessException(); + var entry = catalog.Settings.Single(e => e.Binding == "DepartmentModuleSettings." + request.Module + "Disabled"); + return new(entry.Id, revision, now, "module-menu-impact-v1", entry.Impact, metrics, Array.Empty(), + new[] { "Impact.NoMutation", "Impact.ModuleScope", "Impact.ModuleTiming", "Impact.Window" }, entry.Location.Url); + } + private async Task ReadDisabledAsync(int departmentId, string module, CancellationToken ct) + { + var rows = (await settings.GetAllByDepartmentIdAsync(departmentId).WaitAsync(ct))?.ToList() ?? throw new InvalidOperationException(); + if (rows.Any(s => s.DepartmentId != departmentId)) throw new InvalidOperationException(); + var row = rows.SingleOrDefault(s => s.SettingType == (int)DepartmentSettingTypes.ModuleSettings); + var value = row == null ? new DepartmentModuleSettings() : ObjectSerialization.Deserialize(row.Setting) ?? throw new InvalidOperationException(); + return ModuleImpactSelection.Disabled(value, module); + } + } +} diff --git a/Core/Resgrid.Services/AdminAssist/NotificationImpactService.cs b/Core/Resgrid.Services/AdminAssist/NotificationImpactService.cs new file mode 100644 index 000000000..400a7d3f3 --- /dev/null +++ b/Core/Resgrid.Services/AdminAssist/NotificationImpactService.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Repositories; + +namespace Resgrid.Services.AdminAssist +{ + public sealed class NotificationImpactService(IAdminAssistAccessService access, IAdminAssistRepository repository, + IAdminAssistCatalog catalog, IDepartmentSettingsRepository settings, INotificationImpactStore store, TimeProvider clock) : INotificationImpactService + { + public async Task PreviewAsync(AdminAssistActor actor, NotificationImpactRequest request, CancellationToken ct = default) + { + if (!await access.CanAccessAsync(actor, false, ct)) throw new UnauthorizedAccessException(); + if (request == null || request.WindowDays < 1 || request.WindowDays > 30 || request.EventsPerMember < 0 || request.EventsPerMember > 10000) + throw new ArgumentException("Invalid notification scenario."); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(TimeSpan.FromSeconds(Math.Clamp(Config.AdminAssistConfig.SnapshotTimeoutSeconds, 1, 60))); ct = timeout.Token; + var revision = (await repository.GetConfigurationRevisionAsync(actor.DepartmentId, ct)).ToString(CultureInfo.InvariantCulture); + if (request.ExpectedRevision != revision) throw new AdminAssistConcurrencyException(); + var now = clock.GetUtcNow().UtcDateTime; + var metrics = new List { + new("Impact.NotificationWindowDays", EvidenceState.Known, request.WindowDays, request.WindowDays), + new("Impact.NotificationScenarioEvents", EvidenceState.Known, request.EventsPerMember, request.EventsPerMember), + new("Impact.NotificationHistoricalSample", EvidenceState.NotApplicable, null, null, "DeclaredScenario") }; + try + { + var bound = Math.Clamp(Config.AdminAssistConfig.MaxEvidenceRows, 1, 10000); + var suppression = await ReadSuppressionAsync(actor.DepartmentId, ct); + var blocked = BroadcastBlocked(actor.DepartmentId); + var members = await store.ReadNotificationMembersAsync(actor.DepartmentId, bound, ct); + Validate(members, actor.DepartmentId, bound); + var before = Count(members, suppression.EnableSupressStaffing, suppression.StaffingLevelsToSupress, blocked); + var after = Count(members, request.SuppressStaffing, suppression.StaffingLevelsToSupress, blocked); + if (JsonConvert.SerializeObject(suppression) != JsonConvert.SerializeObject(await ReadSuppressionAsync(actor.DepartmentId, ct)) || + JsonConvert.SerializeObject(members) != JsonConvert.SerializeObject(await store.ReadNotificationMembersAsync(actor.DepartmentId, bound, ct)) || + blocked != BroadcastBlocked(actor.DepartmentId)) throw new AdminAssistConcurrencyException(); + void Add(string key, decimal current, decimal proposed) => metrics.Add(new("Impact.Notification" + key, EvidenceState.Known, current, proposed)); + Add("MemberSample", members.Count, members.Count); + Add("MissingProfiles", members.Count(m => !m.ProfileId.HasValue), members.Count(m => !m.ProfileId.HasValue)); + Add("StaffingUnknown", before.UnknownStaffing, after.UnknownStaffing); + Add("Suppressed", before.Suppressed, after.Suppressed); + Add("RecipientsMinimum", before.RecipientsMinimum, after.RecipientsMinimum); + Add("RecipientsMaximum", before.RecipientsMaximum, after.RecipientsMaximum); + Add("SmsMinimum", before.SmsMinimum, after.SmsMinimum); Add("SmsMaximum", before.SmsMaximum, after.SmsMaximum); + Add("EmailMinimum", before.EmailMinimum, after.EmailMinimum); Add("EmailMaximum", before.EmailMaximum, after.EmailMaximum); + Add("PushMinimum", before.PushMinimum, after.PushMinimum); Add("PushMaximum", before.PushMaximum, after.PushMaximum); + Add("NoChannels", before.NoChannels, after.NoChannels); + Add("VolumeMinimum", 0, 0); + Add("VolumeMaximum", (decimal)request.EventsPerMember * (before.SmsMaximum + before.EmailMaximum + before.PushMaximum), + (decimal)request.EventsPerMember * (after.SmsMaximum + after.EmailMaximum + after.PushMaximum)); + Add("BroadcastBlocked", blocked ? 1 : 0, blocked ? 1 : 0); + } + catch (AdminAssistConcurrencyException) { throw; } + catch (UnauthorizedAccessException) { throw; } + catch (OperationCanceledException) { throw; } + catch (Exception) { metrics.Add(new("Impact.NotificationMemberSample", EvidenceState.Unknown, null, null, "SourceUnavailableOrBoundExceeded")); } + if ((await repository.GetConfigurationRevisionAsync(actor.DepartmentId, ct)).ToString(CultureInfo.InvariantCulture) != revision) throw new AdminAssistConcurrencyException(); + if (!await access.CanAccessAsync(actor, false, ct)) throw new UnauthorizedAccessException(); + var entry = catalog.Settings.Single(e => e.Binding == "DepartmentSuppressStaffingInfo.EnableSupressStaffing"); + return new(entry.Id, revision, now, "notification-cohort-impact-v1", entry.Impact, metrics, Array.Empty(), + new[] { "Impact.NoMutation", "Impact.NotificationScenarioScope", "Impact.NotificationChannelScope", "Impact.NotificationTiming", "Impact.Window" }, entry.Location.Url); + } + private async Task ReadSuppressionAsync(int departmentId, CancellationToken ct) + { + var rows = (await settings.GetAllByDepartmentIdAsync(departmentId).WaitAsync(ct))?.ToList() ?? throw new InvalidOperationException(); + if (rows.Count > 500 || rows.Any(s => s.DepartmentId != departmentId)) throw new InvalidOperationException(); + var row = rows.SingleOrDefault(s => s.SettingType == (int)DepartmentSettingTypes.StaffingSuppressStaffingLevels); + var value = row == null ? new DepartmentSuppressStaffingInfo() : ObjectSerialization.Deserialize(row.Setting) ?? throw new InvalidOperationException(); + if (value.StaffingLevelsToSupress == null || value.StaffingLevelsToSupress.Count > 1000) throw new InvalidOperationException(); + return value; + } + private static bool BroadcastBlocked(int departmentId) => Config.SystemBehaviorConfig.DoNotBroadcast && + !(Config.SystemBehaviorConfig.BypassDoNotBroadcastDepartments?.Contains(departmentId) ?? false); + private static void Validate(IReadOnlyList rows, int departmentId, int bound) + { + if (rows == null || rows.Count > bound || rows.Any(m => m == null || m.DepartmentId != departmentId || m.MemberId <= 0 || + string.IsNullOrWhiteSpace(m.UserId) || m.ProfileId <= 0 || m.StaffingKnown && !m.Staffing.HasValue || + m.ProfileId.HasValue && (!m.Sms.HasValue || !m.Email.HasValue || !m.Push.HasValue)) || + rows.Select(m => m.UserId).Distinct(StringComparer.OrdinalIgnoreCase).Count() != rows.Count) throw new InvalidOperationException(); + } + private sealed class Counts + { + public int Suppressed, UnknownStaffing, RecipientsMinimum, RecipientsMaximum, SmsMinimum, SmsMaximum, + EmailMinimum, EmailMaximum, PushMinimum, PushMaximum, NoChannels; + } + private static Counts Count(IReadOnlyList rows, bool suppress, List levels, bool blocked) + { + var result = new Counts(); + foreach (var row in rows) + { + if (blocked) { result.Suppressed++; continue; } + if (suppress && row.StaffingKnown && levels.Contains(row.Staffing.Value)) { result.Suppressed++; continue; } + var staffingUnknown = suppress && levels.Count > 0 && !row.StaffingKnown; + if (staffingUnknown) result.UnknownStaffing++; + if (!row.ProfileId.HasValue) + { + result.RecipientsMaximum++; result.SmsMaximum++; result.EmailMaximum++; result.PushMaximum++; continue; + } + var channels = NotificationChannelSelection.From(row.Sms.Value, row.MobileVerified, row.Email.Value, row.EmailVerified, row.Push.Value); + if (channels.Sms) { result.SmsMaximum++; if (!staffingUnknown) result.SmsMinimum++; } + if (channels.Email) { result.EmailMaximum++; if (!staffingUnknown) result.EmailMinimum++; } + if (channels.Push) { result.PushMaximum++; if (!staffingUnknown) result.PushMinimum++; } + if (channels.Sms || channels.Email || channels.Push) { result.RecipientsMaximum++; if (!staffingUnknown) result.RecipientsMinimum++; } + else result.NoChannels++; + } + return result; + } + } +} diff --git a/Core/Resgrid.Services/AdminAssist/OperatingProfileEvidenceSource.cs b/Core/Resgrid.Services/AdminAssist/OperatingProfileEvidenceSource.cs new file mode 100644 index 000000000..83cc30bf9 --- /dev/null +++ b/Core/Resgrid.Services/AdminAssist/OperatingProfileEvidenceSource.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Services; + +namespace Resgrid.Services.AdminAssist +{ + /// Only validated public archetype codes enter the setup projection; references and declared system labels stay on the editor. + public sealed class OperatingProfileEvidenceSource(IDepartmentSettingsService settings) : IAdminAssistEvidenceSource + { + public string SourceId => "OperatingProfile"; + public IReadOnlyList EvidenceIds { get; } = new[] { "operatingPackIds", "operatingProfileReviewed" }; + public async Task> ReadAsync(AdminAssistActor actor, DateTime now, CancellationToken ct) + { + var profile = await settings.GetOperatingProfileAsync(actor.DepartmentId).WaitAsync(ct) ?? throw new InvalidOperationException(); + Validator.ValidateObject(profile, new ValidationContext(profile), true); + return new[] { + new ConfigurationEvidence("operatingPackIds", EvidenceState.Known, SourceId, profile.Revision.ToString(System.Globalization.CultureInfo.InvariantCulture), now, Code: string.Join(",", profile.Archetypes.OrderBy(a => a, StringComparer.Ordinal))), + new ConfigurationEvidence("operatingProfileReviewed", EvidenceState.Known, SourceId, "1", now, Boolean: profile.ReviewedOnUtc.HasValue) + }; + } + } +} diff --git a/Core/Resgrid.Services/AdminAssist/OrganizationEvidenceSource.cs b/Core/Resgrid.Services/AdminAssist/OrganizationEvidenceSource.cs new file mode 100644 index 000000000..7c441aebd --- /dev/null +++ b/Core/Resgrid.Services/AdminAssist/OrganizationEvidenceSource.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services.AdminAssist +{ + /// Fresh structural metadata. No names, addresses, coordinates or member identifiers leave this adapter. + public sealed class OrganizationEvidenceSource(IDepartmentGroupsRepository groups, IUnitsRepository units, + IDepartmentsService departments, IUsersService users, IAuthorizationService authorization, + IRecordsAuthorizationService membership) : IAdminAssistEvidenceSource + { + public string SourceId => "Organization"; + public IReadOnlyList EvidenceIds { get; } = new[] { "emptyGroupCount", "unitsWithoutType", "unitsWithoutGroup", "stationsWithoutLocation", "activePersonnelCount", "unitCount", "groupCount" }; + public async Task> ReadAsync(AdminAssistActor actor, DateTime now, CancellationToken ct) + { + var groupRows = (await groups.GetAllGroupsByDepartmentIdAsync(actor.DepartmentId).WaitAsync(ct))?.ToList() + ?? throw new InvalidOperationException("Group metadata unavailable."); + var unitRows = (await units.GetAllUnitsByDepartmentIdAsync(actor.DepartmentId).WaitAsync(ct))?.ToList() + ?? throw new InvalidOperationException("Unit metadata unavailable."); + var members = await departments.GetAllUsersForDepartmentUnlimitedMinusDisabledAsync(actor.DepartmentId, true).WaitAsync(ct) + ?? throw new InvalidOperationException("Member metadata unavailable."); + var limit = Math.Clamp(Config.AdminAssistConfig.MaxEvidenceRows, 1, 10000); + if (groupRows.Count + unitRows.Count + members.Count > limit || groupRows.Any(g => g.DepartmentId != actor.DepartmentId) || unitRows.Any(u => u.DepartmentId != actor.DepartmentId)) + throw new InvalidOperationException("Scope or row bound exceeded."); + var visibleUnits = new List(); + foreach (var unit in unitRows) + { + ct.ThrowIfCancellationRequested(); + if (await authorization.CanUserViewUnitAsync(actor.UserId, unit.UnitId)) visibleUnits.Add(unit); + else throw new UnauthorizedAccessException("The department-wide unit check requires complete visible evidence."); + } + var active = new HashSet(StringComparer.Ordinal); + foreach (var member in members) + { + ct.ThrowIfCancellationRequested(); + if (await membership.IsAssignableMemberAsync(member.UserId, actor.DepartmentId)) + { + if (!await authorization.CanUserViewPersonAsync(actor.UserId, member.UserId, actor.DepartmentId)) throw new UnauthorizedAccessException(); + active.Add(member.UserId); + } + } + ConfigurationEvidence Count(string id, decimal count) => new(id, EvidenceState.Known, SourceId, "1", now, Number: count); + bool Coordinate(string value, decimal min, decimal max) => decimal.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var number) && number >= min && number <= max; + return new[] + { + groupRows.Any(g => g.Members == null) ? new ConfigurationEvidence("emptyGroupCount", EvidenceState.Unknown, SourceId, "1", now, ReasonCode: "GroupMembershipUnavailable") : Count("emptyGroupCount", groupRows.Count(g => !g.Members.Any(m => active.Contains(m.UserId)))), + Count("unitsWithoutType", visibleUnits.Count(u => string.IsNullOrWhiteSpace(u.Type))), + Count("unitsWithoutGroup", visibleUnits.Count(u => !u.StationGroupId.HasValue || !groupRows.Any(g => g.DepartmentGroupId == u.StationGroupId))), + Count("stationsWithoutLocation", groupRows.Count(g => g.Type == (int)DepartmentGroupTypes.Station && !g.AddressId.HasValue && !(Coordinate(g.Latitude, -90, 90) && Coordinate(g.Longitude, -180, 180)))), + Count("activePersonnelCount", active.Count), Count("unitCount", visibleUnits.Count), Count("groupCount", groupRows.Count) + }; + } + } +} diff --git a/Core/Resgrid.Services/AdminAssist/PermissionImpactService.cs b/Core/Resgrid.Services/AdminAssist/PermissionImpactService.cs new file mode 100644 index 000000000..2a9055e31 --- /dev/null +++ b/Core/Resgrid.Services/AdminAssist/PermissionImpactService.cs @@ -0,0 +1,164 @@ +using System; +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 Newtonsoft.Json; +using Resgrid.Model; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Helpers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services.AdminAssist +{ + /// Evaluates actors, not the targets returned by GetAllowedUsersAsync. No permission writes or claim refresh. + public sealed class PermissionImpactService(IAdminAssistAccessService access, IAdminAssistRepository repository, + IAdminAssistCatalog catalog, IDepartmentsService departments, IDepartmentMembersRepository members, + IDepartmentGroupsRepository groups, IPersonnelRolesRepository roles, IPersonnelRoleUsersRepository roleMembers, + IUnitsRepository units, IPermissionsRepository permissions, IPermissionsService policy, TimeProvider clock) : IPermissionImpactService + { + public static readonly IReadOnlyList Supported = Array.AsReadOnly(new[] { + nameof(PermissionTypes.CreateCall), nameof(PermissionTypes.CreateNote), nameof(PermissionTypes.ViewPersonalInfo), + nameof(PermissionTypes.ViewGroupUsers), nameof(PermissionTypes.ViewGroupUnits), + nameof(PermissionTypes.CanSeePersonnelLocations), nameof(PermissionTypes.CanSeeUnitLocations) }); + private sealed record Person(string Id, bool Admin, int? Group, bool GroupAdmin, int[] Roles); + private sealed record Group(int Id, int? Parent, string[] Admins); + private sealed record Target(string Id, int? Group); + private sealed record Inputs(Person[] People, Group[] Groups, Target[] Targets, int[] OwnedRoles, Permission Current); + private static bool Scoped(PermissionTypes type) => type is PermissionTypes.ViewGroupUsers or PermissionTypes.ViewGroupUnits or PermissionTypes.CanSeePersonnelLocations or PermissionTypes.CanSeeUnitLocations; + private static bool UnitScope(PermissionTypes type) => type is PermissionTypes.ViewGroupUnits or PermissionTypes.CanSeeUnitLocations; + public async Task> GetRoleOptionsAsync(AdminAssistActor actor, string expectedRevision, CancellationToken ct = default) + { + if (!await access.CanAccessAsync(actor, false, ct)) throw new UnauthorizedAccessException(); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(TimeSpan.FromSeconds(Math.Clamp(Config.AdminAssistConfig.SnapshotTimeoutSeconds, 1, 60))); + ct = timeout.Token; + var revision = (await repository.GetConfigurationRevisionAsync(actor.DepartmentId, ct)).ToString(CultureInfo.InvariantCulture); + if (revision != expectedRevision) throw new AdminAssistConcurrencyException(); + async Task Read() + { + var rows = (await roles.GetPersonnelRolesByDepartmentIdAsync(actor.DepartmentId).WaitAsync(ct))?.ToArray() ?? throw new InvalidOperationException(); + if (rows.Length > Math.Clamp(Config.AdminAssistConfig.MaxEvidenceRows, 1, 10000) || rows.Any(r => r.DepartmentId != actor.DepartmentId || r.PersonnelRoleId <= 0 || string.IsNullOrWhiteSpace(r.Name)) || + rows.Select(r => r.PersonnelRoleId).Distinct().Count() != rows.Length) throw new InvalidOperationException(); + // Role names are ordinary organization metadata; no member names, contacts or grant-protected fields. + return rows.OrderBy(r => r.PersonnelRoleId).Select(r => new PermissionRoleOption(r.PersonnelRoleId, r.Name)).ToArray(); + } + var options = await Read(); + if (!options.SequenceEqual(await Read()) || (await repository.GetConfigurationRevisionAsync(actor.DepartmentId, ct)).ToString(CultureInfo.InvariantCulture) != revision) throw new AdminAssistConcurrencyException(); + if (!await access.CanAccessAsync(actor, false, ct)) throw new UnauthorizedAccessException(); + return options.OrderBy(o => o.Name, StringComparer.CurrentCultureIgnoreCase).ThenBy(o => o.Id).ToArray(); + } + public async Task PreviewAsync(AdminAssistActor actor, PermissionImpactRequest request, CancellationToken ct = default) + { + if (!await access.CanAccessAsync(actor, false, ct)) throw new UnauthorizedAccessException(); + if (request == null || !Supported.Contains(request.PermissionType, StringComparer.Ordinal) || + request.Action < 0 || request.Action > 3 || request.RoleIds == null || request.RoleIds.Length > 100 || + request.RoleIds.Any(id => id <= 0) || request.RoleIds.Distinct().Count() != request.RoleIds.Length || + request.Action != 2 && request.RoleIds.Length != 0) throw new ArgumentException("Unsupported permission proposal."); + var type = Enum.Parse(request.PermissionType); + if (!Scoped(type) && request.LockToGroup) throw new ArgumentException("This action does not use a resource group lock."); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(TimeSpan.FromSeconds(Math.Clamp(Config.AdminAssistConfig.SnapshotTimeoutSeconds, 1, 60))); + ct = timeout.Token; + var revision = (await repository.GetConfigurationRevisionAsync(actor.DepartmentId, ct)).ToString(CultureInfo.InvariantCulture); + if (revision != request.ExpectedRevision) throw new AdminAssistConcurrencyException(); + var now = clock.GetUtcNow().UtcDateTime; + var metrics = new List(); + try + { + var input = await ReadAsync(actor, type, ct); + if (request.RoleIds.Except(input.OwnedRoles).Any()) throw new ArgumentException("A selected role is unavailable in this department."); + var proposed = new Permission { DepartmentId = actor.DepartmentId, PermissionType = (int)type, + Action = request.Action, LockToGroup = request.LockToGroup, Data = string.Join(",", request.RoleIds.OrderBy(id => id)) }; + var before = Evaluate(input, input.Current, Scoped(type), ct); + var after = Evaluate(input, proposed, Scoped(type), ct); + if (Fingerprint(input) != Fingerprint(await ReadAsync(actor, type, ct))) throw new AdminAssistConcurrencyException(); + void Count(string key, int oldValue, int newValue) => metrics.Add(new("Impact." + key, EvidenceState.Known, oldValue, newValue)); + Count("PermissionActors", before.Select(p => p.Actor).Distinct().Count(), after.Select(p => p.Actor).Distinct().Count()); + Count("PermissionEdges", before.Count, after.Count); + Count("PermissionGained", 0, after.Except(before).Count()); + Count("PermissionLost", 0, before.Except(after).Count()); + Count("PermissionSample", input.People.Length, input.People.Length); + Count("PermissionTargets", input.Targets.Length, input.Targets.Length); + } + catch (ArgumentException) { throw; } + catch (AdminAssistConcurrencyException) { throw; } + catch (UnauthorizedAccessException) { throw; } + catch (OperationCanceledException) { throw; } + catch (Exception) { metrics.Clear(); metrics.Add(new("Impact.PermissionActors", EvidenceState.Unknown, null, null, "SourceUnavailable")); } + if ((await repository.GetConfigurationRevisionAsync(actor.DepartmentId, ct)).ToString(CultureInfo.InvariantCulture) != revision) throw new AdminAssistConcurrencyException(); + if (!await access.CanAccessAsync(actor, false, ct)) throw new UnauthorizedAccessException(); + var entry = catalog.Settings.Single(e => e.Id == "permission." + request.PermissionType); + return new(entry.Id, revision, now, "permission-impact-v1", entry.Impact, metrics, Array.Empty(), + new[] { "Impact.NoMutation", "Impact.PermissionScope", "Impact.PermissionTiming", "Impact.Window" }, entry.Location.Url); + } + private HashSet<(string Actor, string Target)> Evaluate(Inputs input, Permission permission, bool scoped, CancellationToken ct) + { + var result = new HashSet<(string, string)>(); + var byGroup = input.Groups.ToDictionary(g => g.Id); + foreach (var person in input.People) + { + ct.ThrowIfCancellationRequested(); + foreach (var target in input.Targets) + { + bool ancestorAdmin = false; var groupId = target.Group; var visited = new HashSet(); + while (groupId.HasValue) + { + if (!visited.Add(groupId.Value) || !byGroup.TryGetValue(groupId.Value, out var group)) throw new InvalidOperationException("Invalid group hierarchy."); + ancestorAdmin |= group.Admins.Contains(person.Id, StringComparer.OrdinalIgnoreCase); groupId = group.Parent; + } + var allowed = scoped ? ResourceVisibilityPermission.Allows(permission, person.Admin, person.GroupAdmin, person.Group, target.Group, person.Roles, ancestorAdmin) : + policy.IsUserAllowed(permission, person.Admin, person.GroupAdmin, person.Roles.Select(id => new PersonnelRole { PersonnelRoleId = id }).ToList()); + if (allowed) result.Add((person.Id, target.Id)); + } + } + return result; + } + private async Task ReadAsync(AdminAssistActor actor, PermissionTypes type, CancellationToken ct) + { + var department = await departments.GetDepartmentByIdAsync(actor.DepartmentId, true).WaitAsync(ct) ?? throw new InvalidOperationException(); + var memberRows = (await members.GetAllDepartmentMembersUnlimitedAsync(actor.DepartmentId).WaitAsync(ct))?.ToArray() ?? throw new InvalidOperationException(); + var groupRows = (await groups.GetAllGroupsByDepartmentIdAsync(actor.DepartmentId).WaitAsync(ct))?.ToArray() ?? throw new InvalidOperationException(); + var roleRows = (await roles.GetPersonnelRolesByDepartmentIdAsync(actor.DepartmentId).WaitAsync(ct))?.ToArray() ?? throw new InvalidOperationException(); + var assignments = (await roleMembers.GetAllRoleUsersForDepartmentAsync(actor.DepartmentId).WaitAsync(ct))?.ToArray() ?? throw new InvalidOperationException(); + var permissionRows = (await permissions.GetAllByDepartmentIdAsync(actor.DepartmentId).WaitAsync(ct))?.ToArray() ?? throw new InvalidOperationException(); + var bound = Math.Clamp(Config.AdminAssistConfig.MaxEvidenceRows, 1, 10000); + if (department.DepartmentId != actor.DepartmentId || memberRows.Any(m => m.DepartmentId != actor.DepartmentId) || + groupRows.Any(g => g.DepartmentId != actor.DepartmentId || g.Members == null || g.Members.Any(m => m.DepartmentId != actor.DepartmentId || m.DepartmentGroupId != g.DepartmentGroupId)) || + roleRows.Any(r => r.DepartmentId != actor.DepartmentId) || permissionRows.Any(p => p.DepartmentId != actor.DepartmentId)) throw new InvalidOperationException("Invalid tenant source."); + if (memberRows.Length + groupRows.Length + groupRows.Sum(g => g.Members.Count) + roleRows.Length + assignments.Length + permissionRows.Length > bound) throw new InvalidOperationException("Permission evidence bound exceeded."); + var ownedRoles = roleRows.Select(r => r.PersonnelRoleId).OrderBy(id => id).ToArray(); + if (assignments.Any(a => !ownedRoles.Contains(a.PersonnelRoleId))) throw new InvalidOperationException(); + var byUser = groupRows.SelectMany(g => g.Members).GroupBy(m => m.UserId, StringComparer.Ordinal).ToDictionary(g => g.Key, g => g.ToArray(), StringComparer.Ordinal); + var people = memberRows.Where(m => DepartmentMemberStateHelper.IsCurrentMember(m, actor.DepartmentId)).Select(m => { + byUser.TryGetValue(m.UserId, out var memberships); + // The owning service takes its first group without stable ordering. Do not guess when several exist. + if (memberships?.Length > 1) throw new InvalidOperationException("Ambiguous group membership."); + var group = memberships?.SingleOrDefault(); + return new Person(m.UserId, m.IsAdmin.GetValueOrDefault() || m.UserId == department.ManagingUserId, group?.DepartmentGroupId, + group?.IsAdmin == true, assignments.Where(a => a.UserId == m.UserId).Select(a => a.PersonnelRoleId).Distinct().OrderBy(id => id).ToArray()); + }).OrderBy(p => p.Id, StringComparer.Ordinal).ToArray(); + if (people.Select(p => p.Id).Distinct(StringComparer.Ordinal).Count() != people.Length) throw new InvalidOperationException(); + Target[] targets; + if (UnitScope(type)) + { + var unitRows = (await units.GetAllUnitsByDepartmentIdAsync(actor.DepartmentId).WaitAsync(ct))?.ToArray() ?? throw new InvalidOperationException(); + if (unitRows.Length > bound || unitRows.Any(u => u.DepartmentId != actor.DepartmentId)) throw new InvalidOperationException(); + targets = unitRows.OrderBy(u => u.UnitId).Select(u => new Target(u.UnitId.ToString(CultureInfo.InvariantCulture), u.StationGroupId)).ToArray(); + } + else targets = Scoped(type) ? people.Select(p => new Target(p.Id, p.Group)).ToArray() : new[] { new Target("department-action", null) }; + if ((long)people.Length * targets.Length > 100000) throw new InvalidOperationException("Permission comparison bound exceeded."); + var current = permissionRows.SingleOrDefault(p => p.PermissionType == (int)type); + if (current != null && (!Enum.IsDefined(typeof(PermissionActions), current.Action) || (!string.IsNullOrWhiteSpace(current.Data) && + current.Action == 2 && current.Data.Split(',').Any(id => !int.TryParse(id, out var parsed) || !ownedRoles.Contains(parsed))))) throw new InvalidOperationException("Invalid current permission."); + return new(people, groupRows.OrderBy(g => g.DepartmentGroupId).Select(g => new Group(g.DepartmentGroupId, g.ParentDepartmentGroupId, + g.Members.Where(m => m.IsAdmin == true).Select(m => m.UserId).OrderBy(id => id, StringComparer.Ordinal).ToArray())).ToArray(), targets, ownedRoles, + current == null ? null : new Permission { Action = current.Action, LockToGroup = current.LockToGroup, Data = current.Data }); + } + private static string Fingerprint(Inputs input) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(input)))); + } +} diff --git a/Core/Resgrid.Services/AdminAssist/QualificationEvidenceSource.cs b/Core/Resgrid.Services/AdminAssist/QualificationEvidenceSource.cs new file mode 100644 index 000000000..a41fe3bd5 --- /dev/null +++ b/Core/Resgrid.Services/AdminAssist/QualificationEvidenceSource.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Services; + +namespace Resgrid.Services.AdminAssist +{ + public sealed class QualificationEvidenceSource(ICertificationService certifications, IAuthorizationService authorization, + IDepartmentsService departments) : IAdminAssistEvidenceSource + { + public string SourceId => "Qualifications"; + public IReadOnlyList EvidenceIds { get; } = new[] { "qualificationsExpiring30Days", "qualificationsExpiring60Days", "qualificationsExpiring90Days", "qualificationsExpired", "qualificationsPending", "qualificationsSuspended", "uncoveredQualificationCount" }; + public async Task> ReadAsync(AdminAssistActor actor, DateTime now, CancellationToken ct) + { + var department = await departments.GetDepartmentByIdAsync(actor.DepartmentId, true).WaitAsync(ct) + ?? throw new InvalidOperationException("Department unavailable."); + var zone = TimeZoneInfo.FindSystemTimeZoneById(department.TimeZone); + var localDate = TimeZoneInfo.ConvertTimeFromUtc(now, zone).Date; + var dashboard = await certifications.GetExpiryDashboardAsync(actor.DepartmentId, localDate).WaitAsync(ct) + ?? throw new InvalidOperationException("Qualification metadata unavailable."); + var limit = Math.Clamp(Config.AdminAssistConfig.MaxEvidenceRows, 1, 10000); + if (dashboard.PersonCells.Count + dashboard.UnitCells.Count > limit) throw new InvalidOperationException("Row bound exceeded."); + var visible = new List(); + foreach (var cell in dashboard.PersonCells) + { + ct.ThrowIfCancellationRequested(); + if (await authorization.CanUserViewPersonAsync(actor.UserId, cell.SubjectId, actor.DepartmentId)) visible.Add(cell); + else throw new UnauthorizedAccessException(); + } + foreach (var cell in dashboard.UnitCells) + { + ct.ThrowIfCancellationRequested(); + if (int.TryParse(cell.SubjectId, out var id) && await authorization.CanUserViewUnitAsync(actor.UserId, id)) visible.Add(cell); + else throw new UnauthorizedAccessException(); + } + ConfigurationEvidence Count(string id, int value) => new(id, EvidenceState.Known, SourceId, "1", now, Number: value); + var result = new List(); + foreach (var days in new[] { 30, 60, 90 }) + result.Add(Count($"qualificationsExpiring{days}Days", visible.Count(c => c.Status == (int)PersonnelCertificationStatuses.Active && c.DaysUntilExpiry >= 0 && c.DaysUntilExpiry <= days))); + result.Add(Count("qualificationsExpired", visible.Count(c => c.Status == (int)PersonnelCertificationStatuses.Expired || c.DaysUntilExpiry < 0))); + result.Add(Count("qualificationsPending", visible.Count(c => c.Status == (int)PersonnelCertificationStatuses.PendingVerification))); + result.Add(Count("qualificationsSuspended", visible.Count(c => c.Status == (int)PersonnelCertificationStatuses.Suspended || c.Status == (int)PersonnelCertificationStatuses.Revoked))); + var requirements = await certifications.GetAllRoleRequirementsAsync(actor.DepartmentId).WaitAsync(ct); + if (requirements == null || requirements.Count > limit) throw new InvalidOperationException("Role requirements unavailable."); + var uncovered = 0; + foreach (var role in requirements.Where(r => r.IsMandatory).Select(r => r.PersonnelRoleId).Distinct()) + { + var evaluations = await certifications.EvaluateRoleRequirementsAsync(actor.DepartmentId, role, localDate).WaitAsync(ct) + ?? throw new InvalidOperationException("Role evidence unavailable."); + if (evaluations.Count > limit) throw new InvalidOperationException("Row bound exceeded."); + foreach (var evaluation in evaluations) + if (!await authorization.CanUserViewPersonAsync(actor.UserId, evaluation.UserId, actor.DepartmentId)) throw new UnauthorizedAccessException(); + if (!evaluations.Any(e => e.Qualified)) uncovered++; + } + result.Add(Count("uncoveredQualificationCount", uncovered)); + return result; + } + } +} diff --git a/Core/Resgrid.Services/AdminAssist/ReadinessEvidenceSource.cs b/Core/Resgrid.Services/AdminAssist/ReadinessEvidenceSource.cs new file mode 100644 index 000000000..33c0e2d67 --- /dev/null +++ b/Core/Resgrid.Services/AdminAssist/ReadinessEvidenceSource.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Checklists; +using Resgrid.Model.Inventories; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Model.WorkOrders; + +namespace Resgrid.Services.AdminAssist +{ + /// Read-only summaries through the source's permissions and protection gates. No automatic sends or retries. + public sealed class ReadinessEvidenceSource(IChecklistsService checklists, IWorkOrdersService orders, + IWorkOrderReportingService orderReports, IInventoryOperationsService inventory, IWorkflowService workflows, + IRmsOperationalRecordsRepository records, IRecordsAuthorizationService authorization, IFeatureToggleService flags, + IAuthorizationService sourceAuthorization) : IAdminAssistEvidenceSource + { + public string SourceId => "Readiness"; + public IReadOnlyList EvidenceIds { get; } = new[] { "overdueChecklistCount", "activeSafetyHoldCount", "stockExpiring30Days", "stockShortageCount", "failedWorkflowCount", "overdueRecordReviewCount" }; + public async Task> ReadAsync(AdminAssistActor actor, DateTime now, CancellationToken ct) + { + var result = new List(); + var limit = Math.Clamp(Config.AdminAssistConfig.MaxEvidenceRows, 1, 10000); + var checklistActor = new ChecklistActor { DepartmentId = actor.DepartmentId, UserId = actor.UserId }; + var inventoryActor = new InventoryActor { DepartmentId = actor.DepartmentId, UserId = actor.UserId }; + async Task Read(string id, Func> read) + { + ct.ThrowIfCancellationRequested(); + try { result.Add(new ConfigurationEvidence(id, EvidenceState.Known, SourceId, "1", now, Number: await read().WaitAsync(ct))); } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch (UnauthorizedAccessException) { result.Add(new ConfigurationEvidence(id, EvidenceState.Redacted, SourceId, "1", now, ReasonCode: "SourceAccessUnavailable")); } + catch (Exception) { result.Add(new ConfigurationEvidence(id, EvidenceState.Unknown, SourceId, "1", now, ReasonCode: "SourceUnavailable")); } + } + await Read("overdueChecklistCount", async () => + { + var summary = await checklists.GetComplianceSummaryAsync(checklistActor, new ChecklistReportQuery { FromUtc = now.AddDays(-30), UntilUtc = now }); + if (summary == null || summary.UnavailableSources.Count > 0 || summary.Entries.Count > limit) throw new InvalidOperationException("Incomplete checklist evidence."); + if (summary.IsRedacted) throw new UnauthorizedAccessException(); + return summary.Entries.Count(e => e.Expected && !e.Completed && !e.Skipped && e.DueUtc < now); + }); + await Read("activeSafetyHoldCount", async () => + { + var holds = new HashSet(StringComparer.Ordinal); + int rows = 0; + for (int page = 0; ; page++) + { + var list = await orders.ListAsync(checklistActor, new WorkOrderFilter { Page = page }) ?? throw new InvalidOperationException("Orders unavailable."); + rows += list.Items.Count; + if (rows > limit) throw new InvalidOperationException("Row bound exceeded."); + foreach (var order in list.Items) + { + ct.ThrowIfCancellationRequested(); + var evidence = await orderReports.GetWorkOrderHoldsAsync(checklistActor, order.Id); + if (evidence == null || evidence.NextAfterId != null) throw new InvalidOperationException("Incomplete hold evidence."); + foreach (var hold in evidence.Items.Where(h => !h.Hold.ReleasedOn.HasValue)) holds.Add(hold.Hold.Id); + } + if (!list.HasMore) break; + if (list.Items.Count == 0) throw new InvalidOperationException("Invalid page."); + } + return holds.Count; + }); + await Read("stockExpiring30Days", async () => + { + var report = await inventory.BuildReportAsync(inventoryActor, new InventoryReportInput { Kind = InventoryReportKind.Expiration, FromUtc = now, UntilUtc = now.AddDays(30) }); + if (report?.Rows == null || report.Rows.Count > limit) throw new InvalidOperationException("Incomplete stock evidence."); + return report.Rows.Count; + }); + await Read("stockShortageCount", async () => + { + var report = await inventory.BuildReportAsync(inventoryActor, new InventoryReportInput { Kind = InventoryReportKind.LowStock }); + if (report?.Rows == null || report.Rows.Count > limit) throw new InvalidOperationException("Incomplete stock evidence."); + return report.Rows.Count; + }); + await Read("failedWorkflowCount", async () => + { + if (!await sourceAuthorization.CanUserViewWorkflowRunsAsync(actor.UserId, actor.DepartmentId)) throw new UnauthorizedAccessException(); + int count = 0, rows = 0; + for (int page = 1; ; page++) + { + var runs = await workflows.GetRunsByDepartmentIdAsync(actor.DepartmentId, page, 100, ct) ?? throw new InvalidOperationException("Runs unavailable."); + rows += runs.Count; + if (rows > limit) throw new InvalidOperationException("Row bound exceeded."); + count += runs.Count(r => r.DepartmentId == actor.DepartmentId && r.StartedOn >= now.AddDays(-7) && r.StartedOn <= now && r.Status == (int)WorkflowRunStatus.Failed); + if (runs.Count < 100 || runs.All(r => r.StartedOn < now.AddDays(-7))) break; + } + return count; + }); + await Read("overdueRecordReviewCount", async () => + { + if ((await flags.EvaluateFreshAsync(FeatureFlagKeys.RecordsSystem, actor.DepartmentId))?.IsEnabled != true) throw new InvalidOperationException("Records unavailable."); + if (!await authorization.HasPermissionAsync(actor.UserId, actor.DepartmentId, PermissionTypes.ReviewRecords)) throw new UnauthorizedAccessException(); + var rows = (await records.GetOpenAsync(actor.DepartmentId))?.ToList() ?? throw new InvalidOperationException("Records unavailable."); + if (rows.Count > limit) throw new InvalidOperationException("Row bound exceeded."); + int count = 0; + foreach (var row in rows.Where(r => r.DepartmentId == actor.DepartmentId && r.State == (int)RmsRecordState.ReadyForReview && r.ReviewDueOn < now)) + { + if (!await authorization.CanUserViewRecordAsync(actor.UserId, row.RmsOperationalRecordId, actor.DepartmentId)) throw new UnauthorizedAccessException(); + count++; + } + return count; + }); + return result; + } + } +} diff --git a/Core/Resgrid.Services/AdminAssist/RetentionImpactService.cs b/Core/Resgrid.Services/AdminAssist/RetentionImpactService.cs new file mode 100644 index 000000000..9c4aeb121 --- /dev/null +++ b/Core/Resgrid.Services/AdminAssist/RetentionImpactService.cs @@ -0,0 +1,80 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services.AdminAssist +{ + /// Prospective policy preview, preserving the same historical policy boundaries as the owning editor. + public sealed class RetentionImpactService(IAdminAssistAccessService access, IAdminAssistRepository repository, + IAdminAssistCatalog catalog, IDepartmentSettingsRepository settings, IRetentionImpactStore store, TimeProvider clock, + IRecordsAuthorizationService authorization, IRecordsCutoverService cutover) : IRetentionImpactService + { + public async Task PreviewAsync(AdminAssistActor actor, RetentionImpactRequest request, CancellationToken ct = default) + { + if (!await access.CanAccessAsync(actor, false, ct)) throw new UnauthorizedAccessException(); + if (request == null || request.ProposedDefaultYears is < 0 or > 1000) throw new ArgumentException("Invalid retention proposal."); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(TimeSpan.FromSeconds(Math.Clamp(Config.AdminAssistConfig.SnapshotTimeoutSeconds, 1, 60))); + ct = timeout.Token; + var revision = (await repository.GetConfigurationRevisionAsync(actor.DepartmentId, ct)).ToString(CultureInfo.InvariantCulture); + if (revision != request.ExpectedRevision) throw new AdminAssistConcurrencyException(); + var now = clock.GetUtcNow().UtcDateTime; + ConfigurationImpactMetric[] metrics; + try + { + if (!await authorization.HasPermissionAsync(actor.UserId, actor.DepartmentId, PermissionTypes.ViewRestrictedRecords)) throw new UnauthorizedAccessException(); + if ((await cutover.GetModuleStateAsync(actor.DepartmentId, true).WaitAsync(ct))?.FlagEnabled != true) throw new UnauthorizedAccessException(); + var raw = await ReadPolicyAsync(actor.DepartmentId, ct); + var policy = string.IsNullOrEmpty(raw) ? new RecordsRetentionPolicy() : ObjectSerialization.Deserialize(raw) ?? throw new InvalidOperationException(); + var proposed = new RecordsRetentionPolicy { DepartmentDefaultYears = request.ProposedDefaultYears, + Overrides = policy.Overrides?.Select(o => new RecordsRetentionOverride { DefinitionKey = o.DefinitionKey, RetentionYears = o.RetentionYears, AppliesFrom = o.AppliesFrom }).ToList() }; + proposed.PreserveHistory(policy, now); + var bound = Math.Min(250, Math.Clamp(Config.AdminAssistConfig.MaxEvidenceRows, 1, 10000)); + var rows = (await store.ReadRetentionHeadersAsync(actor.DepartmentId, bound, ct))?.ToArray() ?? throw new InvalidOperationException(); + if (rows.Length > 2 * (bound + 1) || rows.Any(r => r == null || string.IsNullOrEmpty(r.RecordId) || r.FinalizedOn > now || r.ModifiedOn > now || + r.Kind != (int)RmsRecordKind.Operational && r.Kind != (int)RmsRecordKind.IncidentReport)) throw new InvalidOperationException(); + foreach (var row in rows) + if (!await authorization.CanUserViewRecordAsync(actor.UserId, row.RecordId, actor.DepartmentId)) throw new UnauthorizedAccessException(); + if (raw != await ReadPolicyAsync(actor.DepartmentId, ct) || JsonConvert.SerializeObject(rows) != JsonConvert.SerializeObject(await store.ReadRetentionHeadersAsync(actor.DepartmentId, bound, ct))) throw new AdminAssistConcurrencyException(); + var complete = rows.GroupBy(r => r.Kind).All(g => g.Count() <= bound); + var sample = rows.GroupBy(r => r.Kind).SelectMany(g => g.Take(bound)).ToArray(); + bool Window(RetentionImpactHeader row, RecordsRetentionPolicy value) => row.AmendsRevisionId == null && row.ModifiedOn <= now.AddHours(-25) && + new[] { RmsRecordState.Finalized, RmsRecordState.Amended, RmsRecordState.Accepted, RmsRecordState.Voided, RmsRecordState.Cancelled }.Contains((RmsRecordState)row.State) && + RecordsRetentionWindow.HasExpired(row.FinalizedOn, row.FinalizedOn.HasValue ? value.ResolveYears(row.DefinitionKey, row.FinalizedOn.Value) : 0, now); + var before = sample.Where(r => Window(r, policy)).ToArray(); var after = sample.Where(r => Window(r, proposed)).ToArray(); + metrics = new[] { + new ConfigurationImpactMetric("Impact.RetentionSample", EvidenceState.Known, sample.Length, sample.Length), + new ConfigurationImpactMetric("Impact.RetentionFullPopulation", EvidenceState.Known, complete ? 1 : 0, complete ? 1 : 0), + new ConfigurationImpactMetric("Impact.RetentionDefault", EvidenceState.Known, policy.DepartmentDefaultYears ?? RecordsRetentionPolicy.StandardClassDefaultYears, request.ProposedDefaultYears ?? RecordsRetentionPolicy.StandardClassDefaultYears), + new ConfigurationImpactMetric("Impact.RetentionExpired", EvidenceState.Known, before.Length, after.Length), + new ConfigurationImpactMetric("Impact.RetentionHeld", EvidenceState.Known, before.Count(r => r.HoldOrPermanentContent != 0), after.Count(r => r.HoldOrPermanentContent != 0)), + new ConfigurationImpactMetric("Impact.RetentionHoldUnknown", EvidenceState.Known, before.Count(r => r.HoldOrPermanentContent == 0 && r.HistoricalHoldUncertainty != 0), after.Count(r => r.HoldOrPermanentContent == 0 && r.HistoricalHoldUncertainty != 0)), + new ConfigurationImpactMetric("Impact.RetentionCandidates", EvidenceState.Known, before.Count(r => r.HoldOrPermanentContent == 0 && r.HistoricalHoldUncertainty == 0), after.Count(r => r.HoldOrPermanentContent == 0 && r.HistoricalHoldUncertainty == 0)), + new ConfigurationImpactMetric("Impact.RetentionActualEligibility", EvidenceState.Unknown, null, null, "OwningLifecycleDependenciesNotEvaluated") }; + } + catch (AdminAssistConcurrencyException) { throw; } + catch (UnauthorizedAccessException) { throw; } + catch (OperationCanceledException) { throw; } + catch (Exception) { metrics = new[] { new ConfigurationImpactMetric("Impact.RetentionCandidates", EvidenceState.Unknown, null, null, "SourceUnavailable") }; } + if ((await repository.GetConfigurationRevisionAsync(actor.DepartmentId, ct)).ToString(CultureInfo.InvariantCulture) != revision) throw new AdminAssistConcurrencyException(); + if ((await cutover.GetModuleStateAsync(actor.DepartmentId, true).WaitAsync(ct))?.FlagEnabled != true || !await access.CanAccessAsync(actor, false, ct) || !await authorization.HasPermissionAsync(actor.UserId, actor.DepartmentId, PermissionTypes.ViewRestrictedRecords)) throw new UnauthorizedAccessException(); + var entry = catalog.Settings.Single(s => s.Id == "setting.RecordsRetentionPolicy"); + return new(entry.Id, revision, now, "retention-window-v1", entry.Impact, metrics, Array.Empty(), + new[] { "Impact.NoMutation", "Impact.RetentionHistory", "Impact.RetentionLimits", "Impact.RetentionProtection", "Impact.Window" }, entry.Location.Url); + } + private async Task ReadPolicyAsync(int departmentId, CancellationToken ct) + { + var rows = (await settings.GetAllByDepartmentIdAsync(departmentId).WaitAsync(ct))?.ToList() ?? throw new InvalidOperationException(); + if (rows.Count > Math.Clamp(Config.AdminAssistConfig.MaxEvidenceRows, 1, 10000) || rows.Any(r => r.DepartmentId != departmentId)) throw new InvalidOperationException(); + return rows.SingleOrDefault(r => r.SettingType == (int)DepartmentSettingTypes.RecordsRetentionPolicy)?.Setting; + } + } +} diff --git a/Core/Resgrid.Services/AdminAssist/SecurityImpactService.cs b/Core/Resgrid.Services/AdminAssist/SecurityImpactService.cs new file mode 100644 index 000000000..dbf2388f4 --- /dev/null +++ b/Core/Resgrid.Services/AdminAssist/SecurityImpactService.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Model; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Repositories; + +namespace Resgrid.Services.AdminAssist +{ + public sealed class SecurityImpactService(IAdminAssistAccessService access, IAdminAssistRepository repository, + IAdminAssistCatalog catalog, ISecurityImpactStore store, TimeProvider clock) : ISecurityImpactService + { + public static IReadOnlyList Supported { get; } = Array.AsReadOnly(new[] { + "RequireMfa", "RequireSso", "SessionTimeoutMinutes", "MaxConcurrentSessions", "PasswordExpirationDays", "MinPasswordLength" }); + public async Task PreviewAsync(AdminAssistActor actor, ConfigurationImpactRequest request, CancellationToken ct = default) + { + if (!await access.CanAccessAsync(actor, false, ct)) throw new UnauthorizedAccessException(); + var entry = catalog.Settings.SingleOrDefault(s => s.Id == request?.SettingId && s.Binding.StartsWith("DepartmentSecurityPolicy.", StringComparison.Ordinal)); + var field = entry?.Binding.Substring("DepartmentSecurityPolicy.".Length); + if (field == null || !Supported.Contains(field)) throw new ArgumentException("Unsupported security proposal."); + var boolean = field is "RequireMfa" or "RequireSso"; + if (boolean ? !request.Boolean.HasValue || request.Number.HasValue : request.Boolean.HasValue || !request.Number.HasValue || + request.Number != decimal.Truncate(request.Number.Value) || request.Number < (field == "MinPasswordLength" ? 8 : 0) || + request.Number > (field == "MinPasswordLength" ? 128 : field == "SessionTimeoutMinutes" ? 43200 : field == "MaxConcurrentSessions" ? 1000 : 36500)) + throw new ArgumentException("Invalid security proposal."); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(TimeSpan.FromSeconds(Math.Clamp(Config.AdminAssistConfig.SnapshotTimeoutSeconds, 1, 60))); ct = timeout.Token; + var revision = (await repository.GetConfigurationRevisionAsync(actor.DepartmentId, ct)).ToString(CultureInfo.InvariantCulture); + if (request.ExpectedRevision != revision) throw new AdminAssistConcurrencyException(); + var now = clock.GetUtcNow().UtcDateTime; + var metrics = new List(); + try + { + var current = await ReadPolicyAsync(actor.DepartmentId, ct); + var proposed = Copy(current); Apply(proposed, field, request); + var bound = Math.Clamp(Config.AdminAssistConfig.MaxEvidenceRows, 1, 10000); + var input = await store.ReadSecurityImpactAsync(actor.DepartmentId, now, bound, ct); + Validate(input, actor.DepartmentId, bound, now); + var gate = Config.SessionSecurityConfig.DepartmentSessionPolicyEnforcementAfterUtc; + if (JsonConvert.SerializeObject(current) != JsonConvert.SerializeObject(await ReadPolicyAsync(actor.DepartmentId, ct)) || + JsonConvert.SerializeObject(input) != JsonConvert.SerializeObject(await store.ReadSecurityImpactAsync(actor.DepartmentId, now, bound, ct)) || + gate != Config.SessionSecurityConfig.DepartmentSessionPolicyEnforcementAfterUtc) throw new AdminAssistConcurrencyException(); + void Add(string key, decimal before, decimal after) => metrics.Add(new("Impact.Security" + key, EvidenceState.Known, before, after)); + void Unknown(string key) => metrics.Add(new("Impact.Security" + key, EvidenceState.Unknown, null, null, "NotVerified")); + Add("Members", input.Members.Count, input.Members.Count); + Add("IdentityUnknown", input.Members.Count(m => !m.TwoFactorEnabled.HasValue), input.Members.Count(m => !m.TwoFactorEnabled.HasValue)); + if (boolean) Add("PolicyValue", field == "RequireMfa" ? current.RequireMfa ? 1 : 0 : current.RequireSso ? 1 : 0, request.Boolean.Value ? 1 : 0); + else Add("PolicyValue", Value(current, field), Value(proposed, field)); + if (field == "RequireMfa") + { + var missing = input.Members.Count(m => m.TwoFactorEnabled == false); var unknown = input.Members.Count(m => !m.TwoFactorEnabled.HasValue); + Add("MfaEnrollmentMinimum", current.RequireMfa ? missing : 0, proposed.RequireMfa ? missing : 0); + Add("MfaEnrollmentMaximum", current.RequireMfa ? missing + unknown : 0, proposed.RequireMfa ? missing + unknown : 0); + Add("MfaCompletionRequired", DepartmentSecurityPolicyDecisions.RequiresMfaCompletion(current.RequireMfa, false) ? input.Members.Count : 0, + DepartmentSecurityPolicyDecisions.RequiresMfaCompletion(proposed.RequireMfa, false) ? input.Members.Count : 0); + Unknown("RecoveryReadiness"); + } + if (field == "RequireSso") + { + Add("EnabledProviders", input.EnabledSsoProviders, input.EnabledSsoProviders); + Add("PasswordPathBlocked", DepartmentSecurityPolicyDecisions.BlocksPasswordLogin(current.RequireSso, input.EnabledSsoProviders > 0, false) ? input.Members.Count : 0, + DepartmentSecurityPolicyDecisions.BlocksPasswordLogin(proposed.RequireSso, input.EnabledSsoProviders > 0, false) ? input.Members.Count : 0); + Add("SsoSafetyValve", current.RequireSso && input.EnabledSsoProviders == 0 ? 1 : 0, proposed.RequireSso && input.EnabledSsoProviders == 0 ? 1 : 0); + Unknown("ProviderAndRecovery"); + } + if (field == "PasswordExpirationDays") + { + Add("UntrackedPasswordAge", input.Members.Count(m => !m.PasswordLastSetOn.HasValue), input.Members.Count(m => !m.PasswordLastSetOn.HasValue)); + Add("ExpiredPasswordAge", input.Members.Count(m => DepartmentSecurityPolicyDecisions.PasswordExpired(current.PasswordExpirationDays, m.PasswordLastSetOn, now)), + input.Members.Count(m => DepartmentSecurityPolicyDecisions.PasswordExpired(proposed.PasswordExpirationDays, m.PasswordLastSetOn, now))); + } + if (field == "MinPasswordLength") + { + Add("MinimumLength", DepartmentSecurityPolicyDecisions.MinimumPasswordLength(current.MinPasswordLength), DepartmentSecurityPolicyDecisions.MinimumPasswordLength(proposed.MinPasswordLength)); + Unknown("ExistingPasswordCompliance"); + } + if (field is "SessionTimeoutMinutes" or "MaxConcurrentSessions") + { + var parsed = DepartmentSecurityPolicyDecisions.TryGetSessionGate(gate, out var gateUtc); + Add("ActiveSessionSample", input.Sessions.Count, input.Sessions.Count); + Add("SessionGateActive", parsed && now >= gateUtc ? 1 : 0, parsed && now >= gateUtc ? 1 : 0); + var managed = input.Sessions.Where(s => parsed && s.CreatedOn >= gateUtc).ToArray(); + Add("ManagedSessions", managed.Length, managed.Length); + if (field == "SessionTimeoutMinutes") + { + var validGeneration = managed.Where(s => s.CurrentGeneration.HasValue && s.AuthenticationGeneration == s.CurrentGeneration).ToArray(); + Add("IdleExpiryCandidates", validGeneration.Count(s => DepartmentSecurityPolicyDecisions.IdleExpired(current.SessionTimeoutMinutes, s.LastActiveOn, now)), + validGeneration.Count(s => DepartmentSecurityPolicyDecisions.IdleExpired(proposed.SessionTimeoutMinutes, s.LastActiveOn, now))); + Unknown("ActualReauthentication"); + } + else + { + int AtLimit(int limit) => parsed && now >= gateUtc && limit > 0 ? managed.GroupBy(s => s.UserId, StringComparer.OrdinalIgnoreCase).Count(g => g.Count() >= limit) : 0; + Add("AtSessionLimit", AtLimit(current.MaxConcurrentSessions), AtLimit(proposed.MaxConcurrentSessions)); + } + } + } + catch (AdminAssistConcurrencyException) { throw; } + catch (UnauthorizedAccessException) { throw; } + catch (OperationCanceledException) { throw; } + catch (Exception) { metrics.Clear(); metrics.Add(new("Impact.SecurityMembers", EvidenceState.Unknown, null, null, "SourceUnavailableOrBoundExceeded")); } + if ((await repository.GetConfigurationRevisionAsync(actor.DepartmentId, ct)).ToString(CultureInfo.InvariantCulture) != revision) throw new AdminAssistConcurrencyException(); + if (!await access.CanAccessAsync(actor, false, ct)) throw new UnauthorizedAccessException(); + return new(entry.Id, revision, now, "security-policy-impact-v1", entry.Impact, metrics, Array.Empty(), + new[] { "Impact.NoMutation", "Impact.SecurityScope", "Impact.Security" + field + "Scope", "Impact.Window" }, entry.Location.Url); + } + private async Task ReadPolicyAsync(int departmentId, CancellationToken ct) + { + var row = await store.ReadSecurityPolicyAsync(departmentId, ct); + if (row != null && row.DepartmentId != departmentId) throw new InvalidOperationException(); + return Copy(row ?? new DepartmentSecurityPolicy { DepartmentId = departmentId }); + } + private static DepartmentSecurityPolicy Copy(DepartmentSecurityPolicy p) => new() { DepartmentId = p.DepartmentId, RequireMfa = p.RequireMfa, RequireSso = p.RequireSso, + SessionTimeoutMinutes = p.SessionTimeoutMinutes, MaxConcurrentSessions = p.MaxConcurrentSessions, PasswordExpirationDays = p.PasswordExpirationDays, MinPasswordLength = p.MinPasswordLength }; + private static decimal Value(DepartmentSecurityPolicy p, string field) => field switch { "SessionTimeoutMinutes" => p.SessionTimeoutMinutes, + "MaxConcurrentSessions" => p.MaxConcurrentSessions, "PasswordExpirationDays" => p.PasswordExpirationDays, "MinPasswordLength" => p.MinPasswordLength, _ => throw new ArgumentException() }; + private static void Apply(DepartmentSecurityPolicy p, string field, ConfigurationImpactRequest request) + { + switch (field) { case "RequireMfa": p.RequireMfa = request.Boolean.Value; break; case "RequireSso": p.RequireSso = request.Boolean.Value; break; + case "SessionTimeoutMinutes": p.SessionTimeoutMinutes = (int)request.Number.Value; break; case "MaxConcurrentSessions": p.MaxConcurrentSessions = (int)request.Number.Value; break; + case "PasswordExpirationDays": p.PasswordExpirationDays = (int)request.Number.Value; break; case "MinPasswordLength": p.MinPasswordLength = (int)request.Number.Value; break; } + } + private static void Validate(SecurityImpactEvidence input, int departmentId, int bound, DateTime now) + { + if (input?.Members == null || input.Sessions == null || input.Members.Count > bound || input.Sessions.Count > bound || input.EnabledSsoProviders < 0 || input.EnabledSsoProviders > bound || + input.Members.Any(m => m == null || m.DepartmentId != departmentId || m.MemberId <= 0 || string.IsNullOrWhiteSpace(m.UserId) || m.PasswordLastSetOn > now) || + input.Members.Select(m => m.UserId).Distinct(StringComparer.OrdinalIgnoreCase).Count() != input.Members.Count || + input.Sessions.Any(s => s == null || s.DepartmentId != departmentId || string.IsNullOrWhiteSpace(s.Id) || !input.Members.Any(m => string.Equals(m.UserId, s.UserId, StringComparison.OrdinalIgnoreCase)) || + s.CreatedOn == default || s.CreatedOn > now || s.LastActiveOn < s.CreatedOn || s.LastActiveOn > now || s.ExpiresOn <= now) || + input.Sessions.Select(s => s.Id).Distinct(StringComparer.Ordinal).Count() != input.Sessions.Count) throw new InvalidOperationException(); + } + } +} diff --git a/Core/Resgrid.Services/AdminAssist/SettingsEvidenceSource.cs b/Core/Resgrid.Services/AdminAssist/SettingsEvidenceSource.cs new file mode 100644 index 000000000..d33c18917 --- /dev/null +++ b/Core/Resgrid.Services/AdminAssist/SettingsEvidenceSource.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Repositories; + +namespace Resgrid.Services.AdminAssist +{ + /// One uncached repository read. Only explicit scalar bindings and secret-presence markers leave it. + public sealed class SettingsEvidenceSource(IDepartmentSettingsRepository settings) : IAdminAssistEvidenceSource + { + private static readonly DepartmentSettingTypes[] BooleanSettings = + { + DepartmentSettingTypes.DispatchShiftInsteadOfGroup, DepartmentSettingTypes.AutoSetStatusForShiftDispatchPersonnel, + DepartmentSettingTypes.EnableTextToCall, DepartmentSettingTypes.EnableTextCommand, + DepartmentSettingTypes.MappingUseMapboxOverride, DepartmentSettingTypes.CheckInTimersAutoEnableForNewCalls, + DepartmentSettingTypes.WeatherAlertsEnabled, DepartmentSettingTypes.RequirePasswordResetViaEmail, + DepartmentSettingTypes.UnitDispatchAlsoDispatchToAssignedPersonnel, DepartmentSettingTypes.UnitDispatchAlsoDispatchToGroup, + DepartmentSettingTypes.AllowSignupsForMultipleShiftGroups, DepartmentSettingTypes.DisabledAutoAvailable, + DepartmentSettingTypes.PersonnelOnUnitSetUnitStatus, DepartmentSettingTypes.DispatchRecommendationAutoDispatch, + DepartmentSettingTypes.MappingPersonnelAllowStatusWithNoLocationToOverwrite, + DepartmentSettingTypes.MappingUnitAllowStatusWithNoLocationToOverwrite + }; + private static readonly Dictionary NumberSettings = new() + { + [DepartmentSettingTypes.Require2FAForAdmins] = 0, [DepartmentSettingTypes.MappingPersonnelLocationTTL] = 0, + [DepartmentSettingTypes.MappingUnitLocationTTL] = 0, [DepartmentSettingTypes.DispatchRecommendationMode] = 0, + [DepartmentSettingTypes.ShiftCallDispatchPersonnelStatusToSet] = -1, + [DepartmentSettingTypes.ShiftCallReleasePersonnelStatusToSet] = -1, + [DepartmentSettingTypes.UnitCallDispatchStatusToSet] = -1, [DepartmentSettingTypes.UnitCallReleaseStatusToSet] = -1 + }; + private static readonly Dictionary PresenceSettings = new() + { + ["textSourcePresent"] = DepartmentSettingTypes.TextToCallSourceNumbers, + ["mapTokenPresent"] = DepartmentSettingTypes.MappingMapboxAccessToken, + ["mapStylePresent"] = DepartmentSettingTypes.MappingMapboxStyleUrl + }; + public string SourceId => "DepartmentSettings"; + public IReadOnlyList EvidenceIds { get; } = BooleanSettings.Select(s => s.ToString()) + .Concat(NumberSettings.Keys.Select(s => s.ToString())).Concat(PresenceSettings.Keys).ToArray(); + + public async Task> ReadAsync(AdminAssistActor actor, DateTime now, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + var rows = await settings.GetAllByDepartmentIdAsync(actor.DepartmentId).WaitAsync(ct) + ?? throw new InvalidOperationException("Settings source unavailable."); + var materialized = rows.ToList(); + if (materialized.Count > Math.Clamp(Config.AdminAssistConfig.MaxEvidenceRows, 1, 10000) || materialized.Any(r => r.DepartmentId != actor.DepartmentId)) throw new InvalidOperationException("Invalid settings scope or bound."); + var data = materialized.ToDictionary(r => (DepartmentSettingTypes)r.SettingType, r => r.Setting); + var values = new List(); + foreach (var type in BooleanSettings) + { + var known = !data.TryGetValue(type, out var raw) || bool.TryParse(raw, out _); + values.Add(new ConfigurationEvidence(type.ToString(), known ? EvidenceState.Known : EvidenceState.Unknown, + SourceId, "1", now, Boolean: known ? raw != null && bool.Parse(raw) : null, ReasonCode: known ? null : "InvalidStoredValue")); + } + foreach (var (type, fallback) in NumberSettings) + { + var value = (decimal)fallback; + var known = !data.TryGetValue(type, out var raw) || decimal.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out value); + values.Add(new ConfigurationEvidence(type.ToString(), known ? EvidenceState.Known : EvidenceState.Unknown, + SourceId, "1", now, Number: known ? value : null, ReasonCode: known ? null : "InvalidStoredValue")); + } + foreach (var (id, type) in PresenceSettings) + values.Add(new ConfigurationEvidence(id, EvidenceState.Known, SourceId, "1", now, + Boolean: data.TryGetValue(type, out var value) && !string.IsNullOrWhiteSpace(value))); + return values; + } + } +} diff --git a/Core/Resgrid.Services/AdminAssist/StaffingEvidenceSource.cs b/Core/Resgrid.Services/AdminAssist/StaffingEvidenceSource.cs new file mode 100644 index 000000000..8e8449582 --- /dev/null +++ b/Core/Resgrid.Services/AdminAssist/StaffingEvidenceSource.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services.AdminAssist +{ + /// Uses the owning roster builder for approved signups/trades. Counts are administrative evidence, not qualification clearance. + public sealed class StaffingEvidenceSource(IShiftsService shifts, IDepartmentsService departments, + IDepartmentGroupsRepository groups, IAuthorizationService authorization, IRecordsAuthorizationService membership) : IAdminAssistEvidenceSource + { + public string SourceId => "ResolvedShiftRoster"; + public IReadOnlyList EvidenceIds { get; } = new[] { "groupsWithoutShiftCoverage", "upcomingOpenShiftSlots", "upcomingShiftCount", "overlappingShiftPersonnel", "unfilledShiftTrades", "singlePersonShiftGroups" }; + public async Task> ReadAsync(AdminAssistActor actor, DateTime now, CancellationToken ct) + { + var department = await departments.GetDepartmentByIdAsync(actor.DepartmentId, true) ?? throw new InvalidOperationException("Department unavailable."); + var localNow = TimeZoneInfo.ConvertTimeFromUtc(now, TimeZoneInfo.FindSystemTimeZoneById(department.TimeZone)); + var bound = Math.Clamp(Config.AdminAssistConfig.MaxEvidenceRows, 1, 10000); + var schedules = await shifts.ReadSchedulesForAdministrationAsync(actor.DepartmentId, localNow.Date.AddDays(-3), localNow.Date.AddDays(7), now, bound, ct) + ?? throw new InvalidOperationException("Roster unavailable."); + var groupRows = (await groups.GetAllGroupsByDepartmentIdAsync(actor.DepartmentId))?.ToList() ?? throw new InvalidOperationException("Groups unavailable."); + if (groupRows.Count + groupRows.Sum(g => g.Members?.Count ?? 0) > bound || groupRows.Any(g => g.DepartmentId != actor.DepartmentId || g.Members == null)) + throw new InvalidOperationException("Incomplete group evidence."); + var people = schedules.SelectMany(s => s.Roster).Select(r => r.UserId).Concat(groupRows.SelectMany(g => g.Members).Select(m => m.UserId)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + if (people.Length > bound) throw new InvalidOperationException("Roster bound exceeded."); + foreach (var userId in people) + { + ct.ThrowIfCancellationRequested(); + if (!await authorization.CanUserViewPersonAsync(actor.UserId, userId, actor.DepartmentId)) throw new UnauthorizedAccessException(); + // Disabled/stale roster references require source cleanup; they cannot prove coverage. + if (!await membership.IsAssignableMemberAsync(userId, actor.DepartmentId)) throw new InvalidOperationException("Roster membership requires review."); + } + var active = schedules.Where(s => s.Day.Start <= localNow && s.Day.End > localNow).SelectMany(s => s.Roster).Where(r => r.IsOnDuty()).ToArray(); + var counts = groupRows.Select(g => active.Where(r => r.DepartmentGroupId == g.DepartmentGroupId || + (!r.DepartmentGroupId.HasValue && g.Members.Any(m => string.Equals(m.UserId, r.UserId, StringComparison.OrdinalIgnoreCase)))).Select(r => r.UserId).Distinct(StringComparer.OrdinalIgnoreCase).Count()).ToArray(); + var upcoming = schedules.Where(s => s.Day.End > localNow && s.Day.Start < localNow.AddDays(7)).ToArray(); + var assignments = upcoming.SelectMany(s => s.Roster.Where(r => r.IsOnDuty()).Select(r => new { r.UserId, s.Day.ShiftDayId, s.Day.Start, s.Day.End })); + var overlapping = assignments.GroupBy(a => a.UserId, StringComparer.OrdinalIgnoreCase).Count(g => + g.Any(a => g.Any(b => a.ShiftDayId != b.ShiftDayId && a.Start < b.End && b.Start < a.End))); + ConfigurationEvidence Count(string id, int value) => new(id, EvidenceState.Known, SourceId, "roster-v1", now, Number: value); + return new[] { Count("groupsWithoutShiftCoverage", counts.Count(c => c == 0)), Count("singlePersonShiftGroups", counts.Count(c => c == 1)), + Count("upcomingShiftCount", upcoming.Length), Count("upcomingOpenShiftSlots", upcoming.Sum(s => s.OpenSlots())), + Count("overlappingShiftPersonnel", overlapping), Count("unfilledShiftTrades", upcoming.SelectMany(s => s.Trades).Where(t => !t.Denied && !t.IsTradeComplete()).Select(t => t.ShiftSignupTradeId).Distinct().Count()) }; + } + } +} diff --git a/Core/Resgrid.Services/AdminAssist/StatusAutomationImpactProvider.cs b/Core/Resgrid.Services/AdminAssist/StatusAutomationImpactProvider.cs new file mode 100644 index 000000000..4f1d0963a --- /dev/null +++ b/Core/Resgrid.Services/AdminAssist/StatusAutomationImpactProvider.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services.AdminAssist +{ + /// Uses the owning status query's actual one-hour reset filter. Preview never creates a status row. + public sealed class StatusAutomationImpactProvider(IActionLogsRepository actions, IAuthorizationService visibility, + IRecordsAuthorizationService membership) : IOperationalImpactProvider + { + public bool Supports(string settingId) => settingId == "setting.DisabledAutoAvailable"; + public async Task EvaluateAsync(AdminAssistActor actor, ConfigurationSnapshot snapshot, ConfigurationImpactRequest request, CancellationToken ct) + { + if (!Supports(request.SettingId) || !request.Boolean.HasValue) throw new ArgumentException("A status-automation proposal is required."); + OperationalImpact Unknown(EvidenceState state) => new(new[] { new ConfigurationImpactMetric("Impact.StatusChanges", state, null, null, "SourceUnavailable") }, new[] { "Impact.StatusScope", "Impact.StatusEvidenceUnavailable" }, "auto-available-v1"); + var current = snapshot.Find("DisabledAutoAvailable"); + if (!current.IsFresh(snapshot.AsOfUtc, TimeSpan.FromSeconds(Math.Clamp(Config.AdminAssistConfig.EvidenceFreshnessSeconds, 1, 300))) || !current.Boolean.HasValue) return Unknown(EvidenceState.Unknown); + try + { + var limit = Math.Clamp(Config.AdminAssistConfig.MaxEvidenceRows, 1, 10000); + async Task> Read(bool disabled) + { + var rows = await actions.ReadLatestForAdministrationAsync(actor.DepartmentId, disabled, snapshot.AsOfUtc, limit, ct) ?? throw new InvalidOperationException(); + if (rows.Count > limit || rows.Any(r => r.DepartmentId != actor.DepartmentId)) throw new InvalidOperationException(); + return rows.ToDictionary(r => r.UserId, StringComparer.Ordinal); + } + var before = await Read(current.Boolean.Value); + var after = current.Boolean.Value == request.Boolean.Value ? before : await Read(request.Boolean.Value); + var ids = before.Keys.Concat(after.Keys).Distinct(StringComparer.Ordinal).ToArray(); + int changed = 0, standingByBefore = 0, standingByAfter = 0; + foreach (var id in ids) + { + ct.ThrowIfCancellationRequested(); + if (!await membership.IsAssignableMemberAsync(id, actor.DepartmentId) || !await visibility.CanUserViewPersonAsync(actor.UserId, id, actor.DepartmentId)) throw new UnauthorizedAccessException(); + var was = before.TryGetValue(id, out var old) ? old.ActionTypeId : (int)ActionTypes.StandingBy; + var next = after.TryGetValue(id, out var proposed) ? proposed.ActionTypeId : (int)ActionTypes.StandingBy; + if (was != next) changed++; + if (was == (int)ActionTypes.StandingBy) standingByBefore++; + if (next == (int)ActionTypes.StandingBy) standingByAfter++; + } + return new(new[] { + new ConfigurationImpactMetric("Impact.StatusChanges", EvidenceState.Known, 0, changed), + new ConfigurationImpactMetric("Impact.StatusStandingBy", EvidenceState.Known, standingByBefore, standingByAfter), + new ConfigurationImpactMetric("Impact.StatusSample", EvidenceState.Known, ids.Length, ids.Length) + }, new[] { "Impact.StatusScope", "Impact.StatusTiming" }, "auto-available-v1"); + } + catch (OperationCanceledException) { throw; } + catch (UnauthorizedAccessException) { return Unknown(EvidenceState.Redacted); } + catch (Exception) { return Unknown(EvidenceState.Unknown); } + } + } +} diff --git a/Core/Resgrid.Services/AdminAssist/TextImportImpactService.cs b/Core/Resgrid.Services/AdminAssist/TextImportImpactService.cs new file mode 100644 index 000000000..e39b696fc --- /dev/null +++ b/Core/Resgrid.Services/AdminAssist/TextImportImpactService.cs @@ -0,0 +1,72 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services.AdminAssist +{ + /// One sender scenario against fresh routing metadata; no inbound body, profile lookup or external call. + public sealed class TextImportImpactService(IAdminAssistAccessService access, IAdminAssistRepository repository, + IAdminAssistCatalog catalog, IDepartmentSettingsRepository settings, INumbersService numbers, TimeProvider clock) : ITextImportImpactService + { + private sealed record Inputs(bool Calls, bool Commands, string Patterns); + public async Task PreviewAsync(AdminAssistActor actor, TextImportImpactRequest request, CancellationToken ct = default) + { + if (!await access.CanAccessAsync(actor, false, ct)) throw new UnauthorizedAccessException(); + if (request == null || request.ProviderPath is not ("TwilioLegacy" or "SignalWire") || request.SourceNumber == null || + request.SourceNumber.Length is < 7 or > 40 || request.SourceNumber.Any(c => !char.IsAsciiDigit(c) && !"+ ().-".Contains(c))) + throw new ArgumentException("Choose a supported path and a numeric sender scenario."); + var digits = new string(request.SourceNumber.Where(char.IsAsciiDigit).ToArray()); + if (digits.Length is < 7 or > 15) throw new ArgumentException("Invalid sender length."); + var path = Enum.Parse(request.ProviderPath); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(TimeSpan.FromSeconds(Math.Clamp(Config.AdminAssistConfig.SnapshotTimeoutSeconds, 1, 60))); + ct = timeout.Token; + var revision = (await repository.GetConfigurationRevisionAsync(actor.DepartmentId, ct)).ToString(CultureInfo.InvariantCulture); + if (revision != request.ExpectedRevision) throw new AdminAssistConcurrencyException(); + ConfigurationImpactMetric[] metrics; + try + { + var input = await ReadAsync(actor.DepartmentId, ct); + var matched = !string.IsNullOrWhiteSpace(input.Patterns) && numbers.DoesNumberMatchAnyPattern(input.Patterns.Split(',').ToList(), request.SourceNumber); + var before = TextIntakeRouting.Decide(path, matched, input.Calls, input.Commands); + var after = TextIntakeRouting.Decide(path, matched, request.CallsEnabled, request.CommandsEnabled); + if (input != await ReadAsync(actor.DepartmentId, ct)) throw new AdminAssistConcurrencyException(); + ConfigurationImpactMetric Metric(string key, bool oldValue, bool newValue) => new("Impact." + key, EvidenceState.Known, oldValue ? 1 : 0, newValue ? 1 : 0); + metrics = new[] { new ConfigurationImpactMetric("Impact.TextScenarioCount", EvidenceState.Known, 1, 1), + Metric("TextPatternMatch", matched, matched), Metric("TextDispatchBranch", before.CallBranch, after.CallBranch), + Metric("TextCommandBranch", before.CommandBranch, after.CommandBranch), + new ConfigurationImpactMetric("Impact.TextActualAcceptance", EvidenceState.Unknown, null, null, "ProviderIdentityAndPlanNotVerified") }; + } + catch (AdminAssistConcurrencyException) { throw; } + catch (UnauthorizedAccessException) { throw; } + catch (OperationCanceledException) { throw; } + catch (Exception) { metrics = new[] { new ConfigurationImpactMetric("Impact.TextDispatchBranch", EvidenceState.Unknown, null, null, "SourceUnavailable") }; } + if ((await repository.GetConfigurationRevisionAsync(actor.DepartmentId, ct)).ToString(CultureInfo.InvariantCulture) != revision) throw new AdminAssistConcurrencyException(); + if (!await access.CanAccessAsync(actor, false, ct)) throw new UnauthorizedAccessException(); + var entry = catalog.Settings.Single(s => s.Id == "setting.EnableTextToCall"); + return new("••••" + digits[^4..], request.ProviderPath, new(entry.Id, revision, clock.GetUtcNow().UtcDateTime, + "text-routing-v1", entry.Impact, metrics, Array.Empty(), + new[] { "Impact.NoMutation", "Impact.TextScenarioLimits", "Impact.TextProviderLimits", "Impact.Window" }, entry.Location.Url)); + } + private async Task ReadAsync(int departmentId, CancellationToken ct) + { + var rows = (await settings.GetAllByDepartmentIdAsync(departmentId).WaitAsync(ct))?.ToList() ?? throw new InvalidOperationException(); + if (rows.Count > Math.Clamp(Config.AdminAssistConfig.MaxEvidenceRows, 1, 10000) || rows.Any(r => r.DepartmentId != departmentId)) throw new InvalidOperationException(); + string Value(DepartmentSettingTypes type) => rows.SingleOrDefault(r => r.SettingType == (int)type)?.Setting; + bool Flag(DepartmentSettingTypes type) + { + var row = rows.SingleOrDefault(r => r.SettingType == (int)type); + return row == null ? false : bool.Parse(row.Setting); + } + var patterns = Value(DepartmentSettingTypes.TextToCallSourceNumbers); + if (patterns?.Length > 8192 || patterns?.Split(',').Length > 100) throw new InvalidOperationException(); + return new(Flag(DepartmentSettingTypes.EnableTextToCall), Flag(DepartmentSettingTypes.EnableTextCommand), patterns); + } + } +} diff --git a/Core/Resgrid.Services/AdpReleaseReceiptService.cs b/Core/Resgrid.Services/AdpReleaseReceiptService.cs new file mode 100644 index 000000000..2a77c3865 --- /dev/null +++ b/Core/Resgrid.Services/AdpReleaseReceiptService.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + public sealed class AdpReleaseReceiptService(IAdpAccessStore store, IAdpAuditRepository audit) : IAdpReleaseReceiptService + { + public async Task IssueAsync(int departmentId, long epoch, string actorId, string purpose, + IReadOnlyList fields, CancellationToken cancellationToken = default) + { + if (departmentId <= 0 || string.IsNullOrWhiteSpace(actorId) || fields == null || fields.Count is < 1 or > 3 || + fields.Any(f => f == null || f.IsBinary || f.FieldId is not ("calls.name" or "calls.address" or "calls.natureofcall")) || + fields.Select(f => f.RowKey).Distinct().Count() != 1 || fields.Select(f => f.FieldId).Distinct().Count() != fields.Count || + purpose is not ("sms-pin" or "voice-pin" or "staff-support")) throw new ArgumentException("Invalid release."); + var consent = purpose == "staff-support" ? await store.GetAsync(AdpSupportConsent.Key(departmentId), cancellationToken) : null; + if (purpose == "staff-support" && (consent == null || !JsonConvert.DeserializeObject(consent.Json).Enabled)) + throw new UnauthorizedAccessException("Department support access is disabled."); + var token = "adpr." + Convert.ToHexString(RandomNumberGenerator.GetBytes(32)); + var receipt = new Receipt { DepartmentId = departmentId, Epoch = epoch, ActorId = actorId, Purpose = purpose, + ExpiresUtc = DateTime.UtcNow.AddMinutes(1), Binding = Bind(fields), ConsentVersion = consent?.Version ?? 0 }; + await audit.AppendAsync(new AdpAuditEvent { DepartmentId = departmentId, Layer = "application", + Operation = purpose, Outcome = "authorized", ActorId = actorId, CorrelationId = Digest(token), PolicyEpoch = epoch }, cancellationToken); + if (!await store.SaveAsync(Digest(token), JsonConvert.SerializeObject(receipt), 0, cancellationToken)) + throw new InvalidOperationException("Release collision."); + return token; + } + + public async Task ConsumeAsync(string token, int departmentId, long epoch, + IReadOnlyList fields, CancellationToken cancellationToken = default) + { + if (token == null || !token.StartsWith("adpr.", StringComparison.Ordinal) || fields == null) return null; + var state = await store.GetAsync(Digest(token), cancellationToken); + var receipt = state == null ? null : JsonConvert.DeserializeObject(state.Json); + if (receipt == null || receipt.Used || receipt.DepartmentId != departmentId || receipt.Epoch != epoch || + receipt.ExpiresUtc <= DateTime.UtcNow || receipt.Binding != Bind(fields)) return null; + if (receipt.Purpose == "staff-support") + { + var consent = await store.GetAsync(AdpSupportConsent.Key(departmentId), cancellationToken); + if (consent == null || consent.Version != receipt.ConsentVersion || !JsonConvert.DeserializeObject(consent.Json).Enabled) return null; + } + receipt.Used = true; + if (!await store.SaveAsync(state.StateId, JsonConvert.SerializeObject(receipt), state.Version, cancellationToken)) return null; + await audit.AppendAsync(new AdpAuditEvent { DepartmentId = departmentId, Layer = "broker", Operation = receipt.Purpose, + Outcome = "consumed", ActorId = receipt.ActorId, CorrelationId = Digest(token), PolicyEpoch = epoch }, cancellationToken); + return receipt.ActorId; + } + + // Bind the exact envelopes too; neither another row nor a newer value can be substituted. + private static string Bind(IEnumerable fields) => Digest(JsonConvert.SerializeObject( + fields.Select(f => new object[] { f.FieldId, f.RowKey, f.IsBinary, f.Value }).ToArray())); + private static string Digest(string text) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text))); + private sealed class Receipt + { + public int DepartmentId { get; set; } + public long Epoch { get; set; } + public string ActorId { get; set; } + public string Purpose { get; set; } + public DateTime ExpiresUtc { get; set; } + public string Binding { get; set; } + public bool Used { get; set; } + public long ConsentVersion { get; set; } + } + } +} diff --git a/Core/Resgrid.Services/AdpReleaseService.cs b/Core/Resgrid.Services/AdpReleaseService.cs new file mode 100644 index 000000000..0e7962b61 --- /dev/null +++ b/Core/Resgrid.Services/AdpReleaseService.cs @@ -0,0 +1,186 @@ +using System; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + public sealed class AdpReleaseService(IAdpAccessStore store, IAdpAuditRepository audit, + IDepartmentDataProtectionService protection, IProtectedDataGrantService grants, + IAdpReleaseReceiptService receipts, IProtectedDataBrokerClient broker, ICallsService calls, + IAuthorizationService authorization, IUserProfileService profiles, IDepartmentsService departments, + IPermissionsService permissions, IDepartmentGroupsService groups, IPersonnelRolesService roles, + IPhoneNumberProcesserProvider phoneNumbers) : IAdpReleaseService + { + public async Task EnrollPinAsync(int departmentId, string userId, string grantToken, string pin, CancellationToken cancellationToken = default) + { + if (pin == null || !Regex.IsMatch(pin, "^[0-9]{6,12}$")) return false; + var policy = await protection.GetPolicyByDepartmentIdAsync(departmentId, bypassCache: true); + if (policy == null || grants.ValidateGrant(grantToken, departmentId, policy.PolicyEpoch, ProtectedDataGrantScopes.Read, + out var grant) != ProtectedDataGrantValidationOutcome.Valid || grant.UserId != userId || grant.StepUpExempt || + grant.MfaAtUtc < DateTime.UtcNow.AddMinutes(-5) || grant.MfaAtUtc > DateTime.UtcNow.AddSeconds(30)) return false; + var key = PinKey(departmentId, userId); + var previous = await store.GetAsync(key, cancellationToken); + var salt = RandomNumberGenerator.GetBytes(32); + var credential = new PinCredential { Salt = Convert.ToBase64String(salt), Hash = Convert.ToBase64String(HashPin(pin, salt)), + Generation = Guid.NewGuid().ToString("N") }; + await Audit(departmentId, userId, "pin-enroll", "requested", cancellationToken); + return await store.SaveAsync(key, JsonConvert.SerializeObject(credential), previous?.Version ?? 0, cancellationToken); + } + + public async Task CreateChallengeAsync(int departmentId, int callId, string userId, string phone, + ProtectedDataEgressChannel channel, CancellationToken cancellationToken = default) + { + if (!await Eligible(departmentId, callId, userId, phone, channel)) return null; + var policy = await protection.GetPolicyByDepartmentIdAsync(departmentId, bypassCache: true); + var egress = await protection.GetEgressPolicyByDepartmentIdAsync(departmentId, bypassCache: true); + var credentialState = await store.GetAsync(PinKey(departmentId, userId), cancellationToken); + var credential = credentialState == null ? null : JsonConvert.DeserializeObject(credentialState.Json); + if (credential == null || credential.LockedUntilUtc > DateTime.UtcNow) return null; + var challenge = new Challenge { DepartmentId = departmentId, CallId = callId, UserId = userId, + PhoneHash = Digest(NormalizePhone(phone)), Channel = channel, Epoch = policy.PolicyEpoch, PinGeneration = credential.Generation, + ExpiresUtc = DateTime.UtcNow.AddMinutes(Math.Clamp(egress.PinChallengeExpiryMinutes, 1, 10)) }; + var id = Convert.ToHexString(RandomNumberGenerator.GetBytes(12)); + await Audit(departmentId, userId, "pin-challenge", "created", cancellationToken); + return await store.SaveAsync("challenge:" + id, JsonConvert.SerializeObject(challenge), 0, cancellationToken) ? id : null; + } + + public async Task ReleaseAsync(string challengeId, string phone, string pin, ProtectedDataEgressChannel channel, + CancellationToken cancellationToken = default) + { + if (challengeId == null || !Regex.IsMatch(challengeId, "^[A-F0-9]{24}$") || pin == null || + !Regex.IsMatch(pin, "^[0-9]{6,12}$")) return null; + var state = await store.GetAsync("challenge:" + challengeId, cancellationToken); + var challenge = state == null ? null : JsonConvert.DeserializeObject(state.Json); + if (challenge == null || challenge.Used || challenge.ExpiresUtc <= DateTime.UtcNow || challenge.Channel != channel || + challenge.PhoneHash != Digest(NormalizePhone(phone))) return null; + var dept = challenge.DepartmentId; + var policy = await protection.GetPolicyByDepartmentIdAsync(dept, bypassCache: true); + if (policy == null || policy.PolicyEpoch != challenge.Epoch || + !await Eligible(dept, challenge.CallId, challenge.UserId, phone, channel)) return null; + var egress = await protection.GetEgressPolicyByDepartmentIdAsync(dept, bypassCache: true); + var key = PinKey(dept, challenge.UserId); + bool verified = false; + for (var attempt = 0; attempt < 8; attempt++) + { + var credentialState = await store.GetAsync(key, cancellationToken); + var credential = credentialState == null ? null : JsonConvert.DeserializeObject(credentialState.Json); + if (credential == null || credential.Generation != challenge.PinGeneration || credential.LockedUntilUtc > DateTime.UtcNow) return null; + if (credential.LockedUntilUtc.HasValue) { credential.Failures = 0; credential.LockedUntilUtc = null; } + verified = CryptographicOperations.FixedTimeEquals(HashPin(pin, Convert.FromBase64String(credential.Salt)), Convert.FromBase64String(credential.Hash)); + credential.Failures = verified ? 0 : credential.Failures + 1; + if (credential.Failures >= Math.Clamp(egress.PinMaxAttempts, 1, 5)) + credential.LockedUntilUtc = DateTime.UtcNow.AddMinutes(Math.Clamp(egress.PinLockoutMinutes, 1, 60)); + if (!await store.SaveAsync(key, JsonConvert.SerializeObject(credential), credentialState.Version, cancellationToken)) { verified = false; continue; } + await Audit(dept, challenge.UserId, "pin-verify", verified ? "verified" : "denied", cancellationToken); + break; + } + if (!verified) return null; + challenge.Used = true; + if (!await store.SaveAsync(state.StateId, JsonConvert.SerializeObject(challenge), state.Version, cancellationToken)) return null; + // Read fresh, never the outbound queue's old object; authorization was checked immediately above. + var call = await calls.GetCallByIdAsync(challenge.CallId); + if (call == null || call.DepartmentId != dept || call.IsDeleted || call.State is (int)CallStates.Closed or (int)CallStates.Cancelled) return null; + var fields = AdpDispatchRelease.Fields(call); + var envelopes = fields.Where(f => ProtectedDataEnvelope.HasEnvelopePrefix(f.Value)).ToArray(); + if (envelopes.Length > 0) + { + var token = await receipts.IssueAsync(dept, policy.PolicyEpoch, challenge.UserId, + channel == ProtectedDataEgressChannel.Sms ? "sms-pin" : "voice-pin", envelopes, cancellationToken); + var result = await broker.DecryptAsync(dept, token, Guid.NewGuid().ToString("N"), envelopes, cancellationToken); + if (!result.Success || result.Items.Count != envelopes.Length || result.Items.Any(f => f.ErrorCode != null)) return null; + foreach (var field in fields) + if (ProtectedDataEnvelope.HasEnvelopePrefix(field.Value)) + field.Value = result.Items.Single(f => f.FieldId == field.FieldId && f.RowKey == field.RowKey).Value; + } + var finalPolicy = await protection.GetPolicyByDepartmentIdAsync(dept, bypassCache: true); + var finalCredential = await store.GetAsync(key, cancellationToken); + if (challenge.ExpiresUtc <= DateTime.UtcNow || finalPolicy?.PolicyEpoch != challenge.Epoch || finalCredential == null || + JsonConvert.DeserializeObject(finalCredential.Json).Generation != challenge.PinGeneration || + !await Eligible(dept, challenge.CallId, challenge.UserId, phone, channel)) return null; + await Audit(dept, challenge.UserId, "pin-release", "disclosed", cancellationToken); + var text = string.Join(". ", fields.Select(f => f.Value).Where(v => !string.IsNullOrWhiteSpace(v))); + if (channel == ProtectedDataEgressChannel.Sms && text.Length > 1200) + { + var length = char.IsHighSurrogate(text[1199]) ? 1199 : 1200; + var profile = await profiles.GetProfileByUserIdAsync(challenge.UserId); + text = text.Substring(0, length) + " … " + Resgrid.Localization.Areas.User.SystemMessages.SystemMessagesResources.Get("AdpProtectedDispatchNotice", profile?.Language); + } + return challenge.ExpiresUtc > DateTime.UtcNow ? text : null; + } + + private async Task Eligible(int departmentId, int callId, string userId, string phone, ProtectedDataEgressChannel channel) + { + if (channel is not (ProtectedDataEgressChannel.Sms or ProtectedDataEgressChannel.Voice) || string.IsNullOrWhiteSpace(userId) || + NormalizePhone(phone).Length < 7 || !await protection.IsProtectionEnforcedAsync(departmentId) || + !await authorization.CanUserViewCallAsync(userId, callId)) return false; + var protectionPolicy = await protection.GetPolicyByDepartmentIdAsync(departmentId, bypassCache: true); + if (protectionPolicy == null || protectionPolicy.State is not ((int)DepartmentDataProtectionState.Enabled) and not ((int)DepartmentDataProtectionState.Rotating)) return false; + var policy = await protection.GetEgressPolicyByDepartmentIdAsync(departmentId, bypassCache: true); + if ((channel == ProtectedDataEgressChannel.Sms ? policy.SmsMode : policy.VoiceMode) != (int)ProtectedDataEgressMode.ProtectedAfterPin || + !policy.AcknowledgedOn.HasValue || string.IsNullOrWhiteSpace(policy.AcknowledgementVersion)) return false; + var call = await calls.GetCallByIdAsync(callId); + if (call == null || call.DepartmentId != departmentId || call.IsDeleted || call.State is (int)CallStates.Closed or (int)CallStates.Cancelled) return false; + var member = await departments.GetDepartmentMemberAsync(userId, departmentId, bypassCache: true); + if (member == null || member.IsDeleted || member.IsDisabled == true) return false; + var permission = await permissions.GetPermissionByDepartmentTypeAsync(departmentId, PermissionTypes.ViewProtectedCallData) + ?? new Permission { DepartmentId = departmentId, Action = (int)AdpPermissionDefaults.For(PermissionTypes.ViewProtectedCallData) }; + var department = await departments.GetDepartmentByIdAsync(departmentId); + var group = await groups.GetGroupForUserAsync(userId, departmentId); + if (!permissions.IsUserAllowed(permission, department?.IsUserAnAdmin(userId) == true, + group?.IsUserGroupAdmin(userId) == true, await roles.GetRolesForUserAsync(userId, departmentId))) return false; + var profile = await profiles.GetProfileByUserIdAsync(userId); + return profile != null && (profile.MobileNumberVerified == true && NormalizePhone(profile.MobileNumber) == NormalizePhone(phone) || + channel == ProtectedDataEgressChannel.Voice && profile.HomeNumberVerified == true && NormalizePhone(profile.HomeNumber) == NormalizePhone(phone)); + } + private Task Audit(int dept, string userId, string operation, string outcome, CancellationToken ct) => audit.AppendAsync( + new AdpAuditEvent { DepartmentId = dept, ActorId = userId, Layer = "application", Operation = operation, Outcome = outcome }, ct); + private static string PinKey(int departmentId, string userId) => "pin:" + departmentId + ":" + Digest(userId); + private string NormalizePhone(string phone) + { + // Use the same canonical number as direct SMS delivery, including legacy national formats. + var parsed = phoneNumbers.Process(phone); + return parsed?.IsValid == true ? parsed.InternationalNumber : string.Empty; + } + private static string Digest(string value) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))); + private static byte[] HashPin(string pin, byte[] salt) => Rfc2898DeriveBytes.Pbkdf2(pin, salt, 600000, HashAlgorithmName.SHA256, 32); + private sealed class PinCredential + { + public string Salt { get; set; } + public string Hash { get; set; } + public string Generation { get; set; } + public int Failures { get; set; } + public DateTime? LockedUntilUtc { get; set; } + } + private sealed class Challenge + { + public int DepartmentId { get; set; } + public int CallId { get; set; } + public string UserId { get; set; } + public string PhoneHash { get; set; } + public ProtectedDataEgressChannel Channel { get; set; } + public long Epoch { get; set; } + public string PinGeneration { get; set; } + public DateTime ExpiresUtc { get; set; } + public bool Used { get; set; } + } + } + + public static class AdpDispatchRelease + { + public static ProtectedFieldOperationItem[] Fields(Call call) => new[] + { + new ProtectedFieldOperationItem { FieldId = "calls.name", RowKey = call.CallId.ToString(), Value = call.Name }, + new ProtectedFieldOperationItem { FieldId = "calls.address", RowKey = call.CallId.ToString(), Value = call.Address }, + new ProtectedFieldOperationItem { FieldId = "calls.natureofcall", RowKey = call.CallId.ToString(), Value = call.NatureOfCall } + }; + } +} diff --git a/Core/Resgrid.Services/AdpTableBindings.cs b/Core/Resgrid.Services/AdpTableBindings.cs index cb4d1b1d5..32e10efa4 100644 --- a/Core/Resgrid.Services/AdpTableBindings.cs +++ b/Core/Resgrid.Services/AdpTableBindings.cs @@ -66,7 +66,7 @@ AdpColumnSpec Companion(string table, string column, bool boolean = false) => AdpTableBinding.Direct("AuditLogs", "AuditLogId", true, "DepartmentId", new[] { Text("AuditLogs", "Data") }) with { Discriminator = new AdpRowDiscriminator("LogType", Resgrid.Model.Checklists.ReadinessHistoryFields.AuditTypes) }, AdpTableBinding.Direct("DomainEventOutbox", "DomainEventOutboxId", true, "DepartmentId", new[] { Text("DomainEventOutbox", "PayloadJson"), Text("DomainEventOutbox", "LastError") }) - with { Discriminator = new AdpRowDiscriminator("ProducerSubsystem", Texts: new[] { "Checklists", "WorkOrders", "Inventory" }) }, + with { Discriminator = new AdpRowDiscriminator("ProducerSubsystem", Texts: Resgrid.Model.Checklists.ChecklistWorkflowPayload.ReadinessProducers) }, AdpTableBinding.Direct("WorkflowRuns", "WorkflowRunId", false, "DepartmentId", new[] { Text("WorkflowRuns", "InputPayload"), Text("WorkflowRuns", "ErrorMessage") }) with { Discriminator = new AdpRowDiscriminator("TriggerEventType", Resgrid.Model.Checklists.ChecklistWorkflowPayload.Triggers) }, AdpTableBinding.ViaParent("WorkflowRunLogs", "WorkflowRunLogId", false, "WorkflowRunId", "WorkflowRuns", "WorkflowRunId", new[] { Text("WorkflowRunLogs", "RenderedOutput"), Text("WorkflowRunLogs", "ActionResult"), Text("WorkflowRunLogs", "ErrorMessage") }) @@ -202,6 +202,9 @@ AdpColumnSpec Companion(string table, string column, bool boolean = false) => Binary("PayDataExportArtifacts", "Data") }) with { ProtectedMarkerColumn = "IsProtected" }, + AdpTableBinding.Direct("AdminAssistFindings", "AdminAssistFindingId", pkIsNumeric: false, "DepartmentId", new[] { Text("AdminAssistFindings", "Content") }) with { ProtectedMarkerColumn = "IsProtected" }, + AdpTableBinding.Direct("AdminAssistDispatchTraces", "AdminAssistDispatchTraceId", pkIsNumeric: false, "DepartmentId", new[] { Text("AdminAssistDispatchTraces", "Content") }) with { ProtectedMarkerColumn = "IsProtected" }, + AdpTableBinding.Direct("Contacts", "ContactId", pkIsNumeric: false, "DepartmentId", new[] { Text("Contacts", "FirstName"), Text("Contacts", "MiddleName"), Text("Contacts", "LastName"), diff --git a/Core/Resgrid.Services/AuditService.cs b/Core/Resgrid.Services/AuditService.cs index be7ab3882..5c568aec9 100644 --- a/Core/Resgrid.Services/AuditService.cs +++ b/Core/Resgrid.Services/AuditService.cs @@ -74,6 +74,10 @@ public string GetAuditLogTypeString(AuditLogTypes logType) { switch (logType) { + case AuditLogTypes.DepartmentConfigurationChanged: + return "Department Configuration Changed"; + case AuditLogTypes.AdminAssistReviewChanged: + return "Admin Assist Review Changed"; case AuditLogTypes.DepartmentSettingsChanged: return "Department Settings Changed"; case AuditLogTypes.UserAdded: diff --git a/Core/Resgrid.Services/AuthorizationService.cs b/Core/Resgrid.Services/AuthorizationService.cs index 17771bb03..9f92e707a 100644 --- a/Core/Resgrid.Services/AuthorizationService.cs +++ b/Core/Resgrid.Services/AuthorizationService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Concurrent; using System.Linq; using System.Threading.Tasks; @@ -382,7 +382,13 @@ public async Task CanUserViewUnitAsync(string userId, int unitId) if (unit.DepartmentId != department.DepartmentId) return false; - return true; + // Security > View Units: the same answer the unit lists get from the visibility matrix. No row means everyone. + var permission = await _permissionsService.GetPermissionByDepartmentTypeAsync(department.DepartmentId, PermissionTypes.ViewGroupUnits); + + if (permission == null) + return true; + + return await IsUserAllowedForUnitAsync(permission, userId, department.DepartmentId, unit); } public async Task CanUserViewUserAsync(string viewerUserId, string targetUserId) @@ -664,17 +670,18 @@ public async Task GetShiftManagementScopeAsync(string user if (department == null) return scope; - var member = await _departmentsService.GetDepartmentMemberAsync(userId, departmentId, false); - - if (member == null || member.IsDeleted) - return scope; - + // Before the member check: the managing user can be an admin without an active DepartmentMember record. if (department.IsUserAnAdmin(userId)) { scope.AllGroups = true; return scope; } + var member = await _departmentsService.GetDepartmentMemberAsync(userId, departmentId, false); + + if (member == null || member.IsDeleted) + return scope; + var permission = await _permissionsService.GetPermissionByDepartmentTypeAsync(departmentId, PermissionTypes.CreateShift); if (permission != null) @@ -723,8 +730,22 @@ public async Task CanUserViewUnitLocationAsync(string userId, int unitId, if (permission == null) return true; - bool isGroupAdmin = false; var unit = await _unitsService.GetUnitByIdAsync(unitId); + + return await IsUserAllowedForUnitAsync(permission, userId, departmentId, unit); + } + + /// + /// One unit permission (View Units or See Unit Locations) for one user, decided the way the unit visibility + /// matrices decide it: locked to group means the unit's own station, except that admins of that station or of + /// any group above it count for the locked "department and group admins" rule. Department admins always pass. + /// + private async Task IsUserAllowedForUnitAsync(Permission permission, string userId, int departmentId, Unit unit) + { + if (unit == null) + return false; + + bool isGroupAdmin = false; var group = await _departmentGroupsService.GetGroupForUserAsync(userId, departmentId); var roles = await _personnelRolesService.GetRolesForUserAsync(userId, departmentId); var department = await _departmentsService.GetDepartmentByIdAsync(departmentId); @@ -735,59 +756,13 @@ public async Task CanUserViewUnitLocationAsync(string userId, int unitId, if (group != null) isGroupAdmin = group.IsUserGroupAdmin(userId); - if (permission.Action == (int)PermissionActions.DepartmentAdminsOnly && department.IsUserAnAdmin(userId)) - { // Department Admins only - return true; - } - else if (permission.Action == (int)PermissionActions.DepartmentAndGroupAdmins && !permission.LockToGroup && (department.IsUserAnAdmin(userId) || isGroupAdmin)) - { // Department and group Admins (not locked to group) - return true; - } - else if (permission.Action == (int)PermissionActions.DepartmentAndGroupAdmins && permission.LockToGroup && (department.IsUserAnAdmin(userId) || isGroupAdmin)) - { // Department and group Admins (locked to group) - if (department.IsUserAnAdmin(userId)) - return true; // Department Admins have access. - - if (unit.StationGroupId.HasValue && group != null && - unit.StationGroupId.Value == group.DepartmentGroupId) - return true; // Group admin in the same group have access to locked to group - } - else if (permission.Action == (int)PermissionActions.DepartmentAdminsAndSelectRoles && department.IsUserAnAdmin(userId)) - { - return true; - } - else if (permission.Action == (int)PermissionActions.DepartmentAdminsAndSelectRoles && !department.IsUserAnAdmin(userId)) - { - if (permission.LockToGroup && unit.StationGroupId.HasValue && group != null && - unit.StationGroupId.Value != group.DepartmentGroupId) - return false; - - if (!String.IsNullOrWhiteSpace(permission.Data)) - { - var roleIds = permission.Data.Split(char.Parse(",")).Select(int.Parse); - var role = from r in roles - where roleIds.Contains(r.PersonnelRoleId) - select r; - - if (role.Any()) - { - return true; - } - } - - } - else if (permission.Action == (int)PermissionActions.Everyone && permission.LockToGroup) - { - if (unit.StationGroupId.HasValue && group != null && - unit.StationGroupId.Value == group.DepartmentGroupId) - return true; // Everyone in the same group have access to locked to group - } - else if (permission.Action == (int)PermissionActions.Everyone && !permission.LockToGroup) - { - return true; - } - - return false; + var departmentAdmin = department.IsUserAnAdmin(userId); + var targetGroupId = unit.StationGroupId; + var ancestorAdmin = !departmentAdmin && isGroupAdmin && permission.LockToGroup && + permission.Action == (int)PermissionActions.DepartmentAndGroupAdmins && + await IsAdminOfGroupOrAncestorAsync(userId, targetGroupId); + return ResourceVisibilityPermission.Allows(permission, departmentAdmin, isGroupAdmin, + group?.DepartmentGroupId, targetGroupId, roles?.Select(r => r.PersonnelRoleId), ancestorAdmin); } public async Task CanUserViewPersonLocationAsync(string userId, string targetUserId, int departmentId) @@ -809,56 +784,13 @@ public async Task CanUserViewPersonLocationAsync(string userId, string tar if (group != null) isGroupAdmin = group.IsUserGroupAdmin(userId); - if (permission.Action == (int)PermissionActions.DepartmentAdminsOnly && department.IsUserAnAdmin(userId)) - { // Department Admins only - return true; - } - else if (permission.Action == (int)PermissionActions.DepartmentAndGroupAdmins && !permission.LockToGroup && (department.IsUserAnAdmin(userId) || isGroupAdmin)) - { // Department and group Admins (not locked to group) - return true; - } - else if (permission.Action == (int)PermissionActions.DepartmentAndGroupAdmins && permission.LockToGroup && (department.IsUserAnAdmin(userId) || isGroupAdmin)) - { // Department and group Admins (locked to group) - if (department.IsUserAnAdmin(userId)) - return true; // Department Admins have access. - - if (group != null && targetUserGroup != null && group.DepartmentGroupId == targetUserGroup.DepartmentGroupId) - return true; // Group admin in the same group have access to locked to group - } - else if (permission.Action == (int)PermissionActions.DepartmentAdminsAndSelectRoles && department.IsUserAnAdmin(userId)) - { - return true; - } - else if (permission.Action == (int)PermissionActions.DepartmentAdminsAndSelectRoles && !department.IsUserAnAdmin(userId)) - { - if (permission.LockToGroup && group != null && targetUserGroup != null && group.DepartmentGroupId != targetUserGroup.DepartmentGroupId) - return false; - - if (!String.IsNullOrWhiteSpace(permission.Data)) - { - var roleIds = permission.Data.Split(char.Parse(",")).Select(int.Parse); - var role = from r in roles - where roleIds.Contains(r.PersonnelRoleId) - select r; - - if (role.Any()) - { - return true; - } - } - - } - else if (permission.Action == (int)PermissionActions.Everyone && permission.LockToGroup) - { - if (group != null && targetUserGroup != null && group.DepartmentGroupId != targetUserGroup.DepartmentGroupId) - return true; // Everyone in the same group have access to locked to group - } - else if (permission.Action == (int)PermissionActions.Everyone && !permission.LockToGroup) - { - return true; - } - - return false; + var departmentAdmin = department.IsUserAnAdmin(userId); + var targetGroupId = targetUserGroup?.DepartmentGroupId; + var ancestorAdmin = !departmentAdmin && isGroupAdmin && permission.LockToGroup && + permission.Action == (int)PermissionActions.DepartmentAndGroupAdmins && + await IsAdminOfGroupOrAncestorAsync(userId, targetGroupId); + return ResourceVisibilityPermission.Allows(permission, departmentAdmin, isGroupAdmin, + group?.DepartmentGroupId, targetGroupId, roles?.Select(r => r.PersonnelRoleId), ancestorAdmin); } public async Task CanUserViewPersonAsync(string userId, string targetUserId, int departmentId) @@ -880,56 +812,32 @@ public async Task CanUserViewPersonAsync(string userId, string targetUserI if (group != null) isGroupAdmin = group.IsUserGroupAdmin(userId); - if (permission.Action == (int)PermissionActions.DepartmentAdminsOnly && department.IsUserAnAdmin(userId)) - { // Department Admins only - return true; - } - else if (permission.Action == (int)PermissionActions.DepartmentAndGroupAdmins && !permission.LockToGroup && (department.IsUserAnAdmin(userId) || isGroupAdmin)) - { // Department and group Admins (not locked to group) - return true; - } - else if (permission.Action == (int)PermissionActions.DepartmentAndGroupAdmins && permission.LockToGroup && (department.IsUserAnAdmin(userId) || isGroupAdmin)) - { // Department and group Admins (locked to group) - if (department.IsUserAnAdmin(userId)) - return true; // Department Admins have access. - - if (group != null && targetUserGroup != null && group.DepartmentGroupId == targetUserGroup.DepartmentGroupId) - return true; // Group admin in the same group have access to locked to group - } - else if (permission.Action == (int)PermissionActions.DepartmentAdminsAndSelectRoles && department.IsUserAnAdmin(userId)) - { - return true; - } - else if (permission.Action == (int)PermissionActions.DepartmentAdminsAndSelectRoles && !department.IsUserAnAdmin(userId)) - { - if (permission.LockToGroup && group != null && targetUserGroup != null && group.DepartmentGroupId != targetUserGroup.DepartmentGroupId) - return false; + var departmentAdmin = department.IsUserAnAdmin(userId); + var targetGroupId = targetUserGroup?.DepartmentGroupId; + var ancestorAdmin = !departmentAdmin && isGroupAdmin && permission.LockToGroup && + permission.Action == (int)PermissionActions.DepartmentAndGroupAdmins && + await IsAdminOfGroupOrAncestorAsync(userId, targetGroupId); + return ResourceVisibilityPermission.Allows(permission, departmentAdmin, isGroupAdmin, + group?.DepartmentGroupId, targetGroupId, roles?.Select(r => r.PersonnelRoleId), ancestorAdmin); + } - if (!String.IsNullOrWhiteSpace(permission.Data)) - { - var roleIds = permission.Data.Split(char.Parse(",")).Select(int.Parse); - var role = from r in roles - where roleIds.Contains(r.PersonnelRoleId) - select r; + private static bool AreInSameGroup(DepartmentGroup group, DepartmentGroup otherGroup) + { + return group != null && otherGroup != null && group.DepartmentGroupId == otherGroup.DepartmentGroupId; + } - if (role.Any()) - { - return true; - } - } + /// + /// The locked-to-group admin rule the visibility matrices apply: an admin of the target's group, or of any + /// group above it (an area supervisor over the stations in their area). No group means no group admin. + /// + private async Task IsAdminOfGroupOrAncestorAsync(string userId, int? groupId) + { + if (!groupId.HasValue) + return false; - } - else if (permission.Action == (int)PermissionActions.Everyone && permission.LockToGroup) - { - if (group != null && targetUserGroup != null && group.DepartmentGroupId != targetUserGroup.DepartmentGroupId) - return true; // Everyone in the same group have access to locked to group - } - else if (permission.Action == (int)PermissionActions.Everyone && !permission.LockToGroup) - { - return true; - } + var admins = await _departmentGroupsService.GetAllAdminsForGroupAndAncestorsAsync(groupId.Value) ?? new System.Collections.Generic.List(); - return false; + return admins.Any(a => string.Equals(a.UserId, userId, StringComparison.OrdinalIgnoreCase)); } /// diff --git a/Core/Resgrid.Services/CommunicationService.cs b/Core/Resgrid.Services/CommunicationService.cs index 28b5b7f69..660a5f86f 100644 --- a/Core/Resgrid.Services/CommunicationService.cs +++ b/Core/Resgrid.Services/CommunicationService.cs @@ -1,5 +1,6 @@ -using Resgrid.Framework; +using Resgrid.Framework; using Resgrid.Model; +using Resgrid.Model.AdminAssist; using Resgrid.Model.Events; using Resgrid.Model.Messages; using Resgrid.Model.Providers; @@ -26,13 +27,15 @@ public class CommunicationService : ICommunicationService private readonly IChatbotOutboundService _chatbotOutboundService; private readonly IDepartmentsService _departmentsService; private readonly IProtectedProjectionService _protectedProjectionService; + private readonly Lazy _adpRelease; public CommunicationService(ISmsService smsService, IEmailService emailService, IPushService pushService, IGeoLocationProvider geoLocationProvider, IOutboundVoiceProvider outboundVoiceProvider, IUserProfileService userProfileService, IDepartmentSettingsService departmentSettingsService, ISubscriptionsService subscriptionsService, IUserStateService userStateService, IChatbotOutboundService chatbotOutboundService, - IDepartmentsService departmentsService, IProtectedProjectionService protectedProjectionService) + IDepartmentsService departmentsService, IProtectedProjectionService protectedProjectionService, Lazy adpRelease) { _protectedProjectionService = protectedProjectionService; + _adpRelease = adpRelease; _smsService = smsService; _emailService = emailService; _pushService = pushService; @@ -181,14 +184,27 @@ await _chatbotOutboundService.SendToUserAsync(message.ReceivingUserId, departmen public async Task SendCallAsync(Call call, CallDispatch dispatch, string departmentNumber, int departmentId, UserProfile profile = null, string address = null) { if (Config.SystemBehaviorConfig.DoNotBroadcast && !Config.SystemBehaviorConfig.BypassDoNotBroadcastDepartments.Contains(departmentId)) - return false; + { DispatchTraceTelemetry.Observe(DispatchTraceStage.Skipped, reason: DispatchTraceReason.BroadcastDisabled, recipientId: dispatch.UserId); return false; } if (!await CanSendToUser(dispatch.UserId, departmentId)) - return false; + { DispatchTraceTelemetry.Observe(DispatchTraceStage.Skipped, reason: DispatchTraceReason.MemberIneligible, recipientId: dispatch.UserId); return false; } if (profile == null) profile = await _userProfileService.GetProfileByUserIdAsync(dispatch.UserId); + if (profile != null) + { + void Skipped(DispatchTraceChannel channel, bool enabled, bool verified = true) + { + if (!enabled || !verified) DispatchTraceTelemetry.Observe(DispatchTraceStage.Skipped, channel, + !enabled ? DispatchTraceReason.ChannelDisabled : DispatchTraceReason.ContactUnverified, dispatch.UserId); + } + Skipped(DispatchTraceChannel.Push, profile.SendPush); + Skipped(DispatchTraceChannel.Sms, profile.SendSms, profile.MobileNumberVerified.IsContactMethodAllowedForSending()); + Skipped(DispatchTraceChannel.Email, profile.SendEmail, profile.EmailVerified.IsContactMethodAllowedForSending()); + Skipped(DispatchTraceChannel.Voice, profile.VoiceForCall, (profile.VoiceCallMobile ? profile.MobileNumberVerified : profile.HomeNumberVerified).IsContactMethodAllowedForSending()); + } + // ADP egress (plan section 9): per-channel notification-safe views, resolved BEFORE any // template, provider DTO, or TTS prompt is built. For unprotected departments every one // of these is the original call; for protected departments each channel gets the @@ -290,7 +306,7 @@ await _chatbotOutboundService.SendToUserAsync(dispatch.UserId, departmentId, spc.Title = spc.Title.Replace(char.Parse("/"), char.Parse(" ")); spc.SubTitle = spc.SubTitle.Replace(char.Parse("/"), char.Parse(" ")); - await _pushService.PushCall(spc, dispatch.UserId, profile, pushCall.CallPriority); + await DispatchTraceTelemetry.AttemptAsync(DispatchTraceChannel.Push, dispatch.UserId, () => _pushService.PushCall(spc, dispatch.UserId, profile, pushCall.CallPriority)); } catch (Exception ex) { @@ -306,8 +322,19 @@ await _chatbotOutboundService.SendToUserAsync(dispatch.UserId, departmentId, var payment = await _subscriptionsService.GetCurrentPaymentForDepartmentAsync(departmentId); // Caller-resolved address wins for an unsanitized channel (same precedence as the // cancellation path); a sanitized channel gets no address at all. - await _smsService.SendCallAsync(smsCall, dispatch, departmentNumber, departmentId, profile, - ReferenceEquals(smsCall, call) ? (address ?? smsCall.Address) : null, payment); + var pinDelivered = false; + try + { + var challenge = await _adpRelease.Value.CreateChallengeAsync(departmentId, call.CallId, dispatch.UserId, + profile?.MobileNumber, ProtectedDataEgressChannel.Sms); + if (challenge != null) + pinDelivered = await DispatchTraceTelemetry.AttemptAsync(DispatchTraceChannel.Sms, dispatch.UserId, () => _smsService.SendProtectedDispatchChallengeAsync(profile, departmentId, departmentNumber, + Resgrid.Localization.Areas.User.SystemMessages.SystemMessagesResources.Get("AdpPinSmsChallenge", profile?.Language, challenge))); + } + catch (Exception) { Logging.LogError($"ADP PIN challenge unavailable for department {departmentId}; sending the safe dispatch notice."); } + if (!pinDelivered) + await DispatchTraceTelemetry.AttemptAsync(DispatchTraceChannel.Sms, dispatch.UserId, () => _smsService.SendCallAsync(smsCall, dispatch, departmentNumber, departmentId, profile, + ReferenceEquals(smsCall, call) ? (address ?? smsCall.Address) : null, payment)); } } @@ -316,7 +343,7 @@ await _smsService.SendCallAsync(smsCall, dispatch, departmentNumber, departmentI { if (profile == null || profile.EmailVerified.IsContactMethodAllowedForSending()) { - await _emailService.SendCallAsync(emailCall, dispatch, profile); + await DispatchTraceTelemetry.AttemptAsync(DispatchTraceChannel.Email, dispatch.UserId, () => _emailService.SendCallAsync(emailCall, dispatch, profile)); } } @@ -334,7 +361,7 @@ await _smsService.SendCallAsync(smsCall, dispatch, departmentNumber, departmentI { if (!Config.SystemBehaviorConfig.DoNotBroadcast || Config.SystemBehaviorConfig.BypassDoNotBroadcastDepartments.Contains(departmentId)) - await _outboundVoiceProvider.CommunicateCallAsync(departmentNumber, profile, voiceCall); + await DispatchTraceTelemetry.AttemptAsync(DispatchTraceChannel.Voice, dispatch.UserId, () => _outboundVoiceProvider.CommunicateCallAsync(departmentNumber, profile, voiceCall)); } catch (Exception ex) { @@ -414,7 +441,7 @@ public async Task SendUnitCallAsync(Call call, CallDispatchUnit dispatch, try { - await _pushService.PushCallUnit(spc, dispatch.UnitId, call.CallPriority); + await DispatchTraceTelemetry.AttemptAsync(DispatchTraceChannel.UnitPush, dispatch.UnitId.ToString(), () => _pushService.PushCallUnit(spc, dispatch.UnitId, call.CallPriority)); } catch (Exception ex) { @@ -669,7 +696,9 @@ public async Task SendNotificationAsync(string userId, int departmentId, s if (profile == null) profile = await _userProfileService.GetProfileByUserIdAsync(userId, false); - if (profile == null || (profile.SendNotificationSms && profile.MobileNumberVerified.IsContactMethodAllowedForSending())) + var channels = NotificationChannelSelection.From(profile); + + if (channels.Sms) { try { @@ -681,7 +710,7 @@ public async Task SendNotificationAsync(string userId, int departmentId, s } } - if (profile == null || profile.SendNotificationEmail) + if (channels.Email) { if (profile == null || profile.EmailVerified.IsContactMethodAllowedForSending()) { @@ -697,7 +726,7 @@ public async Task SendNotificationAsync(string userId, int departmentId, s } } - if (profile == null || profile.SendNotificationPush) + if (channels.Push) { var spm = new StandardPushMessage(); spm.Title = title; diff --git a/Core/Resgrid.Services/DepartmentKeyService.cs b/Core/Resgrid.Services/DepartmentKeyService.cs index 0e55038b4..a705a1a40 100644 --- a/Core/Resgrid.Services/DepartmentKeyService.cs +++ b/Core/Resgrid.Services/DepartmentKeyService.cs @@ -20,12 +20,14 @@ public class DepartmentKeyService : IDepartmentKeyService { private readonly IDepartmentDataProtectionKeyRepository _keyRepository; private readonly IKeyWrappingProvider _keyWrappingProvider; + private readonly IAdpAuditRepository _audit; public DepartmentKeyService(IDepartmentDataProtectionKeyRepository keyRepository, - IKeyWrappingProvider keyWrappingProvider) + IKeyWrappingProvider keyWrappingProvider, IAdpAuditRepository audit) { _keyRepository = keyRepository; _keyWrappingProvider = keyWrappingProvider; + _audit = audit; } public Task GetActiveKeyAsync(int departmentId) => @@ -54,6 +56,8 @@ public async Task ProvisionNextKeyVersionAsync(int return await ActivateAsync(newest, existing.Where(k => k.Version < newest.Version), cancellationToken); var nextVersion = (newest?.Version ?? 0) + 1; + await _audit.AppendAsync(new AdpAuditEvent { DepartmentId = departmentId, Layer = "key-management", + Operation = "key-provision", Outcome = "requested", ResourceId = nextVersion.ToString() }, cancellationToken); var wrapped = await _keyWrappingProvider.GenerateWrappedDataKeyAsync(departmentId, cancellationToken); var keyRow = new DepartmentDataProtectionKey @@ -94,6 +98,8 @@ public async Task RetireKeyVersionAsync(int departmentId, int version, Can if (keyRow == null || keyRow.Status != (int)DepartmentDataProtectionKeyStatus.Retiring) return false; + await _audit.AppendAsync(new AdpAuditEvent { DepartmentId = departmentId, Layer = "key-management", + Operation = "key-retire", Outcome = "requested", ResourceId = version.ToString() }, cancellationToken); keyRow.Status = (int)DepartmentDataProtectionKeyStatus.Retired; keyRow.RetiredOn = DateTime.UtcNow; await _keyRepository.SaveOrUpdateAsync(keyRow, cancellationToken); @@ -111,6 +117,8 @@ private async Task ActivateAsync(DepartmentDataProt // re-encryption retires them. if (keyRow.Status != (int)DepartmentDataProtectionKeyStatus.Active) { + await _audit.AppendAsync(new AdpAuditEvent { DepartmentId = keyRow.DepartmentId, Layer = "key-management", + Operation = "key-activate", Outcome = "requested", ResourceId = keyRow.Version.ToString() }, cancellationToken); keyRow.Status = (int)DepartmentDataProtectionKeyStatus.Active; keyRow.ActivatedOn = DateTime.UtcNow; await _keyRepository.SaveOrUpdateAsync(keyRow, cancellationToken); diff --git a/Core/Resgrid.Services/DepartmentSettingsService.cs b/Core/Resgrid.Services/DepartmentSettingsService.cs index 5780127f8..63d851c6f 100644 --- a/Core/Resgrid.Services/DepartmentSettingsService.cs +++ b/Core/Resgrid.Services/DepartmentSettingsService.cs @@ -46,16 +46,20 @@ public partial class DepartmentSettingsService : IDepartmentSettingsService private readonly IAddressService _addressService; private readonly IGeoLocationProvider _geoLocationProvider; private readonly ICacheProvider _cacheProvider; + private readonly Resgrid.Model.AdminAssist.IAdminAssistRepository _operatingProfileRepository; + private readonly Lazy _operatingProfileAuthorization; public DepartmentSettingsService(IDepartmentSettingsRepository departmentSettingsRepository, IAddressService addressService, IGeoLocationProvider geoLocationProvider, ICacheProvider cacheProvider, - Resgrid.Model.Repositories.Queries.IUnitOfWork moduleUnit = null, IFeatureFlagMutationObserver moduleObserver = null, Lazy moduleFlags = null) + Resgrid.Model.Repositories.Queries.IUnitOfWork moduleUnit = null, IFeatureFlagMutationObserver moduleObserver = null, Lazy moduleFlags = null, + Resgrid.Model.AdminAssist.IAdminAssistRepository operatingProfileRepository = null, Lazy operatingProfileAuthorization = null) { _departmentSettingsRepository = departmentSettingsRepository; _addressService = addressService; _geoLocationProvider = geoLocationProvider; _cacheProvider = cacheProvider; _moduleUnit = moduleUnit; _moduleObserver = moduleObserver; _moduleFlags = moduleFlags; + _operatingProfileRepository = operatingProfileRepository; _operatingProfileAuthorization = operatingProfileAuthorization; } public async Task SaveOrUpdateSettingAsync(int departmentId, string setting, DepartmentSettingTypes type, CancellationToken cancellationToken = default(CancellationToken)) @@ -1467,6 +1471,16 @@ private async Task InvalidateSettingCacheAsync(int departmentId, DepartmentSetti case DepartmentSettingTypes.UnitStatusThresholds: cacheKey = string.Format(UnitStatusThresholdsCacheKey, departmentId); break; + case DepartmentSettingTypes.RecordsDefaultLifecyclePreset: + case DepartmentSettingTypes.RecordsReviewDueHours: + case DepartmentSettingTypes.RecordsNumberingConfig: + case DepartmentSettingTypes.RecordsSearchConfig: + case DepartmentSettingTypes.RecordsRetentionPolicy: + case DepartmentSettingTypes.RecordsGroupVisibilityMode: + case DepartmentSettingTypes.RecordsGroupScopeConfig: + case DepartmentSettingTypes.RecordsDisclosureConfig: + cacheKey = string.Format(RecordsSettingCacheKey, (int)type, departmentId); + break; } if (!string.IsNullOrWhiteSpace(cacheKey)) diff --git a/Core/Resgrid.Services/DepartmentSsoService.cs b/Core/Resgrid.Services/DepartmentSsoService.cs index b5dc3248e..7918e117e 100644 --- a/Core/Resgrid.Services/DepartmentSsoService.cs +++ b/Core/Resgrid.Services/DepartmentSsoService.cs @@ -320,7 +320,7 @@ public async Task EnforceSecurityPolicyAsync(int departmentId, string us if (policy.RequireSso && !loginViaSso) { var hasSso = await IsSsoEnabledForDepartmentAsync(departmentId, cancellationToken); - if (hasSso) + if (DepartmentSecurityPolicyDecisions.BlocksPasswordLogin(policy.RequireSso, hasSso, loginViaSso)) return "This department requires all users to authenticate via Single Sign-On (SSO). Password-based login is disabled."; // Safety valve: if RequireSso is set but no SSO config exists, allow login to // prevent a complete lockout. Admins should fix their SSO config. @@ -334,7 +334,7 @@ public async Task EnforceSecurityPolicyAsync(int departmentId, string us // 2FA enrolled AND provided a valid totp_code in the request. // If RequireMfa is set but the caller did not complete MFA, deny — regardless of // whether the login was via SSO or password. SSO does NOT bypass Resgrid 2FA. - if (policy.RequireMfa && !mfaCompleted) + if (DepartmentSecurityPolicyDecisions.RequiresMfaCompletion(policy.RequireMfa, mfaCompleted)) return "This department requires Multi-Factor Authentication (MFA). Please complete MFA before continuing."; // IP range enforcement @@ -480,7 +480,7 @@ public async Task GetEffectiveMinPasswordLengthAsync(int departmentId, Canc if (policy == null || policy.MinPasswordLength <= SystemMinPasswordLength) return SystemMinPasswordLength; - return policy.MinPasswordLength; + return DepartmentSecurityPolicyDecisions.MinimumPasswordLength(policy.MinPasswordLength); } catch { @@ -518,7 +518,7 @@ public bool IsPasswordExpired(DepartmentSecurityPolicy policy, DateTime? passwor if (passwordLastSetOn == null) return false; - return DateTime.UtcNow > passwordLastSetOn.Value.AddDays(policy.PasswordExpirationDays); + return DepartmentSecurityPolicyDecisions.PasswordExpired(policy.PasswordExpirationDays, passwordLastSetOn, DateTime.UtcNow); } public async Task RecordPasswordChangedAsync(int departmentId, string userId, CancellationToken cancellationToken = default) diff --git a/Core/Resgrid.Services/LimitsService.cs b/Core/Resgrid.Services/LimitsService.cs index 199bce638..5982eba11 100644 --- a/Core/Resgrid.Services/LimitsService.cs +++ b/Core/Resgrid.Services/LimitsService.cs @@ -218,7 +218,7 @@ public async Task GetLimitsForEntityPlanWithFallbackAsync(int async Task getCurrentPlanForDepartmentAsync() { var limits = new DepartmentLimits(); - var plan = await _subscriptionsService.GetCurrentPlanForDepartmentAsync(departmentId); + var plan = await _subscriptionsService.GetCurrentPlanForDepartmentAsync(departmentId, bypassCache); var departmentCount = await _subscriptionsService.GetPlanCountsForDepartmentAsync(departmentId); // No usage data (e.g. Billing API unavailable): default to free-plan limits with usage assumed diff --git a/Core/Resgrid.Services/PermissionsService.cs b/Core/Resgrid.Services/PermissionsService.cs index f954681d1..8fd2db087 100644 --- a/Core/Resgrid.Services/PermissionsService.cs +++ b/Core/Resgrid.Services/PermissionsService.cs @@ -7,6 +7,7 @@ using Resgrid.Framework; using Resgrid.Model; using Resgrid.Model.Events; +using Resgrid.Model.Helpers; using Resgrid.Model.Providers; using Resgrid.Model.Repositories; using Resgrid.Model.Services; @@ -17,11 +18,13 @@ public class PermissionsService : IPermissionsService { private readonly IUsersService _usersService; private readonly IPermissionsRepository _permissionsRepository; + private readonly IDepartmentGroupsService _departmentGroupsService; - public PermissionsService(IPermissionsRepository permissionsRepository, IUsersService usersService) + public PermissionsService(IPermissionsRepository permissionsRepository, IUsersService usersService, IDepartmentGroupsService departmentGroupsService) { _permissionsRepository = permissionsRepository; _usersService = usersService; + _departmentGroupsService = departmentGroupsService; } public async Task> GetAllPermissionsForDepartmentAsync(int departmentId) @@ -260,16 +263,13 @@ public async Task> GetAllowedUsersAsync(Permission permission, int { return allUsers.Select(x => x.UserId).ToList(); } - else if (permission.Action == (int)PermissionActions.DepartmentAndGroupAdmins && isUserDepartmentAdmin) + else if (permission.Action == (int)PermissionActions.DepartmentAndGroupAdmins && isUserGroupAdmin) { + // Locked: the admin's group and every group beneath it, the same reach the visibility matrices give. if (permission.LockToGroup) - { - return allUsers.Where(x => x.DepartmentGroupId == sourceGroupId).Select(x => x.UserId).ToList(); - } - else - { - return allUsers.Select(x => x.UserId).ToList(); - } + return await UsersInGroupAndBelowAsync(allUsers, departmentId, sourceGroupId); + + return allUsers.Select(x => x.UserId).ToList(); } else if (permission.Action == (int)PermissionActions.DepartmentAdminsAndSelectRoles && isUserDepartmentAdmin) @@ -279,21 +279,12 @@ public async Task> GetAllowedUsersAsync(Permission permission, int else if (permission.Action == (int)PermissionActions.DepartmentAdminsAndSelectRoles && !isUserDepartmentAdmin) { - var roleIds = permission.Data.Split(char.Parse(",")).Select(int.Parse); - var role = from r in roles - where roleIds.Contains(r.PersonnelRoleId) - select r; - - if (role.Any()) + if (HasSelectedRole(permission.Data, roles)) { if (permission.LockToGroup) - { - return allUsers.Where(x => x.DepartmentGroupId == sourceGroupId).Select(x => x.UserId).ToList(); - } - else - { - return allUsers.Select(x => x.UserId).ToList(); - } + return UsersInGroup(allUsers, sourceGroupId); + + return allUsers.Select(x => x.UserId).ToList(); } } else if (permission.Action == (int)PermissionActions.DepartmentAndGroupAdminsAndSelectRoles) @@ -301,19 +292,47 @@ where roleIds.Contains(r.PersonnelRoleId) if (isUserDepartmentAdmin || isUserGroupAdmin || HasSelectedRole(permission.Data, roles)) { if (permission.LockToGroup && !isUserDepartmentAdmin) - return allUsers.Where(x => x.DepartmentGroupId == sourceGroupId).Select(x => x.UserId).ToList(); + return UsersInGroup(allUsers, sourceGroupId); return allUsers.Select(x => x.UserId).ToList(); } } else if (permission.Action == (int)PermissionActions.Everyone) { + if (permission.LockToGroup && !isUserDepartmentAdmin) + return UsersInGroup(allUsers, sourceGroupId); + return allUsers.Select(x => x.UserId).ToList(); } return new List(); } + /// + /// The members of the caller's group, for a permission locked to group. A caller in no group shares a + /// group with nobody, so gets nobody -- not every other ungrouped member. + /// + private static List UsersInGroup(List allUsers, int? sourceGroupId) + { + if (!sourceGroupId.HasValue) + return new List(); + + return allUsers.Where(x => x.DepartmentGroupId == sourceGroupId).Select(x => x.UserId).ToList(); + } + + /// Members of the caller's group and of every group beneath it; nobody when the caller is in no group. + private async Task> UsersInGroupAndBelowAsync(List allUsers, int departmentId, int? sourceGroupId) + { + if (!sourceGroupId.HasValue) + return new List(); + + var groups = await _departmentGroupsService.GetAllGroupsForDepartmentUnlimitedAsync(departmentId) ?? new List(); + var groupIds = DepartmentGroupHierarchy.GetSelfAndDescendantIds(groups, sourceGroupId.Value); + groupIds.Add(sourceGroupId.Value); + + return allUsers.Where(x => x.DepartmentGroupId.HasValue && groupIds.Contains(x.DepartmentGroupId.Value)).Select(x => x.UserId).ToList(); + } + /// Null-safe role-CSV membership check used by the DepartmentAndGroupAdminsAndSelectRoles (4) branches. private static bool HasSelectedRole(string roleIdCsv, List roles) { diff --git a/Core/Resgrid.Services/ProtectedFieldCatalog.cs b/Core/Resgrid.Services/ProtectedFieldCatalog.cs index 340364b7d..e128ac475 100644 --- a/Core/Resgrid.Services/ProtectedFieldCatalog.cs +++ b/Core/Resgrid.Services/ProtectedFieldCatalog.cs @@ -719,6 +719,11 @@ void Prevention(string table, string column, ProtectedFieldClassification classi "InventoryVendors" or "InventoryPurchaseOrders" or "InventoryPurchaseOrderItems" => Resgrid.Model.Inventories.InventoryTables.PurchasingCatalogVersion, _ => Resgrid.Model.Inventories.InventoryTables.CatalogVersion })); + // Admin Assist Phase 0, registry §4G: review notes and dispatch evidence are protected derived copies. + foreach (var table in new[] { "AdminAssistFindings", "AdminAssistDispatchTraces" }) + list.Add(new ProtectedFieldDefinition(table.ToLowerInvariant() + ".content", OperationalFamily, table, "Content", ProtectedFieldStorageKind.Text, + ProtectedFieldClassification.Sensitive, PermissionTypes.ViewProtectedOperationalData, PermissionTypes.EditProtectedCallData, 30)); + // Workforce & Business Operations plan, Phase D (catalog 26, registered with M0213/M0214): the free-text and // document columns of unit certification records (Operational family, beside UnitLogs) and of certification // credit entries (Personnel family). PersonnelCertifications itself stays catalog 6; statuses, dates and the diff --git a/Core/Resgrid.Services/ProtectedProjectionService.cs b/Core/Resgrid.Services/ProtectedProjectionService.cs index 1393baaa7..9575ba5f3 100644 --- a/Core/Resgrid.Services/ProtectedProjectionService.cs +++ b/Core/Resgrid.Services/ProtectedProjectionService.cs @@ -275,7 +275,7 @@ private async Task ChannelAllowsProtectedContentAsync(int departmentId, Pr _ => (int)ProtectedDataEgressMode.GenericOnly }; - // ProtectedAfterPin degrades to GenericOnly until the PIN-release flow ships. + // ProtectedAfterPin stays generic here; only the recipient-bound AdpReleaseService can release it. return mode == (int)ProtectedDataEgressMode.AllowProtectedContent; } catch (Exception ex) diff --git a/Core/Resgrid.Services/Resgrid.Services.csproj b/Core/Resgrid.Services/Resgrid.Services.csproj index ffaa9dd97..0ff5efe50 100644 --- a/Core/Resgrid.Services/Resgrid.Services.csproj +++ b/Core/Resgrid.Services/Resgrid.Services.csproj @@ -19,6 +19,7 @@ + diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs index a2a911598..40f559122 100644 --- a/Core/Resgrid.Services/ServicesModule.cs +++ b/Core/Resgrid.Services/ServicesModule.cs @@ -1,4 +1,4 @@ -using System; +using System; using Autofac; using Resgrid.Model.Providers; using Resgrid.Model.Services; @@ -15,6 +15,37 @@ public class ServicesModule : Module protected override void Load(ContainerBuilder builder) { + builder.RegisterInstance(TimeProvider.System).As().SingleInstance().PreserveExistingDefaults(); + builder.RegisterType().As().SingleInstance(); + 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(); + 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(); + 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().AsSelf().As().As().As().As() .As().As().As().As().As().As().As().InstancePerLifetimeScope(); @@ -230,6 +261,8 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); // Advanced Data Protection (ADP) + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().SingleInstance(); diff --git a/Core/Resgrid.Services/ShiftRosterBuilder.cs b/Core/Resgrid.Services/ShiftRosterBuilder.cs index 369b8fe07..a74e7da37 100644 --- a/Core/Resgrid.Services/ShiftRosterBuilder.cs +++ b/Core/Resgrid.Services/ShiftRosterBuilder.cs @@ -15,8 +15,9 @@ namespace Resgrid.Services /// 2. A completed trade moves a signup's slot: the source slot goes to the accepted user (or the owner of the /// swap-back signup), and a swap-back slot goes to the requester. The original owner is off for that day. /// 3. The standing roster (ShiftPersons) is on every day, except a person who has a denied signup for the day - /// (a supervisor took them off that day) or whose slot was traded away. Someone who also has an active signup - /// for the day is represented by that signup instead. + /// (a supervisor took them off that day) or whose slot was traded away. Someone who also has an on-duty signup + /// for the day in the same group is represented by that signup instead; a signup for another group, or one still + /// waiting for approval, leaves their standing slot alone. /// public static class ShiftRosterBuilder { @@ -40,8 +41,8 @@ public static List Build(Shift shift, DateTime day, IEnumer // People on the standing roster who are off for this day. var standingExclusions = new HashSet(StringComparer.OrdinalIgnoreCase); - // People already represented by one of their own signups for the day. - var usersWithActiveSignup = new HashSet(StringComparer.OrdinalIgnoreCase); + // Group slots already filled, on duty, by the person's own signup for the day. + var onDutySignupSlots = new HashSet<(string UserId, int? GroupId)>(); foreach (var signup in daySignups.Where(x => x.Denied)) standingExclusions.Add(signup.UserId); @@ -51,7 +52,8 @@ public static List Build(Shift shift, DateTime day, IEnumer foreach (var signup in daySignups.Where(x => !x.Denied).OrderBy(x => x.ShiftSignupId)) { - usersWithActiveSignup.Add(signup.UserId); + if (!signup.ApprovalPending) + onDutySignupSlots.Add(SlotKey(signup.UserId, signup.DepartmentGroupId)); if (replacements.TryGetValue(signup.ShiftSignupId, out var replacement)) { @@ -85,7 +87,7 @@ public static List Build(Shift shift, DateTime day, IEnumer { foreach (var person in shift.Personnel.Where(x => x != null && !String.IsNullOrWhiteSpace(x.UserId))) { - if (standingExclusions.Contains(person.UserId) || usersWithActiveSignup.Contains(person.UserId)) + if (standingExclusions.Contains(person.UserId) || onDutySignupSlots.Contains(SlotKey(person.UserId, person.GroupId))) continue; AddEntry(entries, new ShiftDayRosterEntry @@ -175,6 +177,11 @@ public static Dictionary> CalculateNeeds(Shift shift, return needs; } + private static (string UserId, int? GroupId) SlotKey(string userId, int? groupId) + { + return (userId?.ToUpperInvariant(), groupId); + } + private static ShiftRosterSources GetSignupSource(ShiftSignup signup, HashSet standingRoster) { if (!String.IsNullOrWhiteSpace(signup.AssignedByUserId)) diff --git a/Core/Resgrid.Services/ShiftsService.AdministrativeEvidence.cs b/Core/Resgrid.Services/ShiftsService.AdministrativeEvidence.cs new file mode 100644 index 000000000..7df76fed0 --- /dev/null +++ b/Core/Resgrid.Services/ShiftsService.AdministrativeEvidence.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; + +namespace Resgrid.Services +{ + public partial class ShiftsService + { + public async Task> ReadSchedulesForAdministrationAsync(int departmentId, DateTime localStart, DateTime localEnd, + DateTime asOfUtc, int maximumRows, CancellationToken cancellationToken) + { + if (departmentId <= 0 || asOfUtc.Kind != DateTimeKind.Utc || localEnd < localStart || localEnd - localStart > TimeSpan.FromDays(14) || maximumRows is < 1 or > 10000) + throw new ArgumentException("Invalid administrative schedule window."); + cancellationToken.ThrowIfCancellationRequested(); + var department = await _departmentsService.GetDepartmentByIdAsync(departmentId, true) ?? throw new InvalidOperationException("Department unavailable."); + var zone = TimeZoneInfo.FindSystemTimeZoneById(department.TimeZone); + var shifts = (await _shiftsRepository.GetShiftAndDaysByDepartmentIdAsync(departmentId))?.ToList() ?? throw new InvalidOperationException("Schedules unavailable."); + var signups = (await _shiftSignupRepository.GetShiftSignupsByDepartmentIdAndDateRangeAsync(departmentId, localStart.Date, localEnd.Date.AddDays(1)))?.ToList() ?? throw new InvalidOperationException("Signups unavailable."); + var trades = (await _shiftSignupTradeRepository.GetShiftSignupTradesByDepartmentIdAsync(departmentId, localStart.Date))?.ToList() ?? throw new InvalidOperationException("Trades unavailable."); + // This owning getter reads membership, role and role-user repositories; it does not use Redis. + var roles = await _personnelRolesService.GetAllRolesForUsersInDepartmentAsync(departmentId) ?? throw new InvalidOperationException("Roles unavailable."); + cancellationToken.ThrowIfCancellationRequested(); + var rows = (long)shifts.Count + signups.Count + trades.Count + roles.Sum(r => (long)r.Value.Count) + shifts.Sum(s => + (long)(s.Days?.Count ?? 0) + (s.Personnel?.Count ?? 0) + (s.Groups?.Count ?? 0) + (s.Groups?.Sum(g => g.Roles?.Count ?? 0) ?? 0)); + if (rows > maximumRows || shifts.Any(s => s.DepartmentId != departmentId) || roles.Any(r => r.Value.Any(role => role == null))) + throw new InvalidOperationException("Incomplete or out-of-scope schedule evidence."); + var shiftIds = shifts.Select(s => s.ShiftId).ToHashSet(); + if (signups.Any(s => !shiftIds.Contains(s.ShiftId)) || trades.Any(t => t.SourceShiftSignup == null || !shiftIds.Contains(t.SourceShiftSignup.ShiftId) || + (t.TargetShiftSignupId.HasValue && (t.TargetShiftSignup == null || !shiftIds.Contains(t.TargetShiftSignup.ShiftId))))) + throw new InvalidOperationException("Incomplete or out-of-scope trade evidence."); + var data = new ScheduleData { Department = department, Shifts = shifts, Signups = signups, Trades = trades, Roles = roles }; + var localNow = TimeZoneInfo.ConvertTimeFromUtc(asOfUtc, zone); + return shifts.SelectMany(shift => (shift.Days ?? Array.Empty()).Where(day => day.Day.Date >= localStart.Date && day.Day.Date <= localEnd.Date) + .Select(day => BuildSchedule(shift, day, data, localNow))).ToList(); + } + } +} diff --git a/Core/Resgrid.Services/ShiftsService.Scheduling.cs b/Core/Resgrid.Services/ShiftsService.Scheduling.cs index 259fc47fe..b3c8aa28d 100644 --- a/Core/Resgrid.Services/ShiftsService.Scheduling.cs +++ b/Core/Resgrid.Services/ShiftsService.Scheduling.cs @@ -156,21 +156,11 @@ public async Task>> GetOnDutyUserIdsForGroupsAsync( foreach (var groupId in groupIds) { - var onDuty = new HashSet(onDutyEntries.Where(x => x.DepartmentGroupId == groupId).Select(x => x.UserId), StringComparer.OrdinalIgnoreCase); - // Standing-roster people placed on a shift without a group cover their own group. - var ungrouped = onDutyEntries.Where(x => !x.DepartmentGroupId.HasValue).Select(x => x.UserId).ToList(); - - if (ungrouped.Any()) - { - var members = await _departmentGroupsService.GetAllMembersForGroupAsync(groupId) ?? new List(); - var memberIds = new HashSet(members.Select(x => x.UserId), StringComparer.OrdinalIgnoreCase); - - foreach (var userId in ungrouped.Where(memberIds.Contains)) - onDuty.Add(userId); - } - - result[groupId] = onDuty.ToList(); + var members = onDutyEntries.Any(x => !x.DepartmentGroupId.HasValue) + ? (await _departmentGroupsService.GetAllMembersForGroupAsync(groupId) ?? new List()).Select(x => x.UserId) + : Enumerable.Empty(); + result[groupId] = ShiftRosterGroups.Select(groupId, onDutyEntries, members); } return result; @@ -533,7 +523,9 @@ public async Task> RespondToTradeAsync(int s if (!accept) { - await RejectTradeRequestAsync(shiftSignupTradeId, userId, note, cancellationToken); + // Nothing was saved (the participant row went away): report that rather than an answer nobody recorded. + if (!await RejectTradeRequestAsync(shiftSignupTradeId, userId, note, cancellationToken)) + return ShiftActionResult.Fail(ShiftActionErrors.NotAllowed); await PublishAsync(departmentId, number => _eventAggregator.SendMessage(new ShiftTradeRejectedEvent { @@ -556,7 +548,8 @@ await PublishAsync(departmentId, number => _eventAggregator.SendMessage.Fail(ShiftActionErrors.InvalidOffer); } - await ProposeShiftDaysForTradeAsync(shiftSignupTradeId, userId, note, offers, cancellationToken); + if (!await ProposeShiftDaysForTradeAsync(shiftSignupTradeId, userId, note, offers, cancellationToken)) + return ShiftActionResult.Fail(ShiftActionErrors.NotAllowed); await PublishAsync(departmentId, number => _eventAggregator.SendMessage(new ShiftTradeProposedEvent { diff --git a/Core/Resgrid.Services/ShiftsService.cs b/Core/Resgrid.Services/ShiftsService.cs index 8bd45da68..4cf2260fd 100644 --- a/Core/Resgrid.Services/ShiftsService.cs +++ b/Core/Resgrid.Services/ShiftsService.cs @@ -282,7 +282,7 @@ public async Task> GetShiftGroupsForShift(int shiftId) if (trade?.Users == null) return false; - var userTradeRequest = trade.Users.FirstOrDefault(x => x.UserId == userId); + var userTradeRequest = trade.Users.FirstOrDefault(x => SameUser(x.UserId, userId)); if (userTradeRequest != null) { @@ -305,7 +305,7 @@ public async Task> GetShiftGroupsForShift(int shiftId) if (trade?.Users == null) return false; - var userTradeRequest = trade.Users.FirstOrDefault(x => x.UserId == userId); + var userTradeRequest = trade.Users.FirstOrDefault(x => SameUser(x.UserId, userId)); if (userTradeRequest != null) { @@ -330,7 +330,7 @@ public async Task> GetShiftGroupsForShift(int shiftId) var signup = await GetShiftSignupByIdAsync(i); // Only the proposer's own live signups can be offered back, never the day being traded. - if (signup != null && signup.UserId == userId && signup.IsActive() && signup.ShiftSignupId != trade.SourceShiftSignupId) + if (signup != null && SameUser(signup.UserId, userId) && signup.IsActive() && signup.ShiftSignupId != trade.SourceShiftSignupId) { var shift = new ShiftSignupTradeUserShift(); shift.ShiftSignupTradeUserId = userTradeRequest.ShiftSignupTradeUserId; diff --git a/Core/Resgrid.Services/SmsService.cs b/Core/Resgrid.Services/SmsService.cs index 48bae9bbf..541e012a7 100644 --- a/Core/Resgrid.Services/SmsService.cs +++ b/Core/Resgrid.Services/SmsService.cs @@ -488,6 +488,16 @@ await _textMessageProvider.SendTextMessage(ResolveDirectSendNumber(profile), For return true; } + public async Task SendProtectedDispatchChallengeAsync(UserProfile profile, int departmentId, string departmentNumber, string challengeText) + { + if (profile == null || !profile.SendSms || profile.MobileNumberVerified != true || string.IsNullOrWhiteSpace(challengeText)) + return false; + // Dispatch preferences apply, and the sender must accept replies. Never use an email-to-SMS gateway. + return await _textMessageProvider.SendTextMessage(ResolveDirectSendNumber(profile), + FormatNotificationForMessage(challengeText, ShouldDiscloseOptOut(profile.UserId)), departmentNumber, + (MobileCarriers)profile.MobileCarrier, departmentId, false, false); + } + public async Task SendSmsVerificationCodeAsync(string toPhoneNumber, string verificationCode, string departmentNumber, string culture = null) { if (string.IsNullOrWhiteSpace(toPhoneNumber)) diff --git a/Core/Resgrid.Services/UnitsService.cs b/Core/Resgrid.Services/UnitsService.cs index ecf12a632..b53cdc886 100644 --- a/Core/Resgrid.Services/UnitsService.cs +++ b/Core/Resgrid.Services/UnitsService.cs @@ -706,7 +706,11 @@ public async Task GetLatestUnitLocationAsync(int unitId, DateTime return null; } - public async Task> GetLatestUnitLocationsAsync(int departmentId) + public Task> GetLatestUnitLocationsAsync(int departmentId) => ReadLatestLocationsAsync(departmentId, false); + + public Task> ReadLatestLocationsForAdministrationAsync(int departmentId) => ReadLatestLocationsAsync(departmentId, true); + + private async Task> ReadLatestLocationsAsync(int departmentId, bool requireComplete) { try { @@ -752,6 +756,7 @@ public async Task> GetLatestUnitLocationsAsync(int departmen } catch (Exception ex) { + if (requireComplete) throw; Logging.LogException(ex); } diff --git a/Core/Resgrid.Services/UserSessionService.cs b/Core/Resgrid.Services/UserSessionService.cs index 565d3bc63..d787adeaf 100644 --- a/Core/Resgrid.Services/UserSessionService.cs +++ b/Core/Resgrid.Services/UserSessionService.cs @@ -183,7 +183,7 @@ public async Task ValidateAsync(SessionPrincipalContext var policy = await _departmentSsoService.GetSecurityPolicyForDepartmentAsync( session.DepartmentId.Value, cancellationToken); if (policy?.SessionTimeoutMinutes > 0 && - session.LastActiveOn <= DateTime.UtcNow.AddMinutes(-policy.SessionTimeoutMinutes)) + DepartmentSecurityPolicyDecisions.IdleExpired(policy.SessionTimeoutMinutes, session.LastActiveOn, DateTime.UtcNow)) return SessionValidationResult.Invalid("session_idle_timeout"); } } @@ -349,19 +349,7 @@ private async Task ResolveLocationAsync(string ipAddress, stri private static string CanonicalIp(string value) => IPAddress.TryParse(value, out var address) ? address.ToString() : null; - private static bool TryGetDepartmentPolicyGate(out DateTime gateUtc) - { - if (DateTimeOffset.TryParse(SessionSecurityConfig.DepartmentSessionPolicyEnforcementAfterUtc, - System.Globalization.CultureInfo.InvariantCulture, - System.Globalization.DateTimeStyles.AssumeUniversal | - System.Globalization.DateTimeStyles.AdjustToUniversal, out var parsed)) - { - gateUtc = parsed.UtcDateTime; - return true; - } - - gateUtc = default; - return false; - } + private static bool TryGetDepartmentPolicyGate(out DateTime gateUtc) => + DepartmentSecurityPolicyDecisions.TryGetSessionGate(SessionSecurityConfig.DepartmentSessionPolicyEnforcementAfterUtc, out gateUtc); } } diff --git a/Core/Resgrid.Services/UsersService.cs b/Core/Resgrid.Services/UsersService.cs index a260621f8..137495989 100644 --- a/Core/Resgrid.Services/UsersService.cs +++ b/Core/Resgrid.Services/UsersService.cs @@ -283,7 +283,11 @@ public async Task SavePersonnelLocationAsync(PersonnelLocatio return personnelLocation; } - public async Task> GetLatestLocationsForDepartmentPersonnelAsync(int departmentId) + public Task> GetLatestLocationsForDepartmentPersonnelAsync(int departmentId) => ReadLatestLocationsAsync(departmentId, false); + + public Task> ReadLatestLocationsForAdministrationAsync(int departmentId) => ReadLatestLocationsAsync(departmentId, true); + + private async Task> ReadLatestLocationsAsync(int departmentId, bool requireComplete) { try { @@ -306,6 +310,7 @@ public async Task> GetLatestLocationsForDepartmentPerson } catch (Exception ex) { + if (requireComplete) throw; Logging.LogException(ex); } diff --git a/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs b/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs index b59371ee8..ef893ba6e 100644 --- a/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs +++ b/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs @@ -161,6 +161,13 @@ private static void AddEventSpecificSamples(ScriptObject obj, WorkflowTriggerEve if (eventType == WorkflowTriggerEventType.ContractStatusChanged) sampleContract["old_status"] = 0; if (eventType == WorkflowTriggerEventType.ContractExpiring) sampleContract["days_until_end"] = 21; break; + case WorkflowTriggerEventType.AdminAssistFindingOpened: + case WorkflowTriggerEventType.AdminAssistFindingResolved: + case WorkflowTriggerEventType.AdminAssistFindingReopened: + obj["admin_assist"] = new ScriptObject { ["id"] = "00000000-0000-0000-0000-000000000001", ["rule_id"] = "admin-mfa", ["episode"] = 1, + ["result"] = eventType == WorkflowTriggerEventType.AdminAssistFindingResolved ? (int)Resgrid.Model.AdminAssist.RuleResult.Pass : (int)Resgrid.Model.AdminAssist.RuleResult.Fail, + ["severity"] = 2, ["review_status"] = 0, ["url"] = $"{(Resgrid.Config.SystemBehaviorConfig.ResgridBaseUrl ?? string.Empty).TrimEnd('/')}/User/AdminAssist/Index" }; + break; case WorkflowTriggerEventType.WorkOrderCreated: case WorkflowTriggerEventType.WorkOrderStatusChanged: case WorkflowTriggerEventType.WorkOrderAssigned: diff --git a/Core/Resgrid.Services/WorkflowService.cs b/Core/Resgrid.Services/WorkflowService.cs index c6a157315..4e4828fc5 100644 --- a/Core/Resgrid.Services/WorkflowService.cs +++ b/Core/Resgrid.Services/WorkflowService.cs @@ -491,8 +491,11 @@ public async Task ExecuteWorkflowAsync( { // Unknown protection state: fail closed. Nothing runs; the run fails (and retries) without sending. Logging.LogError($"Protected workflow gate unavailable for run {run.WorkflowRunId}: {gateEx.GetType().FullName}."); - run.Status = (int)WorkflowRunStatus.Failed; + var gateMaxRetries = workflow.MaxRetryCount > 0 ? workflow.MaxRetryCount : WorkflowConfig.DefaultMaxRetryCount; + run.Status = attemptNumber < gateMaxRetries ? (int)WorkflowRunStatus.Retrying : (int)WorkflowRunStatus.Failed; run.ErrorMessage = "protected_gate_unavailable"; + if (run.Status == (int)WorkflowRunStatus.Failed && _protectedWorkflows != null) + await _protectedWorkflows.NotifyFinalFailureAsync(departmentId, workflow, run.WorkflowRunId, "protected_gate_unavailable", cancellationToken); run.CompletedOn = DateTime.UtcNow; await UpdateRunAsync(run, cancellationToken); return run; @@ -539,9 +542,11 @@ public async Task ExecuteWorkflowAsync( var steps = await GetStepsByWorkflowIdAsync(workflowId, cancellationToken); var anyFailure = false; - var anyRetryable = false; var utcToday = DateTime.UtcNow.Date; string lastProtectedError = null; + // The first protected failure another attempt could not fix. A retry runs every step again, so one such + // failure stops the whole run even when another step's failure alone would have been retried. + string protectedStopError = null; foreach (var step in steps.Where(s => s.IsEnabled)) { @@ -563,8 +568,9 @@ public async Task ExecuteWorkflowAsync( if (protectedStep.Failed) { anyFailure = true; - anyRetryable |= protectedStep.Retryable; lastProtectedError = protectedStep.ErrorCode ?? lastProtectedError; + if (!protectedStep.Retryable && protectedStopError == null) + protectedStopError = protectedStep.ErrorCode ?? ProtectedWorkflowErrorCodes.StepError; } continue; } @@ -863,17 +869,18 @@ public async Task ExecuteWorkflowAsync( // A protected run retries only for failures another attempt could fix (transport, 5xx, 429); a rejected // acknowledgement, a 4xx or a refused send stops at once and alerts. - if (attemptNumber < maxRetries && (!protectedGate.IsProtected || anyRetryable)) + var protectedStopped = protectedGate.IsProtected && protectedStopError != null; + if (attemptNumber < maxRetries && !protectedStopped) run.Status = (int)WorkflowRunStatus.Retrying; else { run.Status = (int)WorkflowRunStatus.Failed; - run.ErrorMessage = protectedGate.IsProtected && !anyRetryable && attemptNumber < maxRetries - ? $"Not retried: {lastProtectedError ?? ProtectedWorkflowErrorCodes.StepError}" + run.ErrorMessage = protectedStopped && attemptNumber < maxRetries + ? $"Not retried: {protectedStopError}" : "Maximum retry attempts exceeded."; if (protectedGate.IsProtected && _protectedWorkflows != null) await _protectedWorkflows.NotifyFinalFailureAsync(departmentId, workflow, run.WorkflowRunId, - lastProtectedError ?? ProtectedWorkflowErrorCodes.StepError, cancellationToken); + protectedStopError ?? lastProtectedError ?? ProtectedWorkflowErrorCodes.StepError, cancellationToken); } } else diff --git a/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs b/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs index 48c1066de..2eb399464 100644 --- a/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs +++ b/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs @@ -618,6 +618,18 @@ public async Task BuildContextAsync( scriptObject["contract"] = contract; break; } + case WorkflowTriggerEventType.AdminAssistFindingOpened: + case WorkflowTriggerEventType.AdminAssistFindingResolved: + case WorkflowTriggerEventType.AdminAssistFindingReopened: + { + var findingEvent = TryDeserialize(eventPayloadJson); + var findingPayload = JObject.Parse(Resgrid.Model.AdminAssist.AdminAssistWorkflowPayload.Routing(findingEvent?.Payload ?? new JObject())); + var finding = new ScriptObject(); + foreach (var pair in Resgrid.Model.AdminAssist.AdminAssistWorkflowPayload.Variables) finding[pair.Variable] = ToScriptValue(findingPayload[pair.Property]); + finding["url"] = $"{(Resgrid.Config.SystemBehaviorConfig.ResgridBaseUrl ?? string.Empty).TrimEnd('/')}/User/AdminAssist/Index"; + scriptObject["admin_assist"] = finding; + break; + } case WorkflowTriggerEventType.WorkOrderCreated: case WorkflowTriggerEventType.WorkOrderStatusChanged: case WorkflowTriggerEventType.WorkOrderAssigned: diff --git a/Docker/resgrid.env b/Docker/resgrid.env index 418be60f0..968c5a511 100644 --- a/Docker/resgrid.env +++ b/Docker/resgrid.env @@ -204,11 +204,14 @@ RESGRID__DODBUPGRADE=true # --- Web Config Section ------------------------ # ----------------------------------------------- -# Allowed Ingress network for forwarded headers (k8s config) -RESGRID__WebConfig__IngressProxyNetwork=10.42.0.0 - -# CIDR for Ingress network mask -RESGRID__WebConfig__IngressProxyNetworkCidr=16 +# Network of the reverse proxy in front of Resgrid (nginx in docker-compose.yml). Only requests arriving from this +# network may set the caller's IP through X-Forwarded-For; audit logs, session tracking and per-IP rate limits use it. +# 172.16.0.0/12 covers Docker's default private networks. Set it to your proxy's network if it runs elsewhere; a +# Kubernetes ingress on k3s' default pod network would be 10.42.0.0/16. +RESGRID__WebConfig__IngressProxyNetwork=172.16.0.0 + +# CIDR prefix length for the proxy network above +RESGRID__WebConfig__IngressProxyNetworkCidr=12 # ----------------------------------------------- # --- Hardware GPS Tracking Section ------------- diff --git a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitAdminAssistTraceQueue.cs b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitAdminAssistTraceQueue.cs new file mode 100644 index 000000000..c9e734d78 --- /dev/null +++ b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitAdminAssistTraceQueue.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using RabbitMQ.Client; +using Resgrid.Config; +using Resgrid.Model.AdminAssist; + +namespace Resgrid.Providers.Bus.Rabbit +{ + /// + /// Dedicated durable telemetry queue and connections. Publisher confirmation precedes success; a + /// consumer acknowledges only after idempotent persistence. No dispatch connection, message or retry + /// is touched. Full queues reject new evidence rather than evicting older evidence (including holds). + /// + public sealed class RabbitAdminAssistTraceQueue(IConnectionFactory factory) : IAdminAssistTraceQueue, IAsyncDisposable + { + private readonly Lane _publisher = new(); + private readonly Lane _consumer = new(); + private static readonly JsonSerializerOptions Json = new() { UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow }; + private static string QueueName => RabbitConnection.SetQueueNameForEnv(AdminAssistConfig.TraceQueueName); + private sealed record Envelope(int Version, string Id, int DepartmentId, int CallId, string AttemptId, + string Stage, string ResolverVersion, DateTime OccurredOn, string Content, bool IsProtected, int ProtectedCatalogVersion) + { + public static Envelope From(AdminAssistDispatchTraceRow row) => new(1, row.AdminAssistDispatchTraceId, row.DepartmentId, + row.CallId, row.AttemptId, row.Stage, row.ResolverVersion, row.OccurredOn, row.Content, row.IsProtected, row.ProtectedCatalogVersion); + public AdminAssistDispatchTraceRow ToRow() + { + if (Version != 1) throw new ArgumentException("Unsupported trace envelope version."); + return new() { AdminAssistDispatchTraceId = Id, DepartmentId = DepartmentId, CallId = CallId, AttemptId = AttemptId, + Stage = Stage, ResolverVersion = ResolverVersion, OccurredOn = OccurredOn, Content = Content, + IsProtected = IsProtected, ProtectedCatalogVersion = ProtectedCatalogVersion }; + } + } + public async Task EnqueueAsync(AdminAssistDispatchTraceRow row, CancellationToken ct) + { + DispatchTraceEnvelope.Validate(row); + var body = JsonSerializer.SerializeToUtf8Bytes(Envelope.From(row), Json); + if (body.Length > DispatchTraceEnvelope.MaximumBytes) throw new ArgumentException("Trace envelope exceeds the queue bound."); + await RunAsync(_publisher, async channel => + { + await channel.BasicPublishAsync(string.Empty, QueueName, true, + new BasicProperties { DeliveryMode = DeliveryModes.Persistent, MessageId = row.AdminAssistDispatchTraceId, + ContentType = DispatchTraceEnvelope.ContentType }, body, ct); + return true; + }, ct); + } + public Task ProcessNextAsync(Func persist, CancellationToken ct) => + RunAsync(_consumer, async channel => + { + var delivery = await channel.BasicGetAsync(QueueName, false, ct); + if (delivery == null) return DispatchTraceReceiveResult.Empty; + if (delivery.Body.Length > DispatchTraceEnvelope.MaximumBytes || delivery.BasicProperties.ContentType != DispatchTraceEnvelope.ContentType) + throw new ArgumentException("Invalid trace transport envelope."); + var row = (JsonSerializer.Deserialize(delivery.Body.Span, Json) ?? throw new ArgumentException("Empty trace envelope.")).ToRow(); + DispatchTraceEnvelope.Validate(row); + if (delivery.BasicProperties.MessageId != row.AdminAssistDispatchTraceId) throw new ArgumentException("Trace message identity mismatch."); + await persist(row, ct); + await channel.BasicAckAsync(delivery.DeliveryTag, false, ct); + return DispatchTraceReceiveResult.Persisted; + }, ct); + + private async Task RunAsync(Lane lane, Func> operation, CancellationToken ct) + { + await lane.Gate.WaitAsync(ct); + try + { + if (lane.Channel?.IsOpen != true || lane.Connection?.IsOpen != true) + { + await lane.ResetAsync(); + var hosts = new[] { ServiceBusConfig.RabbitHostname, ServiceBusConfig.RabbitHostname2, ServiceBusConfig.RabbitHostname3 } + .Where(h => !string.IsNullOrWhiteSpace(h)).Distinct(StringComparer.Ordinal).ToArray(); + lane.Connection = await factory.CreateConnectionAsync(hosts, "Resgrid-AdminAssist-Trace", ct); + lane.Channel = await lane.Connection.CreateChannelAsync(new CreateChannelOptions(true, true), ct); + await lane.Channel.QueueDeclareAsync(QueueName, durable: true, exclusive: false, autoDelete: false, + arguments: new Dictionary { ["x-max-length"] = 100000, ["x-max-length-bytes"] = 268435456L, ["x-overflow"] = "reject-publish" }, cancellationToken: ct); + } + return await operation(lane.Channel); + } + catch + { + // Closing returns unacknowledged deliveries to the broker, including a process/DB failure + // after persistence but before ack. Same observation ID makes replay idempotent. + await lane.ResetAsync(); + throw; + } + finally { lane.Gate.Release(); } + } + private sealed class Lane + { + public readonly SemaphoreSlim Gate = new(1, 1); + public IConnection Connection; + public IChannel Channel; + public async Task ResetAsync() + { + var channel = Channel; var connection = Connection; Channel = null; Connection = null; + try { if (channel != null) await channel.DisposeAsync(); } catch { /* Diagnostic transport cleanup only. */ } + try { if (connection != null) await connection.DisposeAsync(); } catch { /* Never change send behavior. */ } + } + } + public async ValueTask DisposeAsync() + { + foreach (var lane in new[] { _publisher, _consumer }) + { + await lane.Gate.WaitAsync(); + try { await lane.ResetAsync(); } + finally { lane.Gate.Release(); } + } + } + } +} diff --git a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitBusModule.cs b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitBusModule.cs index 19072695c..2fa2d7d98 100644 --- a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitBusModule.cs +++ b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitBusModule.cs @@ -9,6 +9,11 @@ protected override void Load(ContainerBuilder builder) { builder.RegisterType().As().SingleInstance(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.Register(_ => new RabbitAdminAssistTraceQueue(new RabbitMQ.Client.ConnectionFactory + { + UserName = Config.ServiceBusConfig.RabbitUsername, Password = Config.ServiceBusConfig.RabbbitPassword, + AutomaticRecoveryEnabled = false + })).As().SingleInstance(); //builder.RegisterType().As().InstancePerLifetimeScope(); //builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Providers/Resgrid.Providers.Email/PostmarkEmailSender.cs b/Providers/Resgrid.Providers.Email/PostmarkEmailSender.cs index 94cccd9fb..d425d2ff2 100644 --- a/Providers/Resgrid.Providers.Email/PostmarkEmailSender.cs +++ b/Providers/Resgrid.Providers.Email/PostmarkEmailSender.cs @@ -34,6 +34,9 @@ public async Task SendEmail(MailMessage email) var newClient = new PostmarkClient(Config.OutboundEmailServerConfig.PostmarkApiKey); var response = await newClient.SendMessageAsync(message); + Resgrid.Model.AdminAssist.DispatchTraceTelemetry.ProviderResult( + Resgrid.Model.AdminAssist.DispatchTraceProvider.Postmark, Resgrid.Model.AdminAssist.DispatchTraceChannel.Email, + response.MessageID.ToString(), response.ErrorCode == 0); if (response.ErrorCode != 200 && response.ErrorCode != 406 && response.Message != "OK" && !response.Message.Contains("You tried to send to a recipient that has been marked as inactive")) @@ -92,6 +95,9 @@ public async Task Send(Email email) } var response = await newClient.SendMessageAsync(message); + Resgrid.Model.AdminAssist.DispatchTraceTelemetry.ProviderResult( + Resgrid.Model.AdminAssist.DispatchTraceProvider.Postmark, Resgrid.Model.AdminAssist.DispatchTraceChannel.Email, + response.MessageID.ToString(), response.ErrorCode == 0); if (response.ErrorCode != 200 && response.ErrorCode != 406 && response.Message != "OK" && !response.Message.Contains( diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0235_AddAdminAssistFoundation.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0235_AddAdminAssistFoundation.cs new file mode 100644 index 000000000..efa264a62 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0235_AddAdminAssistFoundation.cs @@ -0,0 +1,130 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// Registry §4G: deterministic setup, evidence review and trace storage. No model payloads. + [Migration(235)] + public class M0235_AddAdminAssistFoundation : Migration + { + public override void Up() + { + if (!Schema.Table("AdminAssistWorkspaces").Exists()) + Create.Table("AdminAssistWorkspaces") + .WithColumn("DepartmentId").AsInt32().PrimaryKey().NotNullable() + .WithColumn("Revision").AsInt64().NotNullable() + .WithColumn("Mode").AsInt32().NotNullable() + .WithColumn("AreasJson").AsString(int.MaxValue).NotNullable() + .WithColumn("CatalogVersion").AsString(64).NotNullable() + .WithColumn("ReviewedOn").AsDateTime2().Nullable() + .WithColumn("ModifiedOn").AsDateTime2().NotNullable(); + if (!Schema.Table("AdminAssistLearning").Exists()) + Create.Table("AdminAssistLearning") + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("UserId").AsString(128).NotNullable() + .WithColumn("CapabilityId").AsString(128).NotNullable() + .WithColumn("CatalogVersion").AsString(64).NotNullable() + .WithColumn("Learned").AsBoolean().NotNullable() + .WithColumn("Interested").AsBoolean().NotNullable() + .WithColumn("ModifiedOn").AsDateTime2().NotNullable(); + if (!Schema.Table("AdminAssistHistory").Exists()) + Create.Table("AdminAssistHistory") + .WithColumn("AdminAssistHistoryId").AsString(36).PrimaryKey().NotNullable() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ActorId").AsString(128).Nullable() + .WithColumn("OccurredOnUtc").AsDateTime2().NotNullable() + .WithColumn("Source").AsString(64).NotNullable() + .WithColumn("CorrelationId").AsString(64).Nullable() + .WithColumn("Action").AsString(64).NotNullable() + .WithColumn("SubjectId").AsString(192).NotNullable() + .WithColumn("BeforeCode").AsString(int.MaxValue).Nullable() + .WithColumn("AfterCode").AsString(int.MaxValue).Nullable() + .WithColumn("Revision").AsInt64().NotNullable(); + if (!Schema.Table("AdminAssistConfigurationRevisions").Exists()) + Create.Table("AdminAssistConfigurationRevisions") + .WithColumn("DepartmentId").AsInt32().PrimaryKey().NotNullable() + .WithColumn("Revision").AsInt64().NotNullable() + .WithColumn("ModifiedOn").AsDateTime2().NotNullable(); + if (!Schema.Table("AdminAssistFindings").Exists()) + Create.Table("AdminAssistFindings") + .WithColumn("AdminAssistFindingId").AsString(36).PrimaryKey().NotNullable() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("RuleId").AsString(128).NotNullable() + .WithColumn("SubjectId").AsString(128).NotNullable() + .WithColumn("Episode").AsInt32().NotNullable() + .WithColumn("Result").AsInt32().NotNullable() + .WithColumn("Severity").AsInt32().NotNullable() + .WithColumn("ReviewStatus").AsInt32().NotNullable() + .WithColumn("OwnerId").AsString(128).Nullable() + .WithColumn("ReviewOn").AsDateTime2().Nullable() + .WithColumn("ExceptionUntil").AsDateTime2().Nullable() + .WithColumn("Content").AsString(int.MaxValue).Nullable() + .WithColumn("IsProtected").AsBoolean().WithDefaultValue(false).NotNullable() + .WithColumn("ProtectedCatalogVersion").AsInt32().WithDefaultValue(0).NotNullable() + .WithColumn("SnapshotRevision").AsString(128).NotNullable() + .WithColumn("Revision").AsInt64().NotNullable() + .WithColumn("FirstObservedOn").AsDateTime2().NotNullable() + .WithColumn("LastObservedOn").AsDateTime2().NotNullable(); + if (!Schema.Table("AdminAssistDispatchTraces").Exists()) + Create.Table("AdminAssistDispatchTraces") + .WithColumn("AdminAssistDispatchTraceId").AsString(36).PrimaryKey().NotNullable() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("CallId").AsInt32().NotNullable() + .WithColumn("AttemptId").AsString(36).NotNullable() + .WithColumn("Stage").AsString(48).NotNullable() + .WithColumn("ResolverVersion").AsString(64).NotNullable() + .WithColumn("OccurredOn").AsDateTime2().NotNullable() + .WithColumn("Content").AsString(int.MaxValue).Nullable() + .WithColumn("IsProtected").AsBoolean().WithDefaultValue(false).NotNullable() + .WithColumn("ProtectedCatalogVersion").AsInt32().WithDefaultValue(0).NotNullable(); + if (!Schema.Table("AdminAssistWorkerStates").Exists()) + Create.Table("AdminAssistWorkerStates") + .WithColumn("DepartmentId").AsInt32().PrimaryKey().NotNullable() + .WithColumn("LastEvaluatedOn").AsDateTime2().Nullable() + .WithColumn("LastAttemptOn").AsDateTime2().Nullable() + .WithColumn("LastDigestOn").AsDateTime2().Nullable() + .WithColumn("DigestCursor").AsString(128).Nullable() + .WithColumn("LeaseOwner").AsString(36).Nullable() + .WithColumn("LeaseExpiresOn").AsDateTime2().Nullable(); + if (!Schema.Table("AdminAssistDailySummaries").Exists()) + Create.Table("AdminAssistDailySummaries") + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("DayUtc").AsDateTime2().NotNullable() + .WithColumn("FailedCount").AsInt32().NotNullable() + .WithColumn("UnknownCount").AsInt32().NotNullable() + .WithColumn("EvaluatedCount").AsInt32().NotNullable() + .WithColumn("CatalogVersion").AsString(64).NotNullable(); + if (!Schema.Table("AdminAssistPreferences").Exists()) + Create.Table("AdminAssistPreferences") + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("UserId").AsString(128).NotNullable() + .WithColumn("DigestEnabled").AsBoolean().NotNullable() + .WithColumn("QuietStartHour").AsInt32().NotNullable() + .WithColumn("QuietEndHour").AsInt32().NotNullable() + .WithColumn("Locale").AsString(16).NotNullable() + .WithColumn("Revision").AsInt64().NotNullable() + .WithColumn("LastAttemptWeek").AsString(10).Nullable() + .WithColumn("LastAttemptOn").AsDateTime2().Nullable() + .WithColumn("LastAttemptOutcome").AsString(32).Nullable(); + if (!Schema.Table("AdminAssistPreferences").Index("UX_AdminAssistPreferences_Scope").Exists()) + Create.Index("UX_AdminAssistPreferences_Scope").OnTable("AdminAssistPreferences").OnColumn("DepartmentId").Ascending().OnColumn("UserId").Ascending().WithOptions().Unique(); + if (!Schema.Table("AdminAssistLearning").Index("UX_AdminAssistLearning_Scope").Exists()) + Create.Index("UX_AdminAssistLearning_Scope").OnTable("AdminAssistLearning").OnColumn("DepartmentId").Ascending().OnColumn("UserId").Ascending().OnColumn("CapabilityId").Ascending().OnColumn("CatalogVersion").Ascending().WithOptions().Unique(); + if (!Schema.Table("AdminAssistHistory").Index("IX_AdminAssistHistory_Scope").Exists()) + Create.Index("IX_AdminAssistHistory_Scope").OnTable("AdminAssistHistory").OnColumn("DepartmentId").Ascending().OnColumn("OccurredOnUtc").Ascending().OnColumn("AdminAssistHistoryId").Ascending(); + if (!Schema.Table("AdminAssistFindings").Index("UX_AdminAssistFindings_Scope").Exists()) + Create.Index("UX_AdminAssistFindings_Scope").OnTable("AdminAssistFindings").OnColumn("DepartmentId").Ascending().OnColumn("RuleId").Ascending().OnColumn("SubjectId").Ascending().WithOptions().Unique(); + if (!Schema.Table("AdminAssistDispatchTraces").Index("IX_AdminAssistDispatchTraces_Scope").Exists()) + Create.Index("IX_AdminAssistDispatchTraces_Scope").OnTable("AdminAssistDispatchTraces").OnColumn("DepartmentId").Ascending().OnColumn("CallId").Ascending().OnColumn("OccurredOn").Ascending(); + if (!Schema.Table("AdminAssistDailySummaries").Index("UX_AdminAssistDailySummaries_Scope").Exists()) + Create.Index("UX_AdminAssistDailySummaries_Scope").OnTable("AdminAssistDailySummaries").OnColumn("DepartmentId").Ascending().OnColumn("DayUtc").Ascending().WithOptions().Unique(); + Execute.Sql("IF NOT EXISTS (SELECT 1 FROM [FeatureFlags] WHERE [FlagKey]='Admin.Setup') INSERT INTO [FeatureFlags] ([FlagKey],[Name],[Description],[Category],[IsEnabledGlobally]) VALUES ('Admin.Setup','Department Setup','Seeded off; deterministic setup is independent of AI.','Administration',0);"); + Execute.Sql("IF NOT EXISTS (SELECT 1 FROM [FeatureFlags] WHERE [FlagKey]='Admin.Assist') INSERT INTO [FeatureFlags] ([FlagKey],[Name],[Description],[Category],[IsEnabledGlobally]) VALUES ('Admin.Assist','Admin Assist','Seeded off; deterministic setup is independent of AI.','Administration',0);"); + Execute.Sql("IF NOT EXISTS (SELECT 1 FROM [FeatureFlags] WHERE [FlagKey]='Ai.AdminAssist') INSERT INTO [FeatureFlags] ([FlagKey],[Name],[Description],[Category],[IsEnabledGlobally]) VALUES ('Ai.AdminAssist','Admin Assist Conversation','Seeded off; deterministic setup is independent of AI.','Administration',0);"); + } + + public override void Down() + { + // Keep evidence and operator-owned flags on rollback; disable rollout instead of deleting history. + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0236_AddAdpAudit.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0236_AddAdpAudit.cs new file mode 100644 index 000000000..2470aa3d1 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0236_AddAdpAudit.cs @@ -0,0 +1,36 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + [Migration(236)] + public class M0236_AddAdpAudit : Migration + { + public override void Up() + { + if (!Schema.Table("AdpAuditEvents").Exists()) + Create.Table("AdpAuditEvents") + .WithColumn("EventId").AsString(32).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("Sequence").AsInt64().NotNullable() + .WithColumn("Layer").AsString(32).NotNullable() + .WithColumn("Operation").AsString(64).NotNullable() + .WithColumn("Outcome").AsString(64).NotNullable() + .WithColumn("ActorId").AsString(128).Nullable() + .WithColumn("CorrelationId").AsString(128).Nullable() + .WithColumn("ResourceId").AsString(128).Nullable() + .WithColumn("PolicyEpoch").AsInt64().NotNullable() + .WithColumn("OccurredUtc").AsDateTime2().NotNullable() + .WithColumn("PreviousHash").AsString(64).NotNullable() + .WithColumn("Hash").AsString(64).NotNullable(); + Execute.Sql("IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name='UX_AdpAudit_Sequence' AND object_id=OBJECT_ID('AdpAuditEvents')) CREATE UNIQUE INDEX UX_AdpAudit_Sequence ON AdpAuditEvents(DepartmentId, Sequence);"); + if (!Schema.Table("AdpAccessStates").Exists()) + Create.Table("AdpAccessStates") + .WithColumn("StateId").AsString(200).NotNullable().PrimaryKey() + .WithColumn("Json").AsString(int.MaxValue).NotNullable() + .WithColumn("Version").AsInt64().NotNullable(); + } + + // Audit evidence must survive application rollback. + public override void Down() { } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0235_AddAdminAssistFoundationPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0235_AddAdminAssistFoundationPg.cs new file mode 100644 index 000000000..1ffd0420c --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0235_AddAdminAssistFoundationPg.cs @@ -0,0 +1,129 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// Registry §4G: deterministic setup, evidence review and trace storage. No model payloads. + [Migration(235)] + public class M0235_AddAdminAssistFoundationPg : Migration + { + public override void Up() + { + if (!Schema.Table("adminassistworkspaces").Exists()) + Create.Table("adminassistworkspaces") + .WithColumn("departmentid").AsInt32().PrimaryKey().NotNullable() + .WithColumn("revision").AsInt64().NotNullable() + .WithColumn("mode").AsInt32().NotNullable() + .WithColumn("areasjson").AsString(int.MaxValue).NotNullable() + .WithColumn("catalogversion").AsString(64).NotNullable() + .WithColumn("reviewedon").AsDateTime().Nullable() + .WithColumn("modifiedon").AsDateTime().NotNullable(); + if (!Schema.Table("adminassistlearning").Exists()) + Create.Table("adminassistlearning") + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("userid").AsString(128).NotNullable() + .WithColumn("capabilityid").AsString(128).NotNullable() + .WithColumn("catalogversion").AsString(64).NotNullable() + .WithColumn("learned").AsBoolean().NotNullable() + .WithColumn("interested").AsBoolean().NotNullable() + .WithColumn("modifiedon").AsDateTime().NotNullable(); + if (!Schema.Table("adminassisthistory").Exists()) + Create.Table("adminassisthistory") + .WithColumn("adminassisthistoryid").AsString(36).PrimaryKey().NotNullable() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("actorid").AsString(128).Nullable() + .WithColumn("occurredonutc").AsDateTime().NotNullable() + .WithColumn("source").AsString(64).NotNullable() + .WithColumn("action").AsString(64).NotNullable() + .WithColumn("subjectid").AsString(192).NotNullable() + .WithColumn("beforecode").AsString(128).Nullable() + .WithColumn("aftercode").AsString(128).Nullable() + .WithColumn("revision").AsInt64().NotNullable(); + if (!Schema.Table("adminassistconfigurationrevisions").Exists()) + Create.Table("adminassistconfigurationrevisions") + .WithColumn("departmentid").AsInt32().PrimaryKey().NotNullable() + .WithColumn("revision").AsInt64().NotNullable() + .WithColumn("modifiedon").AsDateTime().NotNullable(); + if (!Schema.Table("adminassistfindings").Exists()) + Create.Table("adminassistfindings") + .WithColumn("adminassistfindingid").AsString(36).PrimaryKey().NotNullable() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("ruleid").AsString(128).NotNullable() + .WithColumn("subjectid").AsString(128).NotNullable() + .WithColumn("episode").AsInt32().NotNullable() + .WithColumn("result").AsInt32().NotNullable() + .WithColumn("severity").AsInt32().NotNullable() + .WithColumn("reviewstatus").AsInt32().NotNullable() + .WithColumn("ownerid").AsString(128).Nullable() + .WithColumn("reviewon").AsDateTime().Nullable() + .WithColumn("exceptionuntil").AsDateTime().Nullable() + .WithColumn("content").AsString(int.MaxValue).Nullable() + .WithColumn("isprotected").AsBoolean().WithDefaultValue(false).NotNullable() + .WithColumn("protectedcatalogversion").AsInt32().WithDefaultValue(0).NotNullable() + .WithColumn("snapshotrevision").AsString(128).NotNullable() + .WithColumn("revision").AsInt64().NotNullable() + .WithColumn("firstobservedon").AsDateTime().NotNullable() + .WithColumn("lastobservedon").AsDateTime().NotNullable(); + if (!Schema.Table("adminassistdispatchtraces").Exists()) + Create.Table("adminassistdispatchtraces") + .WithColumn("adminassistdispatchtraceid").AsString(36).PrimaryKey().NotNullable() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("callid").AsInt32().NotNullable() + .WithColumn("attemptid").AsString(36).NotNullable() + .WithColumn("stage").AsString(48).NotNullable() + .WithColumn("resolverversion").AsString(64).NotNullable() + .WithColumn("occurredon").AsDateTime().NotNullable() + .WithColumn("content").AsString(int.MaxValue).Nullable() + .WithColumn("isprotected").AsBoolean().WithDefaultValue(false).NotNullable() + .WithColumn("protectedcatalogversion").AsInt32().WithDefaultValue(0).NotNullable(); + if (!Schema.Table("adminassistworkerstates").Exists()) + Create.Table("adminassistworkerstates") + .WithColumn("departmentid").AsInt32().PrimaryKey().NotNullable() + .WithColumn("lastevaluatedon").AsDateTime().Nullable() + .WithColumn("lastattempton").AsDateTime().Nullable() + .WithColumn("lastdigeston").AsDateTime().Nullable() + .WithColumn("digestcursor").AsString(128).Nullable() + .WithColumn("leaseowner").AsString(36).Nullable() + .WithColumn("leaseexpireson").AsDateTime().Nullable(); + if (!Schema.Table("adminassistdailysummaries").Exists()) + Create.Table("adminassistdailysummaries") + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("dayutc").AsDateTime().NotNullable() + .WithColumn("failedcount").AsInt32().NotNullable() + .WithColumn("unknowncount").AsInt32().NotNullable() + .WithColumn("evaluatedcount").AsInt32().NotNullable() + .WithColumn("catalogversion").AsString(64).NotNullable(); + if (!Schema.Table("adminassistpreferences").Exists()) + Create.Table("adminassistpreferences") + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("userid").AsString(128).NotNullable() + .WithColumn("digestenabled").AsBoolean().NotNullable() + .WithColumn("quietstarthour").AsInt32().NotNullable() + .WithColumn("quietendhour").AsInt32().NotNullable() + .WithColumn("locale").AsString(16).NotNullable() + .WithColumn("revision").AsInt64().NotNullable() + .WithColumn("lastattemptweek").AsString(10).Nullable() + .WithColumn("lastattempton").AsDateTime().Nullable() + .WithColumn("lastattemptoutcome").AsString(32).Nullable(); + if (!Schema.Table("adminassistpreferences").Index("ux_adminassistpreferences_scope").Exists()) + Create.Index("ux_adminassistpreferences_scope").OnTable("adminassistpreferences").OnColumn("departmentid").Ascending().OnColumn("userid").Ascending().WithOptions().Unique(); + if (!Schema.Table("adminassistlearning").Index("ux_adminassistlearning_scope").Exists()) + Create.Index("ux_adminassistlearning_scope").OnTable("adminassistlearning").OnColumn("departmentid").Ascending().OnColumn("userid").Ascending().OnColumn("capabilityid").Ascending().OnColumn("catalogversion").Ascending().WithOptions().Unique(); + if (!Schema.Table("adminassisthistory").Index("ix_adminassisthistory_scope").Exists()) + Create.Index("ix_adminassisthistory_scope").OnTable("adminassisthistory").OnColumn("departmentid").Ascending().OnColumn("occurredonutc").Ascending().OnColumn("adminassisthistoryid").Ascending(); + if (!Schema.Table("adminassistfindings").Index("ux_adminassistfindings_scope").Exists()) + Create.Index("ux_adminassistfindings_scope").OnTable("adminassistfindings").OnColumn("departmentid").Ascending().OnColumn("ruleid").Ascending().OnColumn("subjectid").Ascending().WithOptions().Unique(); + if (!Schema.Table("adminassistdispatchtraces").Index("ix_adminassistdispatchtraces_scope").Exists()) + Create.Index("ix_adminassistdispatchtraces_scope").OnTable("adminassistdispatchtraces").OnColumn("departmentid").Ascending().OnColumn("callid").Ascending().OnColumn("occurredon").Ascending(); + if (!Schema.Table("adminassistdailysummaries").Index("ux_adminassistdailysummaries_scope").Exists()) + Create.Index("ux_adminassistdailysummaries_scope").OnTable("adminassistdailysummaries").OnColumn("departmentid").Ascending().OnColumn("dayutc").Ascending().WithOptions().Unique(); + Execute.Sql("INSERT INTO featureflags (flagkey,name,description,category,isenabledglobally) SELECT 'Admin.Setup','Department Setup','Seeded off; deterministic setup is independent of AI.','Administration',false WHERE NOT EXISTS (SELECT 1 FROM featureflags WHERE flagkey='Admin.Setup');"); + Execute.Sql("INSERT INTO featureflags (flagkey,name,description,category,isenabledglobally) SELECT 'Admin.Assist','Admin Assist','Seeded off; deterministic setup is independent of AI.','Administration',false WHERE NOT EXISTS (SELECT 1 FROM featureflags WHERE flagkey='Admin.Assist');"); + Execute.Sql("INSERT INTO featureflags (flagkey,name,description,category,isenabledglobally) SELECT 'Ai.AdminAssist','Admin Assist Conversation','Seeded off; deterministic setup is independent of AI.','Administration',false WHERE NOT EXISTS (SELECT 1 FROM featureflags WHERE flagkey='Ai.AdminAssist');"); + } + + public override void Down() + { + // Keep evidence and operator-owned flags on rollback; disable rollout instead of deleting history. + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0236_AddAdpAuditPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0236_AddAdpAuditPg.cs new file mode 100644 index 000000000..4d0256c07 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0236_AddAdpAuditPg.cs @@ -0,0 +1,36 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + [Migration(236)] + public class M0236_AddAdpAuditPg : Migration + { + public override void Up() + { + if (!Schema.Table("adpauditevents").Exists()) + Create.Table("adpauditevents") + .WithColumn("eventid").AsString(32).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("sequence").AsInt64().NotNullable() + .WithColumn("layer").AsString(32).NotNullable() + .WithColumn("operation").AsString(64).NotNullable() + .WithColumn("outcome").AsString(64).NotNullable() + .WithColumn("actorid").AsString(128).Nullable() + .WithColumn("correlationid").AsString(128).Nullable() + .WithColumn("resourceid").AsString(128).Nullable() + .WithColumn("policyepoch").AsInt64().NotNullable() + .WithColumn("occurredutc").AsCustom("timestamp").NotNullable() + .WithColumn("previoushash").AsString(64).NotNullable() + .WithColumn("hash").AsString(64).NotNullable(); + Execute.Sql("CREATE UNIQUE INDEX IF NOT EXISTS ux_adpaudit_sequence ON adpauditevents(departmentid, sequence);"); + if (!Schema.Table("adpaccessstates").Exists()) + Create.Table("adpaccessstates") + .WithColumn("stateid").AsString(200).NotNullable().PrimaryKey() + .WithColumn("json").AsString(int.MaxValue).NotNullable() + .WithColumn("version").AsInt64().NotNullable(); + } + + // Audit evidence must survive application rollback. + public override void Down() { } + } +} diff --git a/Providers/Resgrid.Providers.Number/OutboundVoiceProvider.cs b/Providers/Resgrid.Providers.Number/OutboundVoiceProvider.cs index e5b5b95dd..d43feb02a 100644 --- a/Providers/Resgrid.Providers.Number/OutboundVoiceProvider.cs +++ b/Providers/Resgrid.Providers.Number/OutboundVoiceProvider.cs @@ -45,6 +45,9 @@ public async Task CommunicateCallAsync(string phoneNumber, UserProfile pro // a voicemail simply records the dispatch prompt. var phoneCall = await CallResource.CreateAsync(options); + Resgrid.Model.AdminAssist.DispatchTraceTelemetry.ProviderResult( + Resgrid.Model.AdminAssist.DispatchTraceProvider.Twilio, Resgrid.Model.AdminAssist.DispatchTraceChannel.Voice, + phoneCall?.Sid, Resgrid.Model.AdminAssist.DispatchProviderOutcome.CreationStatus(phoneCall?.Status?.ToString())); return true; } } @@ -59,6 +62,9 @@ public async Task CommunicateCallAsync(string phoneNumber, UserProfile pro // No machine detection — see the mobile branch above. var phoneCall = await CallResource.CreateAsync(options); + Resgrid.Model.AdminAssist.DispatchTraceTelemetry.ProviderResult( + Resgrid.Model.AdminAssist.DispatchTraceProvider.Twilio, Resgrid.Model.AdminAssist.DispatchTraceChannel.Voice, + phoneCall?.Sid, Resgrid.Model.AdminAssist.DispatchProviderOutcome.CreationStatus(phoneCall?.Status?.ToString())); return true; } } diff --git a/Providers/Resgrid.Providers.Number/TextMessageProvider.cs b/Providers/Resgrid.Providers.Number/TextMessageProvider.cs index c0de3ebfc..140e50723 100644 --- a/Providers/Resgrid.Providers.Number/TextMessageProvider.cs +++ b/Providers/Resgrid.Providers.Number/TextMessageProvider.cs @@ -156,6 +156,10 @@ public async Task SendTextMessageViaTwillio(string number, string message, to: new PhoneNumber(number), body: message); + Resgrid.Model.AdminAssist.DispatchTraceTelemetry.ProviderResult( + Resgrid.Model.AdminAssist.DispatchTraceProvider.Twilio, Resgrid.Model.AdminAssist.DispatchTraceChannel.Sms, + messageResource?.Sid, Resgrid.Model.AdminAssist.DispatchProviderOutcome.CreationStatus(messageResource?.Status?.ToString())); + if (messageResource != null) return true; else @@ -211,6 +215,10 @@ public async Task SendTextMessageViaSignalWire(string number, string messa }); var response = await client.ExecuteAsync(request); + Resgrid.Model.AdminAssist.DispatchTraceTelemetry.ProviderResult( + Resgrid.Model.AdminAssist.DispatchTraceProvider.SignalWire, Resgrid.Model.AdminAssist.DispatchTraceChannel.Sms, + response.Data?.sid, response.ResponseStatus == ResponseStatus.Completed && response.StatusCode == HttpStatusCode.Created && response.Data != null && response.Data.error_code == null + ? Resgrid.Model.AdminAssist.DispatchProviderOutcome.CreationStatus(response.Data.status) : null); if (response.ResponseStatus == ResponseStatus.Completed) { diff --git a/Providers/Resgrid.Providers.ProtectedData/AuditedKeyWrappingProvider.cs b/Providers/Resgrid.Providers.ProtectedData/AuditedKeyWrappingProvider.cs new file mode 100644 index 000000000..ff65a8ac9 --- /dev/null +++ b/Providers/Resgrid.Providers.ProtectedData/AuditedKeyWrappingProvider.cs @@ -0,0 +1,62 @@ +using System; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; + +namespace Resgrid.Providers.ProtectedData +{ + /// Audits every broker-host KMS operation, including migration work outside the HTTP pipeline. + public sealed class AuditedKeyWrappingProvider(IKeyWrappingProvider inner, IAdpAuditRepository audit) : IKeyWrappingProvider + { + public string ProviderType => inner.ProviderType; + + public async Task GenerateWrappedDataKeyAsync(int departmentId, CancellationToken cancellationToken = default) + { + var id = Guid.NewGuid().ToString("N"); + await Record(departmentId, id, "generate-wrapped-key", "requested", cancellationToken); + try + { + var result = await inner.GenerateWrappedDataKeyAsync(departmentId, cancellationToken); + await Record(departmentId, id, "generate-wrapped-key", "completed", cancellationToken); + return result; + } + catch + { + await RecordFailure(departmentId, id, "generate-wrapped-key"); + throw; + } + } + + public async Task UnwrapDataKeyAsync(int departmentId, string wrappedKeyBase64, CancellationToken cancellationToken = default) + { + var id = Guid.NewGuid().ToString("N"); + await Record(departmentId, id, "unwrap-key", "requested", cancellationToken); + byte[] key = null; + try + { + key = await inner.UnwrapDataKeyAsync(departmentId, wrappedKeyBase64, cancellationToken); + await Record(departmentId, id, "unwrap-key", "completed", cancellationToken); + return key; + } + catch + { + if (key != null) CryptographicOperations.ZeroMemory(key); + await RecordFailure(departmentId, id, "unwrap-key"); + throw; + } + } + + private Task Record(int departmentId, string id, string operation, string outcome, CancellationToken ct) => + audit.AppendAsync(new AdpAuditEvent { DepartmentId = departmentId, Layer = "key-management", + Operation = operation, Outcome = outcome, CorrelationId = id }, ct); + + private async Task RecordFailure(int departmentId, string id, string operation) + { + try { await Record(departmentId, id, operation, "failed", CancellationToken.None); } + catch { /* Preserve the original failure; no key or successful result is returned. */ } + } + } +} diff --git a/Providers/Resgrid.Providers.ProtectedData/ProtectedDataBrokerClient.cs b/Providers/Resgrid.Providers.ProtectedData/ProtectedDataBrokerClient.cs index 098890ad7..298d88218 100644 --- a/Providers/Resgrid.Providers.ProtectedData/ProtectedDataBrokerClient.cs +++ b/Providers/Resgrid.Providers.ProtectedData/ProtectedDataBrokerClient.cs @@ -9,6 +9,8 @@ using Resgrid.Config; using Resgrid.Framework; using Resgrid.Model.Providers; +using Resgrid.Model; +using Resgrid.Model.Repositories; namespace Resgrid.Providers.ProtectedData { @@ -26,15 +28,17 @@ public class ProtectedDataBrokerClient : IProtectedDataBrokerClient, IDisposable internal const string BrokerUnavailableErrorCode = "broker_unavailable"; private readonly HttpClient _httpClient; + private readonly IAdpAuditRepository _audit; - public ProtectedDataBrokerClient() - : this(new HttpClientHandler()) + public ProtectedDataBrokerClient(IAdpAuditRepository audit) + : this(new HttpClientHandler(), audit) { } /// Test seam: inject a message handler. - public ProtectedDataBrokerClient(HttpMessageHandler handler) + public ProtectedDataBrokerClient(HttpMessageHandler handler, IAdpAuditRepository audit) { + _audit = audit; _httpClient = new HttpClient(handler, disposeHandler: true) { Timeout = TimeSpan.FromMilliseconds(DataProtectionConfig.BrokerTimeoutMs > 0 @@ -117,6 +121,18 @@ public Task DecryptForWorkloadAsync(int departmentId, private async Task SendAsync(string path, int departmentId, string grantToken, string requestId, IReadOnlyList items, CancellationToken cancellationToken) + { + await _audit.AppendAsync(new AdpAuditEvent { DepartmentId = departmentId, Layer = "application", + Operation = path.Contains("decrypt") ? "decrypt" : "encrypt", Outcome = "requested", CorrelationId = requestId }, cancellationToken); + var result = await SendCoreAsync(path, departmentId, grantToken, requestId, items, cancellationToken); + await _audit.AppendAsync(new AdpAuditEvent { DepartmentId = departmentId, Layer = "application", + Operation = path.Contains("decrypt") ? "decrypt" : "encrypt", Outcome = result.Success ? "completed" : "denied", + CorrelationId = requestId }, cancellationToken); + return result; + } + + private async Task SendCoreAsync(string path, int departmentId, string grantToken, + string requestId, IReadOnlyList items, CancellationToken cancellationToken) { // HTTPS-only, enforced per request: the payload carries the workload key, the grant and // protected field values. A non-HTTPS configuration fails closed here. diff --git a/Providers/Resgrid.Providers.ProtectedData/ProtectedDataBrokerClientModule.cs b/Providers/Resgrid.Providers.ProtectedData/ProtectedDataBrokerClientModule.cs index 1d172b4c6..03e04079a 100644 --- a/Providers/Resgrid.Providers.ProtectedData/ProtectedDataBrokerClientModule.cs +++ b/Providers/Resgrid.Providers.ProtectedData/ProtectedDataBrokerClientModule.cs @@ -13,7 +13,7 @@ public class ProtectedDataBrokerClientModule : Module { protected override void Load(ContainerBuilder builder) { - builder.RegisterType().As().SingleInstance(); + builder.RegisterType().As().InstancePerLifetimeScope(); } } } diff --git a/Providers/Resgrid.Providers.ProtectedData/ProtectedDataProviderModule.cs b/Providers/Resgrid.Providers.ProtectedData/ProtectedDataProviderModule.cs index 3ea740b1f..e079ea5f2 100644 --- a/Providers/Resgrid.Providers.ProtectedData/ProtectedDataProviderModule.cs +++ b/Providers/Resgrid.Providers.ProtectedData/ProtectedDataProviderModule.cs @@ -15,6 +15,7 @@ public class ProtectedDataProviderModule : Module { protected override void Load(ContainerBuilder builder) { + builder.RegisterDecorator(); if (string.Equals(DataProtectionConfig.KeyWrappingProviderType, "OpenBaoTransit", System.StringComparison.OrdinalIgnoreCase)) builder.RegisterType().As().SingleInstance(); } diff --git a/Providers/Resgrid.Providers.Workflow/Executors/ProtectedResponseRules.cs b/Providers/Resgrid.Providers.Workflow/Executors/ProtectedResponseRules.cs index 389b5b55a..7495f3d8b 100644 --- a/Providers/Resgrid.Providers.Workflow/Executors/ProtectedResponseRules.cs +++ b/Providers/Resgrid.Providers.Workflow/Executors/ProtectedResponseRules.cs @@ -266,7 +266,8 @@ private static string SelectXml(string body, string path) /// "MSA-2", "PID-3.1" or "PID-3.1.2": field, then optional component and subcomponent. private static string Hl7FieldReference(string body, string reference) { - var match = Regex.Match(reference ?? string.Empty, @"^(?[A-Z0-9]{3})-(?\d{1,3})(\.(?\d{1,3}))?(\.(?\d{1,3}))?$"); + // ASCII digits only ([0-9], not \d), so every int.Parse below is safe. + var match = Regex.Match(reference ?? string.Empty, @"^(?[A-Z0-9]{3})-(?[0-9]{1,3})(\.(?[0-9]{1,3}))?(\.(?[0-9]{1,3}))?$"); if (!match.Success) return null; diff --git a/Repositories/Resgrid.Repositories.DataRepository/ActionLogsRepository.AdministrativeEvidence.cs b/Repositories/Resgrid.Repositories.DataRepository/ActionLogsRepository.AdministrativeEvidence.cs new file mode 100644 index 000000000..d38c2c029 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/ActionLogsRepository.AdministrativeEvidence.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Dapper; +using Resgrid.Model; + +namespace Resgrid.Repositories.DataRepository +{ + public partial class ActionLogsRepository + { + public async Task> ReadLatestForAdministrationAsync(int departmentId, bool disableAutoAvailable, DateTime asOfUtc, int maximumRows, CancellationToken ct) + { + if (departmentId <= 0 || asOfUtc.Kind != DateTimeKind.Utc || maximumRows < 1 || maximumRows > 10000) throw new ArgumentException("Invalid administrative status scope."); + var postgres = Config.DataConfig.DatabaseType == Config.DatabaseTypes.Postgres; + string Q(string name) => postgres ? name.Trim('[', ']', '"').ToLowerInvariant() : "[" + name.Trim('[', ']', '"') + "]"; + string Table(string name) => Q(_sqlConfiguration.SchemaName) + "." + Q(name); + var columns = new[] { "ActionLogId", "UserId", "DepartmentId", "ActionTypeId", "Timestamp", "GeoLocationData" }; + var sql = $@"WITH selected AS ( +SELECT {string.Join(",", columns.Select(c => "al." + Q(c)))}, ROW_NUMBER() OVER (PARTITION BY al.{Q("UserId")} ORDER BY al.{Q("ActionLogId")} DESC) AS position +FROM {Table("ActionLogs")} al +INNER JOIN {Table("AspNetUsers")} u ON u.{Q("Id")}=al.{Q("UserId")} +INNER JOIN {Table("DepartmentMembers")} dm ON dm.{Q("UserId")}=al.{Q("UserId")} AND dm.{Q("DepartmentId")}=al.{Q("DepartmentId")} +WHERE al.{Q("DepartmentId")}=@DepartmentId AND dm.{Q("IsDeleted")}=@False AND dm.{Q("IsDisabled")}=@False AND dm.{Q("IsHidden")}=@False +AND al.{Q("Timestamp")}>=@Earliest AND (@DisableAutoAvailable=@True OR al.{Q("Timestamp")}>=@Threshold)) +SELECT {(postgres ? "" : "TOP (@Take) ")}{string.Join(",", columns.Select(Q))} FROM selected WHERE position=1 ORDER BY {Q("UserId")} {(postgres ? "LIMIT @Take" : "")}"; + DateTime Stamp(DateTime value) => postgres ? DateTime.SpecifyKind(value, DateTimeKind.Unspecified) : value; + var args = new { DepartmentId = departmentId, DisableAutoAvailable = disableAutoAvailable, False = false, True = true, + Earliest = Stamp(asOfUtc.AddYears(-1)), AsOfUtc = Stamp(asOfUtc), Threshold = Stamp(asOfUtc.AddHours(-1)), Take = maximumRows + 1 }; + var owns = _unitOfWork.Connection == null; + var connection = owns ? _connectionProvider.Create() : _unitOfWork.Connection; + try + { + if (owns) await connection.OpenAsync(ct); + var rows = (await connection.QueryAsync(new Dapper.CommandDefinition(sql, args, _unitOfWork.Transaction, commandTimeout: 20, cancellationToken: ct))).ToList(); + if (rows.Count > maximumRows) throw new InvalidOperationException("Administrative status row bound exceeded."); + return rows; + } + finally { if (owns) await connection.DisposeAsync(); } + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/ActionLogsRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/ActionLogsRepository.cs index 11d344dd1..fe36fba68 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/ActionLogsRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/ActionLogsRepository.cs @@ -15,7 +15,7 @@ namespace Resgrid.Repositories.DataRepository { - public class ActionLogsRepository : RepositoryBase, IActionLogsRepository + public partial class ActionLogsRepository : RepositoryBase, IActionLogsRepository { private readonly IConnectionProvider _connectionProvider; private readonly SqlConfiguration _sqlConfiguration; diff --git a/Repositories/Resgrid.Repositories.DataRepository/AdminAssistDepartmentCleanup.cs b/Repositories/Resgrid.Repositories.DataRepository/AdminAssistDepartmentCleanup.cs new file mode 100644 index 000000000..f98c5b187 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/AdminAssistDepartmentCleanup.cs @@ -0,0 +1,27 @@ +using System; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; +using Dapper; +using CommandDefinition = Dapper.CommandDefinition; +using Resgrid.Config; + +namespace Resgrid.Repositories.DataRepository +{ + /// Called only from an authorized department-deletion transaction, after the shared hold fence. + public static class AdminAssistDepartmentCleanup + { + public static async Task DeleteWithinTransactionAsync(DbConnection connection, DbTransaction transaction, int departmentId, DatabaseTypes type, CancellationToken ct = default) + { + if (departmentId <= 0 || transaction?.Connection != connection) throw new InvalidOperationException("A department-deletion transaction is required."); + var pg = type == DatabaseTypes.Postgres; + string Q(string value) => pg ? value.ToLowerInvariant() : "[" + value + "]"; + foreach (var table in new[] { "AdminAssistDispatchTraces", "AdminAssistFindings", "AdminAssistDailySummaries", "AdminAssistPreferences", "AdminAssistLearning", "AdminAssistHistory", "AdminAssistWorkerStates", "AdminAssistWorkspaces", "AdminAssistConfigurationRevisions" }) + { + var exists = await connection.ExecuteScalarAsync(new CommandDefinition(pg ? "SELECT CASE WHEN to_regclass(@Name) IS NULL THEN 0 ELSE 1 END" : "SELECT CASE WHEN OBJECT_ID(@Name,'U') IS NOT NULL THEN 1 WHEN HAS_PERMS_BY_NAME(DB_NAME(),'DATABASE','VIEW DEFINITION')=1 THEN 0 ELSE -1 END", new { Name = (pg ? "public." + table.ToLowerInvariant() : "dbo." + table) }, transaction, cancellationToken: ct)); + if (exists < 0) throw new InvalidOperationException("Cannot verify the Admin Assist cleanup schema."); + if (exists == 1) await connection.ExecuteAsync(new CommandDefinition($"DELETE FROM {Q(table)} WHERE {Q("DepartmentId")}=@DepartmentId", new { DepartmentId = departmentId }, transaction, cancellationToken: ct)); + } + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/AdminAssistRepository.Maintenance.cs b/Repositories/Resgrid.Repositories.DataRepository/AdminAssistRepository.Maintenance.cs new file mode 100644 index 000000000..cf1a386d4 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/AdminAssistRepository.Maintenance.cs @@ -0,0 +1,129 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Config; +using Resgrid.Model.AdminAssist; + +namespace Resgrid.Repositories.DataRepository +{ + public sealed partial class AdminAssistRepository + { + public async Task> GetDueDepartmentsAsync(DateTime nowUtc, int take, CancellationToken ct) => + (await QueryAsync($"SELECT w.{Col("DepartmentId")} FROM {Tbl("AdminAssistWorkspaces")} w INNER JOIN {Tbl("Departments")} d ON d.{Col("DepartmentId")}=w.{Col("DepartmentId")} " + + $"LEFT JOIN {Tbl("AdminAssistWorkerStates")} s ON s.{Col("DepartmentId")}=w.{Col("DepartmentId")} " + + $"WHERE (s.{Col("LeaseExpiresOn")} IS NULL OR s.{Col("LeaseExpiresOn")}<={P}Now) AND (s.{Col("LastAttemptOn")} IS NULL OR s.{Col("LastAttemptOn")}<{P}Due) " + + $"ORDER BY COALESCE(s.{Col("LastAttemptOn")},w.{Col("ModifiedOn")}),w.{Col("DepartmentId")} {Paging()}", + new { Now = DatabaseTimestamp(nowUtc), Due = DatabaseTimestamp(nowUtc.AddHours(-1)), Skip = 0, Take = Math.Clamp(take, 1, 50) }, ct)).ToArray(); + + private async Task MaintenanceTransactionAsync(int departmentId, Func> action, CancellationToken ct) + { + if (UnitOfWork.Transaction != null) throw new InvalidOperationException("Maintenance commands own their transaction."); + await UnitOfWork.CreateOrGetConnectionAsync(ct); + try { await LockConfigurationAsync(departmentId, ct); var result = await action(); UnitOfWork.CommitChanges(); return result; } + catch { UnitOfWork.DiscardChanges(); throw; } + } + public Task TryLeaseAsync(int departmentId, string lease, DateTime nowUtc, CancellationToken ct) => MaintenanceTransactionAsync(departmentId, async () => + { + var args = new { DepartmentId = departmentId, Lease = lease, Now = DatabaseTimestamp(nowUtc), Until = DatabaseTimestamp(nowUtc.AddMinutes(20)) }; + var exists = await ScalarAsync($"SELECT COUNT(*) FROM {Tbl("AdminAssistWorkerStates")} WHERE {Col("DepartmentId")}={P}DepartmentId", args, ct); + if (exists == 0) await ExecuteAsync($"INSERT INTO {Tbl("AdminAssistWorkerStates")} ({Col("DepartmentId")}) VALUES ({P}DepartmentId)", args, ct); + return await ExecuteAsync($"UPDATE {Tbl("AdminAssistWorkerStates")} SET {Col("LeaseOwner")}={P}Lease,{Col("LeaseExpiresOn")}={P}Until " + + $"WHERE {Col("DepartmentId")}={P}DepartmentId AND ({Col("LeaseExpiresOn")} IS NULL OR {Col("LeaseExpiresOn")}<={P}Now)", args, ct) == 1; + }, ct); + public async Task CompleteLeaseAsync(int departmentId, string lease, DateTime? evaluatedOn, CancellationToken ct) => + await MaintenanceTransactionAsync(departmentId, () => ExecuteAsync($"UPDATE {Tbl("AdminAssistWorkerStates")} SET {Col("LeaseOwner")}=NULL,{Col("LeaseExpiresOn")}=NULL," + + $"{Col("LastAttemptOn")}={P}AttemptOn,{Col("LastEvaluatedOn")}=COALESCE({P}EvaluatedOn,{Col("LastEvaluatedOn")}) WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("LeaseOwner")}={P}Lease", + new { DepartmentId = departmentId, Lease = lease, AttemptOn = DatabaseTimestamp(DateTime.UtcNow), EvaluatedOn = evaluatedOn.HasValue ? DatabaseTimestamp(evaluatedOn.Value) : (DateTime?)null }, ct), ct); + public async Task GetWorkerStatusAsync(int departmentId, CancellationToken ct) + { + var row = await QueryFirstOrDefaultAsync($"SELECT {Cols("LastEvaluatedOn", "LastDigestOn")} FROM {Tbl("AdminAssistWorkerStates")} WHERE {Col("DepartmentId")}={P}DepartmentId", new { DepartmentId = departmentId }, ct); + DateTime? Utc(DateTime? value) => value.HasValue ? DateTime.SpecifyKind(value.Value, DateTimeKind.Utc) : null; + return new AdminAssistWorkerStatus(Utc(row?.LastEvaluatedOn), Utc(row?.LastDigestOn)); + } + public async Task GetPreferencesAsync(int departmentId, string userId, CancellationToken ct) => + await QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("AdminAssistPreferences")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("UserId")}={P}UserId", new { DepartmentId = departmentId, UserId = userId }, ct) + ?? new AdminAssistPreferences { DepartmentId = departmentId, UserId = userId }; + public async Task> GetDigestPreferencesAsync(int departmentId, CancellationToken ct) + { + var cursor = await ScalarAsync($"SELECT {Col("DigestCursor")} FROM {Tbl("AdminAssistWorkerStates")} WHERE {Col("DepartmentId")}={P}DepartmentId", new { DepartmentId = departmentId }, ct); + async Task Page(string after) => (await QueryAsync( + $"SELECT * FROM {Tbl("AdminAssistPreferences")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("DigestEnabled")}={P}Enabled " + + $"AND ({P}After IS NULL OR {Col("UserId")}>{P}After) ORDER BY {Col("UserId")} {Paging()}", new { DepartmentId = departmentId, Enabled = true, After = after, Skip = 0, Take = 50 }, ct)).ToArray(); + var rows = await Page(cursor); + if (rows.Length == 0 && cursor != null) + { + await AdvanceDigestCursorAsync(departmentId, null, ct); + rows = await Page(null); + } + return rows; + } + public async Task AdvanceDigestCursorAsync(int departmentId, string userId, CancellationToken ct) + { + if (userId?.Length > 128) throw new ArgumentException("Invalid digest cursor."); + await MaintenanceTransactionAsync(departmentId, () => ExecuteAsync($"UPDATE {Tbl("AdminAssistWorkerStates")} SET {Col("DigestCursor")}={P}UserId WHERE {Col("DepartmentId")}={P}DepartmentId", + new { DepartmentId = departmentId, UserId = userId }, ct), ct); + } + public async Task SavePreferencesAsync(AdminAssistActor actor, AdminAssistPreferencesCommand command, CancellationToken ct) + { + if (command == null || command.ExpectedRevision < 0 || command.QuietStartHour is < 0 or > 23 || command.QuietEndHour is < 0 or > 23) throw new ArgumentException("Invalid preferences."); + await MaintenanceTransactionAsync(actor.DepartmentId, async () => + { + var current = await GetPreferencesAsync(actor.DepartmentId, actor.UserId, ct); + if (current.Revision != command.ExpectedRevision) throw new AdminAssistConcurrencyException(); + var args = new { actor.DepartmentId, actor.UserId, command.DigestEnabled, command.QuietStartHour, command.QuietEndHour, + Locale = (actor.Locale ?? "en").Split('-')[0], Revision = command.ExpectedRevision + 1 }; + if (current.Revision == 0) + await ExecuteAsync($"INSERT INTO {Tbl("AdminAssistPreferences")} ({Cols("DepartmentId", "UserId", "DigestEnabled", "QuietStartHour", "QuietEndHour", "Locale", "Revision")}) VALUES ({P}DepartmentId,{P}UserId,{P}DigestEnabled,{P}QuietStartHour,{P}QuietEndHour,{P}Locale,{P}Revision)", args, ct); + else await ExecuteAsync($"UPDATE {Tbl("AdminAssistPreferences")} SET {Col("DigestEnabled")}={P}DigestEnabled,{Col("QuietStartHour")}={P}QuietStartHour,{Col("QuietEndHour")}={P}QuietEndHour,{Col("Locale")}={P}Locale,{Col("Revision")}={P}Revision WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("UserId")}={P}UserId", args, ct); + return true; + }, ct); + } + public Task ClaimDigestAsync(AdminAssistPreferences preference, string week, DateTime nowUtc, CancellationToken ct) => MaintenanceTransactionAsync(preference.DepartmentId, async () => + { + // Reserve before provider handoff. Ambiguous provider failures are never automatically resent. + var args = new { preference.DepartmentId, preference.UserId, preference.Revision, Week = week, Now = DatabaseTimestamp(nowUtc), Enabled = true }; + return await ExecuteAsync($"UPDATE {Tbl("AdminAssistPreferences")} SET {Col("LastAttemptWeek")}={P}Week,{Col("LastAttemptOn")}={P}Now,{Col("LastAttemptOutcome")}='HandoffUnconfirmed' " + + $"WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("UserId")}={P}UserId AND {Col("Revision")}={P}Revision AND {Col("DigestEnabled")}={P}Enabled AND ({Col("LastAttemptWeek")} IS NULL OR {Col("LastAttemptWeek")}<>{P}Week)", args, ct) == 1; + }, ct); + public async Task CompleteDigestAsync(int departmentId, string userId, string week, string outcome, DateTime nowUtc, CancellationToken ct) + { + if (outcome != "HandedOff" && outcome != "HandoffUnconfirmed" && outcome != "Suppressed") throw new ArgumentException("Invalid digest outcome."); + await MaintenanceTransactionAsync(departmentId, async () => + { + var args = new { DepartmentId = departmentId, UserId = userId, Week = week, Outcome = outcome, Now = DatabaseTimestamp(nowUtc) }; + await ExecuteAsync($"UPDATE {Tbl("AdminAssistPreferences")} SET {Col("LastAttemptOutcome")}={P}Outcome WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("UserId")}={P}UserId AND {Col("LastAttemptWeek")}={P}Week", args, ct); + return await ExecuteAsync($"UPDATE {Tbl("AdminAssistWorkerStates")} SET {Col("LastDigestOn")}={P}Now WHERE {Col("DepartmentId")}={P}DepartmentId", args, ct); + }, ct); + } + public Task PurgeExpiredMetadataAsync(int departmentId, DateTime nowUtc, CancellationToken ct) => MaintenanceTransactionAsync(departmentId, async () => + { + // The owning RMS hold fence takes the same department lock. Conservatively retain all derived + // evidence while any department hold is active, including encrypted hold membership. + if (await ScalarAsync($"SELECT COUNT(*) FROM {Tbl("RmsRecordLegalHolds")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("ReleasedOn")} IS NULL", new { DepartmentId = departmentId }, ct) > 0) return 0; + var deleted = 0; + Task DeleteWhere(string table, string where, object args) + { + var sql = IsPostgres ? $"DELETE FROM {Tbl(table)} WHERE ctid IN (SELECT ctid FROM {Tbl(table)} WHERE {where} LIMIT 500)" : $"DELETE TOP (500) FROM {Tbl(table)} WHERE {where}"; + return ExecuteAsync(sql, args, ct); + } + foreach (var (table, column, days) in new[] { ("AdminAssistDailySummaries", "DayUtc", AdminAssistConfig.AggregateRetentionDays), ("AdminAssistLearning", "ModifiedOn", AdminAssistConfig.PersonalLearningRetentionDays), ("AdminAssistDispatchTraces", "OccurredOn", AdminAssistConfig.TraceRetentionDays) }) + { + var where = $"{Col("DepartmentId")}={P}DepartmentId AND {Col(column)}<{P}Before"; + deleted += await DeleteWhere(table, where, new { DepartmentId = departmentId, Before = DatabaseTimestamp(nowUtc.AddDays(-Math.Clamp(days, 1, 3650))) }); + } + var personalActions = $"{Col("Action")} IN ('learn','interest','dismiss')"; + deleted += await DeleteWhere("AdminAssistHistory", $"{Col("DepartmentId")}={P}DepartmentId AND {personalActions} AND {Col("OccurredOnUtc")}<{P}Before", + new { DepartmentId = departmentId, Before = DatabaseTimestamp(nowUtc.AddDays(-Math.Clamp(AdminAssistConfig.PersonalLearningRetentionDays, 1, 3650))) }); + foreach (var (table, userColumn) in new[] { ("AdminAssistLearning", "UserId"), ("AdminAssistPreferences", "UserId"), ("AdminAssistHistory", "ActorId") }) + { + var where = $"{Col("DepartmentId")}={P}DepartmentId AND " + (table == "AdminAssistHistory" ? personalActions + " AND " : "") + + $"NOT EXISTS (SELECT 1 FROM {Tbl("DepartmentMembers")} m WHERE m.{Col("DepartmentId")}={P}DepartmentId AND m.{Col("UserId")}={Tbl(table)}.{Col(userColumn)} AND m.{Col("IsDeleted")}={P}NotDeleted)"; + deleted += await DeleteWhere(table, where, new { DepartmentId = departmentId, NotDeleted = false }); + } + return deleted; + }, ct); + private sealed class WorkerDates { public DateTime? LastEvaluatedOn { get; set; } public DateTime? LastDigestOn { get; set; } } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/AdminAssistRepository.ModuleImpact.cs b/Repositories/Resgrid.Repositories.DataRepository/AdminAssistRepository.ModuleImpact.cs new file mode 100644 index 000000000..2a7a1f79a --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/AdminAssistRepository.ModuleImpact.cs @@ -0,0 +1,28 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.AdminAssist; + +namespace Resgrid.Repositories.DataRepository +{ + public sealed partial class AdminAssistRepository + { + public async Task ReadModuleImpactCountsAsync(int departmentId, string module, int bound, CancellationToken ct) + { + if (departmentId <= 0 || bound < 1 || bound > 10000) throw new ArgumentException("Invalid preview bounds."); + // Table identifiers are server-authored; no catalog binding or caller text is executed as SQL. + var table = module switch { "Messaging" => "Messages", "Shifts" => "Shifts", "Documents" => "Documents", + "Calendar" => "CalendarItems", "Notes" => "Notes", "Training" => "Trainings", + "Mapping" or "Reports" or "Logs" or "Inventory" => null, _ => throw new ArgumentException("Unsupported module.") }; + async Task Count(string source, string additional = "") + { + var select = $"SELECT {(IsPostgres ? "" : "TOP (" + P + "Take) ")}1 AS n FROM {Tbl(source)} WHERE {Col("DepartmentId")}={P}DepartmentId {additional}{(IsPostgres ? " LIMIT " + P + "Take" : "")}"; + var count = await ScalarAsync($"SELECT COUNT(*) FROM ({select}) sample", new { DepartmentId = departmentId, Take = bound + 1, False = false }, ct); + if (count > bound) throw new InvalidOperationException("Module preview row bound exceeded."); + return count; + } + var people = await Count("DepartmentMembers", $"AND {Col("IsDeleted")}={P}False AND ({Col("IsDisabled")} IS NULL OR {Col("IsDisabled")}={P}False) AND NULLIF(LTRIM(RTRIM({Col("UserId")})),'') IS NOT NULL"); + return new(people, table == null ? null : await Count(table)); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/AdminAssistRepository.NotificationImpact.cs b/Repositories/Resgrid.Repositories.DataRepository/AdminAssistRepository.NotificationImpact.cs new file mode 100644 index 000000000..bc0f88681 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/AdminAssistRepository.NotificationImpact.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.AdminAssist; + +namespace Resgrid.Repositories.DataRepository +{ + public sealed partial class AdminAssistRepository + { + public async Task> ReadNotificationMembersAsync(int departmentId, int bound, CancellationToken ct) + { + if (departmentId <= 0 || bound < 1 || bound > 10000) throw new ArgumentException("Invalid preview bounds."); + // The production staffing getter selects the latest global row by ID. Read a state only if that + // row belongs to this department; otherwise mark it unknown rather than reading another tenant's state. + var sql = $"SELECT {(IsPostgres ? "" : "TOP (" + P + "Take) ")}m.{Col("DepartmentId")}, m.{Col("DepartmentMemberId")} AS {Col("MemberId")}, m.{Col("UserId")}, " + + $"p.{Col("UserProfileId")} AS {Col("ProfileId")}, p.{Col("SendNotificationSms")} AS {Col("Sms")}, p.{Col("MobileNumberVerified")} AS {Col("MobileVerified")}, " + + $"p.{Col("SendNotificationEmail")} AS {Col("Email")}, p.{Col("EmailVerified")}, p.{Col("SendNotificationPush")} AS {Col("Push")}, " + + $"CASE WHEN s.{Col("UserStateId")} IS NULL OR s.{Col("DepartmentId")}={P}DepartmentId THEN 1 ELSE 0 END AS {Col("StaffingKnown")}, " + + $"CASE WHEN s.{Col("UserStateId")} IS NULL THEN 0 WHEN s.{Col("DepartmentId")}={P}DepartmentId THEN s.{Col("State")} ELSE NULL END AS {Col("Staffing")} " + + $"FROM {Tbl("DepartmentMembers")} m LEFT JOIN {Tbl("UserProfiles")} p ON p.{Col("UserId")}=m.{Col("UserId")} " + + $"LEFT JOIN {Tbl("UserStates")} s ON s.{Col("UserStateId")}=(SELECT MAX(latest.{Col("UserStateId")}) FROM {Tbl("UserStates")} latest WHERE latest.{Col("UserId")}=m.{Col("UserId")}) " + + $"WHERE m.{Col("DepartmentId")}={P}DepartmentId AND m.{Col("IsDeleted")}={P}False AND (m.{Col("IsDisabled")} IS NULL OR m.{Col("IsDisabled")}={P}False) " + + $"AND NULLIF(LTRIM(RTRIM(m.{Col("UserId")})),'') IS NOT NULL ORDER BY m.{Col("DepartmentMemberId")}, p.{Col("UserProfileId")}{(IsPostgres ? " LIMIT " + P + "Take" : "")}"; + return (await QueryAsync(sql, new { DepartmentId = departmentId, Take = bound + 1, False = false }, ct)).ToList(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/AdminAssistRepository.OperatingProfile.cs b/Repositories/Resgrid.Repositories.DataRepository/AdminAssistRepository.OperatingProfile.cs new file mode 100644 index 000000000..25b79e2fd --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/AdminAssistRepository.OperatingProfile.cs @@ -0,0 +1,33 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.AdminAssist; + +namespace Resgrid.Repositories.DataRepository +{ + public sealed partial class AdminAssistRepository + { + public async Task ValidateOperatingProfileReferencesAsync(int departmentId, DepartmentOperatingProfile profile, DateTime asOfUtc, CancellationToken ct) + { + if (UnitOfWork.Transaction == null) throw new InvalidOperationException("Profile validation requires a transaction."); + var groups = profile.SiteGroupReferences; + var documents = profile.StaffingPolicyReferences.Concat(profile.QualificationPolicyReferences).Concat(profile.ContinuityProcedureReferences).ToArray(); + if (groups.Count > 25 || documents.Length > 75) return false; + foreach (var reference in groups.Distinct(StringComparer.Ordinal)) + { + if (!int.TryParse(reference, NumberStyles.None, CultureInfo.InvariantCulture, out var id) || id <= 0) return false; + if (await ScalarAsync($"SELECT COUNT(*) FROM {Tbl("DepartmentGroups")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("DepartmentGroupId")}={P}Id", + new { DepartmentId = departmentId, Id = id }, ct) != 1) return false; + } + foreach (var reference in documents.Distinct(StringComparer.Ordinal)) + { + if (!int.TryParse(reference, NumberStyles.None, CultureInfo.InvariantCulture, out var id) || id <= 0) return false; + if (await ScalarAsync($"SELECT COUNT(*) FROM {Tbl("Documents")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("DocumentId")}={P}Id AND ({Col("RemoveOn")} IS NULL OR {Col("RemoveOn")}>{P}AsOfUtc)", + new { DepartmentId = departmentId, Id = id, AsOfUtc = DatabaseTimestamp(asOfUtc) }, ct) != 1) return false; + } + return true; + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/AdminAssistRepository.References.cs b/Repositories/Resgrid.Repositories.DataRepository/AdminAssistRepository.References.cs new file mode 100644 index 000000000..172b839f7 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/AdminAssistRepository.References.cs @@ -0,0 +1,34 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.AdminAssist; + +namespace Resgrid.Repositories.DataRepository +{ + public sealed partial class AdminAssistRepository + { + private sealed class AdministrativeDocument + { + public int DocumentId { get; set; } + public DateTime? RemoveOn { get; set; } + } + public async Task ReadAdministrativeReferencesAsync(int departmentId, int[] documentIds, int[] groupIds, DateTime asOfUtc, CancellationToken ct) + { + if (departmentId <= 0 || documentIds == null || groupIds == null || documentIds.Length > 75 || groupIds.Length > 25 || documentIds.Concat(groupIds).Any(id => id <= 0)) throw new ArgumentException("Invalid reference projection."); + documentIds = documentIds.Distinct().ToArray(); groupIds = groupIds.Distinct().ToArray(); + var documents = documentIds.Length == 0 ? Array.Empty() : + (await QueryAsync($"SELECT {Cols("DocumentId", "RemoveOn")} FROM {Tbl("Documents")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("DocumentId")} IN ({string.Join(",", documentIds.Select((_, i) => P + "D" + i))})", Parameters(departmentId, "D", documentIds), ct)).ToArray(); + var groups = groupIds.Length == 0 ? Array.Empty() : + (await QueryAsync($"SELECT {Col("DepartmentGroupId")} FROM {Tbl("DepartmentGroups")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("DepartmentGroupId")} IN ({string.Join(",", groupIds.Select((_, i) => P + "G" + i))})", Parameters(departmentId, "G", groupIds), ct)).ToArray(); + return new(documentIds.Length, documentIds.Length - documents.Count(d => !d.RemoveOn.HasValue || d.RemoveOn > asOfUtc), + documents.Count(d => d.RemoveOn > asOfUtc && d.RemoveOn <= asOfUtc.AddDays(30)), groupIds.Length, groupIds.Length - groups.Distinct().Count()); + } + private static Dapper.DynamicParameters Parameters(int departmentId, string prefix, int[] ids) + { + var parameters = new Dapper.DynamicParameters(); parameters.Add("DepartmentId", departmentId); + for (int i = 0; i < ids.Length; i++) parameters.Add(prefix + i, ids[i]); + return parameters; + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/AdminAssistRepository.RetentionImpact.cs b/Repositories/Resgrid.Repositories.DataRepository/AdminAssistRepository.RetentionImpact.cs new file mode 100644 index 000000000..ff6905060 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/AdminAssistRepository.RetentionImpact.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.AdminAssist; + +namespace Resgrid.Repositories.DataRepository +{ + public sealed partial class AdminAssistRepository + { + public async Task> ReadRetentionHeadersAsync(int departmentId, int bound, CancellationToken ct) + { + if (departmentId <= 0 || bound is < 1 or > 250) throw new ArgumentException("Invalid retention preview bounds."); + var result = new List(); + foreach (var kind in new[] { RmsRecordKind.Operational, RmsRecordKind.IncidentReport }) + { + var table = kind == RmsRecordKind.Operational ? "RmsOperationalRecords" : "RmsIncidentReports"; + var id = kind == RmsRecordKind.Operational ? "RmsOperationalRecordId" : "RmsIncidentReportId"; + // All hold rows and content bodies stay in the owning database. Historical/child coverage is conservative. + var directHold = $"EXISTS (SELECT 1 FROM {Tbl("RmsRecordLegalHolds")} h WHERE h.{Col("DepartmentId")}=r.{Col("DepartmentId")} AND h.{Col("ReleasedOn")} IS NULL AND h.{Col("RecordId")}=r.{Col(id)})"; + var stickyHold = $"EXISTS (SELECT 1 FROM {Tbl("RmsRecordLegalHoldMembers")} m JOIN {Tbl("RmsRecordLegalHolds")} h ON h.{Col("DepartmentId")}=m.{Col("DepartmentId")} AND h.{Col("RmsRecordLegalHoldId")}=m.{Col("HoldId")} WHERE m.{Col("DepartmentId")}=r.{Col("DepartmentId")} AND m.{Col("RecordId")}=r.{Col(id)} AND h.{Col("ReleasedOn")} IS NULL)"; + var permanent = string.Join(" OR ", new[] { "RmsCasualtyRescues", "RmsExposures" }.Select(t => $"EXISTS (SELECT 1 FROM {Tbl(t)} p WHERE p.{Col("DepartmentId")}=r.{Col("DepartmentId")} AND p.{Col("RecordId")}=r.{Col(id)})")); + // An applicable period hold might cover a historical date. Do not read protected historical JSON or call it clear. + var historicalHold = $"EXISTS (SELECT 1 FROM {Tbl("RmsRecordLegalHolds")} h WHERE h.{Col("DepartmentId")}=r.{Col("DepartmentId")} AND h.{Col("ReleasedOn")} IS NULL AND h.{Col("RecordId")} IS NULL AND (h.{Col("DefinitionKey")} IS NULL OR h.{Col("DefinitionKey")}=r.{Col("DefinitionKey")}))"; + var select = $"SELECT {(IsPostgres ? "" : "TOP (" + P + "Take) ")}r.{Col(id)} AS {Col("RecordId")}, {(int)kind} AS {Col("Kind")}, {string.Join(",", new[] { "DefinitionKey", "State", "FinalizedOn", "ModifiedOn", "AmendsRevisionId", "RowVersion" }.Select(c => "r." + Col(c)))}, CASE WHEN {directHold} OR {stickyHold} OR {permanent} THEN 1 ELSE 0 END AS {Col("HoldOrPermanentContent")}, CASE WHEN {historicalHold} THEN 1 ELSE 0 END AS {Col("HistoricalHoldUncertainty")} FROM {Tbl(table)} r WHERE r.{Col("DepartmentId")}={P}DepartmentId AND r.{Col("PurgedOn")} IS NULL ORDER BY r.{Col(id)}{(IsPostgres ? " LIMIT " + P + "Take" : "")}"; + result.AddRange(await QueryAsync(select, new { DepartmentId = departmentId, Take = bound + 1 }, ct)); + } + return result; + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/AdminAssistRepository.SecurityImpact.cs b/Repositories/Resgrid.Repositories.DataRepository/AdminAssistRepository.SecurityImpact.cs new file mode 100644 index 000000000..d259f0530 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/AdminAssistRepository.SecurityImpact.cs @@ -0,0 +1,34 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.AdminAssist; + +namespace Resgrid.Repositories.DataRepository +{ + public sealed partial class AdminAssistRepository + { + public async Task ReadSecurityPolicyAsync(int departmentId, CancellationToken ct) + { + if (departmentId <= 0) throw new ArgumentException("Invalid department."); + var rows = (await QueryAsync($"SELECT {(IsPostgres ? "" : "TOP (2) ")}{Cols("DepartmentId", "RequireMfa", "RequireSso", "SessionTimeoutMinutes", "MaxConcurrentSessions", "PasswordExpirationDays", "MinPasswordLength")} " + + $"FROM {Tbl("DepartmentSecurityPolicies")} WHERE {Col("DepartmentId")}={P}DepartmentId{(IsPostgres ? " LIMIT 2" : "")}", new { DepartmentId = departmentId }, ct)).ToList(); + return rows.SingleOrDefault(); + } + public async Task ReadSecurityImpactAsync(int departmentId, DateTime nowUtc, int bound, CancellationToken ct) + { + if (departmentId <= 0 || bound < 1 || bound > 10000) throw new ArgumentException("Invalid preview bounds."); + var top = IsPostgres ? "" : "TOP (" + P + "Take) "; var limit = IsPostgres ? " LIMIT " + P + "Take" : ""; + var args = new { DepartmentId = departmentId, Take = bound + 1, False = false, True = true, Now = DatabaseTimestamp(nowUtc), Active = (int)UserSessionState.Active }; + var current = $"m.{Col("DepartmentId")}={P}DepartmentId AND m.{Col("IsDeleted")}={P}False AND (m.{Col("IsDisabled")} IS NULL OR m.{Col("IsDisabled")}={P}False) AND NULLIF(LTRIM(RTRIM(m.{Col("UserId")})),'') IS NOT NULL"; + var members = (await QueryAsync($"SELECT {top}m.{Col("DepartmentId")},m.{Col("DepartmentMemberId")} AS {Col("MemberId")},m.{Col("UserId")},m.{Col("PasswordLastSetOn")},u.{Col("TwoFactorEnabled")} " + + $"FROM {Tbl("DepartmentMembers")} m LEFT JOIN {Tbl("AspNetUsers")} u ON u.{Col("Id")}=m.{Col("UserId")} WHERE {current} ORDER BY m.{Col("DepartmentMemberId")}{limit}", args, ct)).ToList(); + var sessions = (await QueryAsync($"SELECT {top}s.{Col("DepartmentId")},s.{Col("UserSessionId")} AS {Col("Id")},s.{Col("UserId")},s.{Col("CreatedOn")},s.{Col("LastActiveOn")},s.{Col("ExpiresOn")},s.{Col("AuthenticationGeneration")},u.{Col("AuthenticationGeneration")} AS {Col("CurrentGeneration")} " + + $"FROM {Tbl("UserSessions")} s LEFT JOIN {Tbl("AspNetUsers")} u ON u.{Col("Id")}=s.{Col("UserId")} WHERE s.{Col("DepartmentId")}={P}DepartmentId AND s.{Col("State")}={P}Active AND s.{Col("ExpiresOn")}>{P}Now " + + $"AND EXISTS (SELECT 1 FROM {Tbl("DepartmentMembers")} m WHERE m.{Col("UserId")}=s.{Col("UserId")} AND {current}) ORDER BY s.{Col("UserSessionId")}{limit}", args, ct)).ToList(); + var providers = await ScalarAsync($"SELECT COUNT(*) FROM (SELECT {top}1 AS n FROM {Tbl("DepartmentSsoConfigs")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("IsEnabled")}={P}True{limit}) sample", args, ct); + return new(members, sessions, providers); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/AdminAssistRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/AdminAssistRepository.cs new file mode 100644 index 000000000..8423666de --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/AdminAssistRepository.cs @@ -0,0 +1,219 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository +{ + /// Tenant-keyed metadata only. Workspace revision serializes competing setup and learning commands. + public sealed partial class AdminAssistRepository : RmsRepositoryBase, IAdminAssistRepository, IAdminAssistMaintenanceStore, IAdminAssistTraceStore, IModuleImpactStore, IAdministrativeReferenceStore, IRetentionImpactStore, INotificationImpactStore, ISecurityImpactStore + { + public AdminAssistRepository(IConnectionProvider connections, SqlConfiguration configuration, IUnitOfWork unit, + IQueryFactory queries) : base(connections, configuration, unit, queries) { } + + public async Task LockConfigurationAsync(int departmentId, CancellationToken ct) + { + if (UnitOfWork.Transaction == null) throw new InvalidOperationException("Configuration audit requires a transaction."); + var sql = IsPostgres + ? $"SELECT {Col("DepartmentId")} FROM {Tbl("Departments")} WHERE {Col("DepartmentId")}={P}DepartmentId FOR UPDATE" + : $"SELECT {Col("DepartmentId")} FROM {Tbl("Departments")} WITH (UPDLOCK,HOLDLOCK) WHERE {Col("DepartmentId")}={P}DepartmentId"; + if (await ScalarAsync(sql, new { DepartmentId = departmentId }, ct) != departmentId) throw new InvalidOperationException("Configuration department unavailable."); + } + + public async Task TraceDepartmentExistsAsync(int departmentId, CancellationToken ct) => + await ScalarAsync($"SELECT COUNT(*) FROM {Tbl("Departments")} WHERE {Col("DepartmentId")}={P}DepartmentId", new { DepartmentId = departmentId }, ct) == 1; + + public async Task SaveTraceAsync(AdminAssistDispatchTraceRow row, CancellationToken ct) => + await MaintenanceTransactionAsync(row.DepartmentId, async () => + { + var existingDepartment = await ScalarAsync($"SELECT {Col("DepartmentId")} FROM {Tbl("AdminAssistDispatchTraces")} WHERE {Col("AdminAssistDispatchTraceId")}={P}Id", new { Id = row.AdminAssistDispatchTraceId }, ct); + if (existingDepartment.HasValue) + { + if (existingDepartment != row.DepartmentId) throw new UnauthorizedAccessException(); + return 0; + } + return await ExecuteAsync($"INSERT INTO {Tbl("AdminAssistDispatchTraces")} ({Cols("AdminAssistDispatchTraceId", "DepartmentId", "CallId", "AttemptId", "Stage", "ResolverVersion", "OccurredOn", "Content", "IsProtected", "ProtectedCatalogVersion")}) " + + $"VALUES ({P}AdminAssistDispatchTraceId,{P}DepartmentId,{P}CallId,{P}AttemptId,{P}Stage,{P}ResolverVersion,{P}OccurredOn,{P}Content,{P}IsProtected,{P}ProtectedCatalogVersion)", + new { row.AdminAssistDispatchTraceId, row.DepartmentId, row.CallId, row.AttemptId, row.Stage, row.ResolverVersion, + OccurredOn = DatabaseTimestamp(row.OccurredOn), row.Content, row.IsProtected, row.ProtectedCatalogVersion }, ct); + }, ct); + + public async Task AppendConfigurationChangeAsync(int departmentId, string actorId, string binding, string before, string after, string correlationId, CancellationToken ct) + { + if (UnitOfWork.Transaction == null) throw new InvalidOperationException("Configuration audit requires a transaction."); + var revision = await GetConfigurationRevisionAsync(departmentId, ct) + 1; + var args = new { DepartmentId = departmentId, Revision = revision, ModifiedOn = DatabaseTimestamp(DateTime.UtcNow) }; + if (await ExecuteAsync($"UPDATE {Tbl("AdminAssistConfigurationRevisions")} SET {Col("Revision")}={P}Revision,{Col("ModifiedOn")}={P}ModifiedOn WHERE {Col("DepartmentId")}={P}DepartmentId", args, ct) == 0) + await ExecuteAsync($"INSERT INTO {Tbl("AdminAssistConfigurationRevisions")} ({Cols("DepartmentId", "Revision", "ModifiedOn")}) VALUES ({P}DepartmentId,{P}Revision,{P}ModifiedOn)", args, ct); + await ExecuteAsync($"INSERT INTO {Tbl("AdminAssistHistory")} ({Cols("AdminAssistHistoryId", "DepartmentId", "ActorId", "OccurredOnUtc", "Source", "Action", "SubjectId", "BeforeCode", "AfterCode", "Revision", "CorrelationId")}) " + + $"VALUES ({P}Id,{P}DepartmentId,{P}ActorId,{P}OccurredOnUtc,{P}Source,{P}Action,{P}SubjectId,{P}BeforeCode,{P}AfterCode,{P}Revision,{P}CorrelationId)", + new { Id = Guid.NewGuid().ToString("D"), DepartmentId = departmentId, ActorId = actorId, OccurredOnUtc = args.ModifiedOn, Source = "Configuration", Action = "Changed", SubjectId = binding, BeforeCode = before, AfterCode = after, Revision = revision, CorrelationId = correlationId }, ct); + return revision; + } + + public Task GetConfigurationRevisionAsync(int departmentId, CancellationToken ct) => + ScalarAsync($"SELECT COALESCE(MAX({Col("Revision")}),0) FROM {Tbl("AdminAssistConfigurationRevisions")} WHERE {Col("DepartmentId")}={P}DepartmentId", new { DepartmentId = departmentId }, ct); + + public async Task> GetFindingsAsync(int departmentId, CancellationToken ct) => + (await QueryAsync($"SELECT * FROM {Tbl("AdminAssistFindings")} WHERE {Col("DepartmentId")}={P}DepartmentId ORDER BY {Col("RuleId")}", new { DepartmentId = departmentId }, ct)).ToList(); + + public async Task SaveFindingAsync(AdminAssistFindingRow row, long expectedRevision, CancellationToken ct) + { + if (UnitOfWork.Transaction == null) throw new InvalidOperationException("Finding changes require a transaction."); + var names = new[] { "AdminAssistFindingId", "DepartmentId", "RuleId", "SubjectId", "Episode", "Result", "Severity", "ReviewStatus", "OwnerId", "ReviewOn", "ExceptionUntil", "Content", "IsProtected", "ProtectedCatalogVersion", "SnapshotRevision", "Revision", "FirstObservedOn", "LastObservedOn" }; + var args = new { row.AdminAssistFindingId, row.DepartmentId, row.RuleId, row.SubjectId, row.Episode, row.Result, row.Severity, row.ReviewStatus, row.OwnerId, + ReviewOn = row.ReviewOn.HasValue ? DatabaseTimestamp(row.ReviewOn.Value) : (DateTime?)null, + ExceptionUntil = row.ExceptionUntil.HasValue ? DatabaseTimestamp(row.ExceptionUntil.Value) : (DateTime?)null, + row.Content, row.IsProtected, row.ProtectedCatalogVersion, row.SnapshotRevision, row.Revision, + FirstObservedOn = DatabaseTimestamp(row.FirstObservedOn), LastObservedOn = DatabaseTimestamp(row.LastObservedOn), ExpectedRevision = expectedRevision }; + if (expectedRevision == 0) + await ExecuteAsync($"INSERT INTO {Tbl("AdminAssistFindings")} ({Cols(names)}) VALUES ({string.Join(",", names.Select(n => P + n))})", args, ct); + else if (await ExecuteAsync($"UPDATE {Tbl("AdminAssistFindings")} SET {string.Join(",", names.Skip(4).Select(n => Col(n) + "=" + P + n))} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("AdminAssistFindingId")}={P}AdminAssistFindingId AND {Col("Revision")}={P}ExpectedRevision", args, ct) != 1) + throw new AdminAssistConcurrencyException(); + } + + public async Task SaveDailySummaryAsync(int departmentId, ConfigurationReport report, string catalogVersion, CancellationToken ct) + { + if (UnitOfWork.Transaction == null) throw new InvalidOperationException("Summary changes require a transaction."); + var args = new { DepartmentId = departmentId, DayUtc = DatabaseTimestamp(report.Snapshot.AsOfUtc.Date), FailedCount = report.Failed, UnknownCount = report.Unknown, EvaluatedCount = report.Required - report.Unknown, CatalogVersion = catalogVersion }; + if (await ExecuteAsync($"UPDATE {Tbl("AdminAssistDailySummaries")} SET {Col("FailedCount")}={P}FailedCount,{Col("UnknownCount")}={P}UnknownCount,{Col("EvaluatedCount")}={P}EvaluatedCount,{Col("CatalogVersion")}={P}CatalogVersion WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("DayUtc")}={P}DayUtc", args, ct) == 0) + await ExecuteAsync($"INSERT INTO {Tbl("AdminAssistDailySummaries")} ({Cols("DepartmentId", "DayUtc", "FailedCount", "UnknownCount", "EvaluatedCount", "CatalogVersion")}) VALUES ({P}DepartmentId,{P}DayUtc,{P}FailedCount,{P}UnknownCount,{P}EvaluatedCount,{P}CatalogVersion)", args, ct); + } + + public async Task GetWorkspaceAsync(int departmentId, string userId, string catalogVersion, CancellationToken ct) + { + var row = await ReadRowAsync(departmentId, ct); + var state = SetupWorkspaceMetadata.Read(row?.AreasJson); + var personal = await QueryAsync($"SELECT {Cols("CapabilityId", "Learned", "Interested")} FROM {Tbl("AdminAssistLearning")} " + + $"WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("UserId")}={P}UserId AND {Col("CatalogVersion")}={P}CatalogVersion", + new { DepartmentId = departmentId, UserId = userId, CatalogVersion = catalogVersion }, ct); + return new SetupWorkspace(departmentId, row?.Revision ?? 0, (SetupMode)(row?.Mode ?? 0), + state.Areas, personal.Where(p => p.Learned && p.CapabilityId != "setup-prompt").Select(p => p.CapabilityId).ToArray(), + personal.Where(p => p.Interested && p.CapabilityId != "setup-prompt").Select(p => p.CapabilityId).ToArray(), catalogVersion, + row?.ReviewedOn.HasValue == true ? DateTime.SpecifyKind(row.ReviewedOn.Value, DateTimeKind.Utc) : null, + personal.Any(p => p.CapabilityId == "setup-prompt" && p.Learned), state.AreaReasons, state.ReviewEvidence, state.RevisitOnUtc, state.ScopeRevision); + } + + public async Task UpdateWorkspaceAsync(AdminAssistActor actor, SetupProgressCommand command, CancellationToken ct) + { + var owns = UnitOfWork.Transaction == null; + await UnitOfWork.CreateOrGetConnectionAsync(ct); + try + { + await LockConfigurationAsync(actor.DepartmentId, ct); + var now = DatabaseTimestamp(DateTime.UtcNow); + var row = await ReadRowAsync(actor.DepartmentId, ct); + if ((row?.Revision ?? 0) != command.ExpectedRevision) throw new AdminAssistConcurrencyException(); + var state = SetupWorkspaceMetadata.Read(row?.AreasJson); + var areas = state.Areas; + string before = null; + string after = command.Choice; + var mode = row?.Mode ?? 0; + var reviewedOn = row?.ReviewedOn; + switch (command.Operation) + { + case "area": + before = areas.TryGetValue(command.TargetId, out var choice) ? choice.ToString() : null; + if (state.AreaReasons.TryGetValue(command.TargetId, out var priorReason)) before += ":" + priorReason; + areas[command.TargetId] = Enum.Parse(command.Choice); + if (areas[command.TargetId] == SetupAreaChoice.NotApplicable) + { + state.AreaReasons[command.TargetId] = Enum.Parse(command.ReasonCode); + after += ":" + command.ReasonCode; + } + else state.AreaReasons.Remove(command.TargetId); + if (before != after) state.ScopeRevision = checked(state.ScopeRevision + 1); + break; + case "mode": before = ((SetupMode)mode).ToString(); mode = (int)Enum.Parse(command.Choice); break; + case "review": + if (command.ReviewEvidence == null || command.ReviewEvidence.ScopeRevision != state.ScopeRevision || (await GetConfigurationRevisionAsync(actor.DepartmentId, ct)).ToString(System.Globalization.CultureInfo.InvariantCulture) != command.ReviewEvidence.SnapshotRevision) + throw new AdminAssistConcurrencyException(); + state.ReviewEvidence = command.ReviewEvidence; + reviewedOn = now; after = "Reviewed:" + command.ReviewEvidence.SnapshotRevision; break; + case "revisit": + before = state.RevisitOnUtc?.ToString("O"); state.RevisitOnUtc = command.RevisitOnUtc; after = state.RevisitOnUtc?.ToString("O"); break; + case "learn": case "interest": case "dismiss": break; + default: throw new ArgumentException("Invalid setup operation."); + } + var revision = command.ExpectedRevision + 1; + var args = new { actor.DepartmentId, Revision = revision, Mode = mode, AreasJson = state.Serialize(), + command.CatalogVersion, ReviewedOn = reviewedOn, ModifiedOn = now, command.ExpectedRevision }; + if (row == null) + { + try + { + await ExecuteAsync($"INSERT INTO {Tbl("AdminAssistWorkspaces")} ({Cols("DepartmentId", "Revision", "Mode", "AreasJson", "CatalogVersion", "ReviewedOn", "ModifiedOn")}) " + + $"VALUES ({P}DepartmentId,{P}Revision,{P}Mode,{P}AreasJson,{P}CatalogVersion,{P}ReviewedOn,{P}ModifiedOn)", args, ct); + } + catch (Exception ex) when (IsUniqueViolation(ex)) { throw new AdminAssistConcurrencyException(); } + } + else if (await ExecuteAsync($"UPDATE {Tbl("AdminAssistWorkspaces")} SET {Col("Revision")}={P}Revision,{Col("Mode")}={P}Mode," + + $"{Col("AreasJson")}={P}AreasJson,{Col("CatalogVersion")}={P}CatalogVersion,{Col("ReviewedOn")}={P}ReviewedOn,{Col("ModifiedOn")}={P}ModifiedOn " + + $"WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("Revision")}={P}ExpectedRevision", args, ct) != 1) + throw new AdminAssistConcurrencyException(); + + if (command.Operation == "learn" || command.Operation == "interest" || command.Operation == "dismiss") + { + var personalArgs = new { actor.DepartmentId, actor.UserId, CapabilityId = command.Operation == "dismiss" ? "setup-prompt" : command.TargetId, + command.CatalogVersion, ModifiedOn = now, Value = command.Choice == "true" }; + var where = $"{Col("DepartmentId")}={P}DepartmentId AND {Col("UserId")}={P}UserId AND {Col("CapabilityId")}={P}CapabilityId AND {Col("CatalogVersion")}={P}CatalogVersion"; + var personal = await QueryFirstOrDefaultAsync($"SELECT {Cols("CapabilityId", "Learned", "Interested")} FROM {Tbl("AdminAssistLearning")} WHERE {where}", personalArgs, ct); + before = (command.Operation != "interest" ? personal?.Learned : personal?.Interested)?.ToString().ToLowerInvariant(); + var column = command.Operation != "interest" ? "Learned" : "Interested"; + if (personal != null) + await ExecuteAsync($"UPDATE {Tbl("AdminAssistLearning")} SET {Col(column)}={P}Value,{Col("ModifiedOn")}={P}ModifiedOn WHERE {where}", personalArgs, ct); + else + await ExecuteAsync($"INSERT INTO {Tbl("AdminAssistLearning")} ({Cols("DepartmentId", "UserId", "CapabilityId", "CatalogVersion", "Learned", "Interested", "ModifiedOn")}) " + + $"VALUES ({P}DepartmentId,{P}UserId,{P}CapabilityId,{P}CatalogVersion,{P}Learned,{P}Interested,{P}ModifiedOn)", + new { actor.DepartmentId, actor.UserId, CapabilityId = command.Operation == "dismiss" ? "setup-prompt" : command.TargetId, command.CatalogVersion, + Learned = command.Operation != "interest" && command.Choice == "true", Interested = command.Operation == "interest" && command.Choice == "true", ModifiedOn = now }, ct); + } + await ExecuteAsync($"INSERT INTO {Tbl("AdminAssistHistory")} ({Cols("AdminAssistHistoryId", "DepartmentId", "ActorId", "OccurredOnUtc", "Source", "Action", "SubjectId", "BeforeCode", "AfterCode", "Revision")}) " + + $"VALUES ({P}Id,{P}DepartmentId,{P}ActorId,{P}OccurredOnUtc,{P}Source,{P}Action,{P}SubjectId,{P}BeforeCode,{P}AfterCode,{P}Revision)", + new { Id = Guid.NewGuid().ToString(), actor.DepartmentId, ActorId = actor.UserId, OccurredOnUtc = now, + Source = "Setup", Action = command.Operation, SubjectId = command.TargetId ?? "workspace", BeforeCode = before, AfterCode = after, Revision = revision }, ct); + var result = await GetWorkspaceAsync(actor.DepartmentId, actor.UserId, command.CatalogVersion, ct); + if (owns) UnitOfWork.CommitChanges(); + return result; + } + catch { if (owns) UnitOfWork.DiscardChanges(); throw; } + } + + public async Task> GetHistoryAsync(int departmentId, string actorId, int skip, int take, CancellationToken ct) + { + var rows = await QueryAsync($"SELECT * FROM {Tbl("AdminAssistHistory")} WHERE {Col("DepartmentId")}={P}DepartmentId " + + $"AND ({Col("Action")} NOT IN ('learn','interest','dismiss') OR {Col("ActorId")}={P}ActorId) " + + $"ORDER BY {Col("OccurredOnUtc")} DESC,{Col("AdminAssistHistoryId")} DESC {Paging()}", + new { DepartmentId = departmentId, ActorId = actorId, Skip = Math.Max(0, skip), Take = Math.Clamp(take, 1, 100) }, ct); + return rows.Select(r => new AdminAssistHistoryItem(r.AdminAssistHistoryId, r.ActorId, + DateTime.SpecifyKind(r.OccurredOnUtc, DateTimeKind.Utc), r.Source, r.Action, r.SubjectId, r.BeforeCode, r.AfterCode, r.Revision)).ToArray(); + } + + private Task ReadRowAsync(int departmentId, CancellationToken ct) => + QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("AdminAssistWorkspaces")} WHERE {Col("DepartmentId")}={P}DepartmentId", new { DepartmentId = departmentId }, ct); + private sealed class LearningRow + { + public string CapabilityId { get; set; } + public bool Learned { get; set; } + public bool Interested { get; set; } + } + private sealed class HistoryRow + { + public string AdminAssistHistoryId { get; set; } + public string ActorId { get; set; } + public DateTime OccurredOnUtc { get; set; } + public string Source { get; set; } + public string Action { get; set; } + public string SubjectId { get; set; } + public string BeforeCode { get; set; } + public string AfterCode { get; set; } + public long Revision { get; set; } + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/AdpAccessStore.cs b/Repositories/Resgrid.Repositories.DataRepository/AdpAccessStore.cs new file mode 100644 index 000000000..5182f9007 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/AdpAccessStore.cs @@ -0,0 +1,50 @@ +using System.Threading; +using System.Threading.Tasks; +using Dapper; +using Resgrid.Config; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository +{ + public sealed class AdpAccessStore : IAdpAccessStore + { + private readonly IConnectionProvider _connections; + private readonly string _table; + public AdpAccessStore(IConnectionProvider connections, SqlConfiguration configuration) + { + _connections = connections; + _table = configuration.SchemaName + (DataConfig.DatabaseType == DatabaseTypes.Postgres ? ".adpaccessstates" : ".[AdpAccessStates]"); + } + public async Task GetAsync(string id, CancellationToken cancellationToken = default) + { + using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.QuerySingleOrDefaultAsync(new CommandDefinition( + $"SELECT * FROM {_table} WHERE StateId=@id", new { id }, cancellationToken: cancellationToken)); + } + public async Task SaveAsync(string id, string json, long expectedVersion, CancellationToken cancellationToken = default) + { + using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + // First creation can race; a unique-key failure is a failed CAS, never a replacement. + if (expectedVersion == 0) + { + try + { + return await connection.ExecuteAsync(new CommandDefinition( + $"INSERT INTO {_table}(StateId,Json,Version) VALUES(@id,@json,1)", new { id, json }, cancellationToken: cancellationToken)) == 1; + } + catch (System.Data.Common.DbException) + { + if (await GetAsync(id, cancellationToken) == null) throw; + return false; + } + } + return await connection.ExecuteAsync(new CommandDefinition( + $"UPDATE {_table} SET Json=@json, Version=Version+1 WHERE StateId=@id AND Version=@expectedVersion", + new { id, json, expectedVersion }, cancellationToken: cancellationToken)) == 1; + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/AdpAuditRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/AdpAuditRepository.cs new file mode 100644 index 000000000..547c53d82 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/AdpAuditRepository.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Dapper; +using CommandDefinition = Dapper.CommandDefinition; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository +{ + /// Independent commits preserve evidence even when the business transaction rolls back. + public sealed class AdpAuditRepository : IAdpAuditRepository + { + private readonly IConnectionProvider _connections; + private readonly string _table; + private readonly bool _postgres; + public AdpAuditRepository(IConnectionProvider connections, SqlConfiguration configuration) + { + _connections = connections; + _postgres = DataConfig.DatabaseType == DatabaseTypes.Postgres; + _table = configuration.SchemaName + (_postgres ? ".adpauditevents" : ".[AdpAuditEvents]"); + } + + private string TailSql => _postgres + ? $"SELECT * FROM {_table} WHERE departmentid=@departmentId ORDER BY sequence DESC LIMIT 1" + : $"SELECT TOP 1 * FROM {_table} WHERE DepartmentId=@departmentId ORDER BY Sequence DESC"; + + public async Task AppendAsync(AdpAuditEvent record, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(record); + if (record.DepartmentId <= 0 || string.IsNullOrWhiteSpace(record.Layer) || + string.IsNullOrWhiteSpace(record.Operation) || string.IsNullOrWhiteSpace(record.Outcome)) + throw new ArgumentException("ADP audit requires a department, layer, operation and outcome."); + using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + for (var attempt = 0; ; attempt++) + { + var tail = await connection.QuerySingleOrDefaultAsync(new Dapper.CommandDefinition(TailSql, + new { record.DepartmentId }, cancellationToken: cancellationToken)); + AdpAuditChain.Link(record, tail); + try + { + await connection.ExecuteAsync(new Dapper.CommandDefinition($@"INSERT INTO {_table} +(EventId,DepartmentId,Sequence,Layer,Operation,Outcome,ActorId,CorrelationId,ResourceId,PolicyEpoch,OccurredUtc,PreviousHash,Hash) +VALUES (@EventId,@DepartmentId,@Sequence,@Layer,@Operation,@Outcome,@ActorId,@CorrelationId,@ResourceId,@PolicyEpoch,@OccurredUtc,@PreviousHash,@Hash)", + record, cancellationToken: cancellationToken)); + return; + } + catch (DbException) when (attempt < 31) + { + // The unique tenant/sequence index serializes writers across hosts. Retry only a lost race. + var current = await connection.QuerySingleOrDefaultAsync(new Dapper.CommandDefinition(TailSql, + new { record.DepartmentId }, cancellationToken: cancellationToken)); + if (current == null || current.Sequence < record.Sequence) throw; + } + } + } + + public async Task> ReadAsync(int departmentId, CancellationToken cancellationToken = default) + { + using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return (await connection.QueryAsync(new Dapper.CommandDefinition( + $"SELECT * FROM {_table} WHERE DepartmentId=@departmentId ORDER BY Sequence", + new { departmentId }, cancellationToken: cancellationToken))).ToList(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/AuditedConfigurationRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/AuditedConfigurationRepository.cs new file mode 100644 index 000000000..bd509c859 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/AuditedConfigurationRepository.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Model; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository +{ + /// Shared command boundary for explicitly opted-in configuration repositories. Never used for operational records. + public abstract class AuditedConfigurationRepository : RmsRepositoryBase where T : class, IEntity + { + private readonly IConfigurationChangeJournal _journal; + protected bool HasConfigurationJournal => _journal != null; + protected AuditedConfigurationRepository(IConnectionProvider connections, SqlConfiguration configuration, IUnitOfWork unit, + IQueryFactory queries, IConfigurationChangeJournal journal) : base(connections, configuration, unit, queries) { _journal = journal; } + + public override Task InsertAsync(T entity, CancellationToken ct, bool firstLevelOnly = false) => + ChangeAsync(entity, () => base.InsertAsync(entity, ct, firstLevelOnly), ct); + public override Task UpdateAsync(T entity, CancellationToken ct, bool firstLevelOnly = false) => + ChangeAsync(entity, () => base.UpdateAsync(entity, ct, firstLevelOnly), ct); + public override Task DeleteAsync(T entity, CancellationToken ct) => entity == null ? Task.FromResult(false) : + ChangeAsync(entity, () => base.DeleteAsync(entity, ct), ct); + + protected virtual Task ConfigurationDepartmentAsync(T entity, CancellationToken ct) => entity switch + { + DepartmentSetting row => Task.FromResult(row.DepartmentId), + Unit row => Task.FromResult(row.DepartmentId), + DepartmentGroup row => Task.FromResult(row.DepartmentId), + PersonnelRole row => Task.FromResult(row.DepartmentId), + PersonnelRoleUser row => ParentDepartmentAsync("PersonnelRoles", "PersonnelRoleId", row.PersonnelRoleId, row.DepartmentId, ct), + DepartmentGroupMember row => ParentDepartmentAsync("DepartmentGroups", "DepartmentGroupId", row.DepartmentGroupId, row.DepartmentId, ct), + UnitRole row => ParentDepartmentAsync("Units", "UnitId", row.UnitId, null, ct), + Shift row => Task.FromResult(row.DepartmentId), + DepartmentSecurityPolicy row => Task.FromResult(row.DepartmentId), + DepartmentSsoConfig row => Task.FromResult(row.DepartmentId), + DepartmentCallEmail row => Task.FromResult(row.DepartmentId), + ChatbotDepartmentConfig row => Task.FromResult(row.DepartmentId), + DepartmentNotification row => Task.FromResult(row.DepartmentId), + DispatchProtocol row => Task.FromResult(row.DepartmentId), + WeatherAlertZone row => Task.FromResult(row.DepartmentId), + DispatchProtocolAttachment row => ProtocolDepartmentAsync(row.DispatchProtocolId, ct), + DispatchProtocolQuestion row => ProtocolDepartmentAsync(row.DispatchProtocolId, ct), + DispatchProtocolTrigger row => ProtocolDepartmentAsync(row.DispatchProtocolId, ct), + DispatchProtocolQuestionAnswer row => QuestionDepartmentAsync(row.DispatchProtocolQuestionId, ct), + _ => throw new InvalidOperationException("Configuration audit scope has no reviewed binding.") + }; + protected Task ExecuteConfigurationMutationAsync(int departmentId, string binding, + Func> read, Func> write, CancellationToken ct) => + _journal == null ? write() : _journal.ExecuteAsync(departmentId, binding, read, write, ct); + private async Task ParentDepartmentAsync(string table, string key, int id, int? expectedDepartmentId, CancellationToken ct) + { + var departmentId = await ScalarAsync($"SELECT {Col("DepartmentId")} FROM {Tbl(table)} WHERE {Col(key)}={P}Id", new { Id = id }, ct); + if (departmentId <= 0 || expectedDepartmentId > 0 && expectedDepartmentId != departmentId) throw new UnauthorizedAccessException(); + return departmentId; + } + private Task ProtocolDepartmentAsync(int id, CancellationToken ct) => ScalarAsync($"SELECT {Col("DepartmentId")} FROM {Tbl("DispatchProtocols")} WHERE {Col("DispatchProtocolId")}={P}Id", new { Id = id }, ct); + private async Task QuestionDepartmentAsync(int id, CancellationToken ct) => await ProtocolDepartmentAsync(await ScalarAsync( + $"SELECT {Col("DispatchProtocolId")} FROM {Tbl("DispatchProtocolQuestions")} WHERE {Col("DispatchProtocolQuestionId")}={P}Id", new { Id = id }, ct), ct); + + private async Task ChangeAsync(T entity, Func> write, CancellationToken ct) + { + // The optional constructor exists for legacy repository fixtures; production Autofac resolves the journal. + if (_journal == null) return await write(); + var departmentId = await ConfigurationDepartmentAsync(entity, ct); + var binding = entity is DepartmentSetting setting ? "setting." + (DepartmentSettingTypes)setting.SettingType : entity.TableName; + async Task Read() + { + var current = entity.IdValue == null ? null : await GetByIdAsync(entity.IdValue); + if (current != null && await ConfigurationDepartmentAsync(current, ct) != departmentId) throw new UnauthorizedAccessException(); + var stamp = ConfigurationAuditProjection.Project(current); + if (current == null) return stamp; + // Legacy child synchronization instantiates RepositoryBase directly. Capture the complete + // aggregate here so those inserts/updates/deletes cannot bypass protocol audit/revision. + var children = new List(); + switch (current) + { + case DispatchProtocol protocol: + children.AddRange(await QueryAsync($"SELECT * FROM {Tbl("DispatchProtocolQuestions")} WHERE {Col("DispatchProtocolId")}={P}Id", new { Id = protocol.DispatchProtocolId }, ct)); + children.AddRange(await QueryAsync($"SELECT * FROM {Tbl("DispatchProtocolTriggers")} WHERE {Col("DispatchProtocolId")}={P}Id", new { Id = protocol.DispatchProtocolId }, ct)); + children.AddRange(await QueryAsync($"SELECT * FROM {Tbl("DispatchProtocolAttachments")} WHERE {Col("DispatchProtocolId")}={P}Id", new { Id = protocol.DispatchProtocolId }, ct)); + foreach (var question in children.OfType().ToArray()) + children.AddRange(await QueryAsync($"SELECT * FROM {Tbl("DispatchProtocolQuestionAnswers")} WHERE {Col("DispatchProtocolQuestionId")}={P}Id", new { Id = question.DispatchProtocolQuestionId }, ct)); + break; + case Unit unit: + children.AddRange(await QueryAsync($"SELECT * FROM {Tbl("UnitRoles")} WHERE {Col("UnitId")}={P}Id", new { Id = unit.UnitId }, ct)); break; + case DepartmentGroup group: + children.AddRange(await QueryAsync($"SELECT * FROM {Tbl("DepartmentGroupMembers")} WHERE {Col("DepartmentGroupId")}={P}Id", new { Id = group.DepartmentGroupId }, ct)); break; + case PersonnelRole role: + children.AddRange(await QueryAsync($"SELECT * FROM {Tbl("PersonnelRoleUsers")} WHERE {Col("PersonnelRoleId")}={P}Id", new { Id = role.PersonnelRoleId }, ct)); break; + default: return stamp; + } + var childStamps = children.OrderBy(c => c.TableName, StringComparer.Ordinal).ThenBy(c => c.IdValue.ToString(), StringComparer.Ordinal).Select(ConfigurationAuditProjection.Project).ToArray(); + return new ConfigurationChangeStamp(Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(stamp.Fingerprint + string.Join("|", childStamps.Select(c => c.Fingerprint))))), + JsonConvert.SerializeObject(new { root = stamp.Values, children = children.GroupBy(c => c.TableName).ToDictionary(g => g.Key, g => g.Count()) })); + } + return await _journal.ExecuteAsync(departmentId, binding, Read, write, ct); + } + } + + /// Only stored scalar columns are inspected. Strings/binary/identifiers are presence markers; no raw values enter audit. + public static class ConfigurationAuditProjection + { + private static readonly HashSet SafeNumbers = new(StringComparer.Ordinal) + { + "SettingType", "SsoProviderType", "SessionTimeoutMinutes", "MaxConcurrentSessions", "PasswordExpirationDays", "MinPasswordLength", + "DataClassificationLevel", "EventType", "UpperLimit", "LowerLimit", "MinimumWeight", "State", "Weight", "TriggerType", "Type" + }; + private static readonly HashSet SafeScalars = new() + { + DepartmentSettingTypes.DisabledAutoAvailable, DepartmentSettingTypes.EnableTextToCall, DepartmentSettingTypes.EnableTextCommand, + DepartmentSettingTypes.DispatchShiftInsteadOfGroup, DepartmentSettingTypes.AutoSetStatusForShiftDispatchPersonnel, + DepartmentSettingTypes.ShiftCallDispatchPersonnelStatusToSet, DepartmentSettingTypes.ShiftCallReleasePersonnelStatusToSet, + DepartmentSettingTypes.AllowSignupsForMultipleShiftGroups, DepartmentSettingTypes.MappingPersonnelLocationTTL, + DepartmentSettingTypes.MappingUnitLocationTTL, DepartmentSettingTypes.MappingPersonnelAllowStatusWithNoLocationToOverwrite, + DepartmentSettingTypes.MappingUnitAllowStatusWithNoLocationToOverwrite, DepartmentSettingTypes.UnitDispatchAlsoDispatchToAssignedPersonnel, + DepartmentSettingTypes.UnitDispatchAlsoDispatchToGroup, DepartmentSettingTypes.PersonnelOnUnitSetUnitStatus, DepartmentSettingTypes.Require2FAForAdmins, + DepartmentSettingTypes.CheckInTimersAutoEnableForNewCalls, DepartmentSettingTypes.WeatherAlertsEnabled, + DepartmentSettingTypes.WeatherAlertMinimumSeverity, DepartmentSettingTypes.WeatherAlertAutoMessageSeverity, + DepartmentSettingTypes.WeatherAlertCallIntegration, DepartmentSettingTypes.WeatherAlertCacheMinutes, + DepartmentSettingTypes.MappingUseMapboxOverride, DepartmentSettingTypes.UnitCallDispatchStatusToSet, DepartmentSettingTypes.UnitCallReleaseStatusToSet, + DepartmentSettingTypes.EnableModernNotifications, DepartmentSettingTypes.ForceChatbotSecurityPin, DepartmentSettingTypes.HardwareTrackingStaleAfterSeconds, + DepartmentSettingTypes.HardwareTrackingMobileFallbackEnabled, DepartmentSettingTypes.HardwareTrackingLocationRetentionDays, + DepartmentSettingTypes.DispatchRecommendationMode, DepartmentSettingTypes.DispatchRecommendationAutoDispatch, + DepartmentSettingTypes.RequirePasswordResetViaEmail, DepartmentSettingTypes.RecordsDefaultLifecyclePreset, + DepartmentSettingTypes.RecordsReviewDueHours, DepartmentSettingTypes.RecordsGroupVisibilityMode + }; + public static ConfigurationChangeStamp Project(IEntity entity) + { + if (entity == null) return null; + var safe = new SortedDictionary(StringComparer.Ordinal); + var fingerprint = new SortedDictionary(StringComparer.Ordinal); + var ignored = entity.IgnoredProperties.ToHashSet(StringComparer.Ordinal); + foreach (var property in entity.GetType().GetProperties(BindingFlags.Instance | BindingFlags.Public).Where(p => p.CanRead && p.GetIndexParameters().Length == 0 && !ignored.Contains(p.Name))) + { + var value = property.GetValue(entity); + var type = Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType; + if (!(type.IsPrimitive || type.IsEnum || type == typeof(string) || type == typeof(decimal) || type == typeof(DateTime) || type == typeof(Guid) || type == typeof(byte[]))) continue; + fingerprint[property.Name] = value is byte[] bytes ? Convert.ToHexString(SHA256.HashData(bytes)) : value; + if (property.Name == "Setting" && entity is DepartmentSetting setting && SafeScalars.Contains((DepartmentSettingTypes)setting.SettingType)) + { + safe[property.Name] = bool.TryParse(setting.Setting, out var boolean) ? boolean : decimal.TryParse(setting.Setting, NumberStyles.Integer, CultureInfo.InvariantCulture, out var number) ? number : "InvalidStoredValue"; + continue; + } + if (property.Name.EndsWith("Id", StringComparison.Ordinal) || type == typeof(string) || type == typeof(byte[]) || type == typeof(Guid)) + safe[property.Name] = value == null || value is string text && string.IsNullOrWhiteSpace(text) || value is byte[] data && data.Length == 0 ? "Absent" : "Present"; + else safe[property.Name] = type == typeof(bool) || type == typeof(DateTime) || SafeNumbers.Contains(property.Name) ? value : value == null ? "Absent" : "Present"; + } + var digest = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(fingerprint)))); + return new ConfigurationChangeStamp(digest, JsonConvert.SerializeObject(safe)); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/ChatbotDepartmentConfigRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/ChatbotDepartmentConfigRepository.cs index 223aa7123..063c7e398 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/ChatbotDepartmentConfigRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/ChatbotDepartmentConfigRepository.cs @@ -10,10 +10,10 @@ namespace Resgrid.Repositories.DataRepository { - public class ChatbotDepartmentConfigRepository : RepositoryBase, IChatbotDepartmentConfigRepository + public class ChatbotDepartmentConfigRepository : AuditedConfigurationRepository, IChatbotDepartmentConfigRepository { - public ChatbotDepartmentConfigRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) - : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + public ChatbotDepartmentConfigRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory, Resgrid.Model.AdminAssist.IConfigurationChangeJournal configurationJournal = null) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory, configurationJournal) { } diff --git a/Repositories/Resgrid.Repositories.DataRepository/ChecklistDepartmentCleanup.cs b/Repositories/Resgrid.Repositories.DataRepository/ChecklistDepartmentCleanup.cs index c119b6093..702dc4a6f 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/ChecklistDepartmentCleanup.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/ChecklistDepartmentCleanup.cs @@ -35,6 +35,7 @@ async Task Exists(string table) if (await Exists("RmsRecordLegalHolds") && await connection.ExecuteScalarAsync(new CommandDefinition($"SELECT COUNT(*) FROM {Q("RmsRecordLegalHolds")} WHERE {Q("DepartmentId")}=@DepartmentId AND {Q("ReleasedOn")} IS NULL", new { DepartmentId = departmentId }, transaction, cancellationToken: ct)) > 0) throw new InvalidOperationException("Department readiness evidence is retained under an active legal hold."); await InventoryDepartmentCleanup.DeleteWithinTransactionAsync(connection, transaction, departmentId, type, ct); + await AdminAssistDepartmentCleanup.DeleteWithinTransactionAsync(connection, transaction, departmentId, type, ct); if (!hasChecklists) return; var triggers = ChecklistWorkflowPayload.Triggers.Except(InventoryWorkflowPayload.Triggers).ToArray(); var triggerPredicate = Q("TriggerEventType") + (pg ? "=ANY(@Triggers)" : " IN @Triggers"); diff --git a/Repositories/Resgrid.Repositories.DataRepository/DepartmentCallEmailsRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/DepartmentCallEmailsRepository.cs index f498dfb43..bc7952b0d 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/DepartmentCallEmailsRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/DepartmentCallEmailsRepository.cs @@ -1,4 +1,4 @@ -using Resgrid.Model; +using Resgrid.Model; using Resgrid.Model.Repositories; using Resgrid.Model.Repositories.Connection; using Resgrid.Model.Repositories.Queries; @@ -6,15 +6,15 @@ namespace Resgrid.Repositories.DataRepository { - public class DepartmentCallEmailsRepository : RepositoryBase, IDepartmentCallEmailsRepository + public class DepartmentCallEmailsRepository : AuditedConfigurationRepository, IDepartmentCallEmailsRepository { private readonly IConnectionProvider _connectionProvider; private readonly SqlConfiguration _sqlConfiguration; private readonly IQueryFactory _queryFactory; private readonly IUnitOfWork _unitOfWork; - public DepartmentCallEmailsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) - : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + public DepartmentCallEmailsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory, Resgrid.Model.AdminAssist.IConfigurationChangeJournal configurationJournal = null) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory, configurationJournal) { _connectionProvider = connectionProvider; _sqlConfiguration = sqlConfiguration; diff --git a/Repositories/Resgrid.Repositories.DataRepository/DepartmentGroupMembersRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/DepartmentGroupMembersRepository.cs index ace694542..0b6fb49e9 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/DepartmentGroupMembersRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/DepartmentGroupMembersRepository.cs @@ -14,15 +14,15 @@ namespace Resgrid.Repositories.DataRepository { - public class DepartmentGroupMembersRepository : RepositoryBase, IDepartmentGroupMembersRepository + public class DepartmentGroupMembersRepository : AuditedConfigurationRepository, IDepartmentGroupMembersRepository { private readonly IConnectionProvider _connectionProvider; private readonly SqlConfiguration _sqlConfiguration; private readonly IQueryFactory _queryFactory; private readonly IUnitOfWork _unitOfWork; - public DepartmentGroupMembersRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) - : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + public DepartmentGroupMembersRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory, Resgrid.Model.AdminAssist.IConfigurationChangeJournal configurationJournal = null) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory, configurationJournal) { _connectionProvider = connectionProvider; _sqlConfiguration = sqlConfiguration; @@ -116,6 +116,22 @@ public async Task> GetAllGroupMembersByUserAn } public async Task DeleteGroupMembersByGroupIdAsync(int groupId, int departmentId, CancellationToken cancellationToken = default(CancellationToken)) + { + if (!HasConfigurationJournal) return await DeleteGroupMembersCoreAsync(groupId, departmentId, cancellationToken); + var owner = await ScalarAsync($"SELECT {Col("DepartmentId")} FROM {Tbl("DepartmentGroups")} WHERE {Col("DepartmentGroupId")}={P}Id", new { Id = groupId }, cancellationToken); + if (owner == 0) return false; + if (owner != departmentId) throw new UnauthorizedAccessException(); + async Task Read() + { + var count = await ScalarAsync($"SELECT COUNT(*) FROM {Tbl("DepartmentGroupMembers")} WHERE {Col("DepartmentGroupId")}={P}Id AND {Col("DepartmentId")}={P}DepartmentId", new { Id = groupId, DepartmentId = departmentId }, cancellationToken); + var value = count.ToString(System.Globalization.CultureInfo.InvariantCulture); + return new(value, "{\"members\":" + value + "}"); + } + return await ExecuteConfigurationMutationAsync(departmentId, "DepartmentGroupMembers.BulkDelete", Read, + () => DeleteGroupMembersCoreAsync(groupId, departmentId, cancellationToken), cancellationToken); + } + + private async Task DeleteGroupMembersCoreAsync(int groupId, int departmentId, CancellationToken cancellationToken) { try { diff --git a/Repositories/Resgrid.Repositories.DataRepository/DepartmentGroupsRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/DepartmentGroupsRepository.cs index f424e4e0f..c4025f48a 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/DepartmentGroupsRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/DepartmentGroupsRepository.cs @@ -21,15 +21,15 @@ namespace Resgrid.Repositories.DataRepository { - public class DepartmentGroupsRepository : RepositoryBase, IDepartmentGroupsRepository + public class DepartmentGroupsRepository : AuditedConfigurationRepository, IDepartmentGroupsRepository { private readonly IConnectionProvider _connectionProvider; private readonly SqlConfiguration _sqlConfiguration; private readonly IQueryFactory _queryFactory; private readonly IUnitOfWork _unitOfWork; - public DepartmentGroupsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) - : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + public DepartmentGroupsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory, Resgrid.Model.AdminAssist.IConfigurationChangeJournal configurationJournal = null) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory, configurationJournal) { _connectionProvider = connectionProvider; _sqlConfiguration = sqlConfiguration; diff --git a/Repositories/Resgrid.Repositories.DataRepository/DepartmentNotificationRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/DepartmentNotificationRepository.cs index d3dd8f429..a6be27a5c 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/DepartmentNotificationRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/DepartmentNotificationRepository.cs @@ -7,15 +7,15 @@ namespace Resgrid.Repositories.DataRepository { - public class DepartmentNotificationRepository : RepositoryBase, IDepartmentNotificationRepository + public class DepartmentNotificationRepository : AuditedConfigurationRepository, IDepartmentNotificationRepository { private readonly IConnectionProvider _connectionProvider; private readonly SqlConfiguration _sqlConfiguration; private readonly IQueryFactory _queryFactory; private readonly IUnitOfWork _unitOfWork; - public DepartmentNotificationRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) - : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + public DepartmentNotificationRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory, Resgrid.Model.AdminAssist.IConfigurationChangeJournal configurationJournal = null) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory, configurationJournal) { _connectionProvider = connectionProvider; _sqlConfiguration = sqlConfiguration; diff --git a/Repositories/Resgrid.Repositories.DataRepository/DepartmentSecurityPolicyRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/DepartmentSecurityPolicyRepository.cs index 1bb9026f7..2557106a2 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/DepartmentSecurityPolicyRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/DepartmentSecurityPolicyRepository.cs @@ -12,7 +12,7 @@ namespace Resgrid.Repositories.DataRepository { - public class DepartmentSecurityPolicyRepository : RepositoryBase, IDepartmentSecurityPolicyRepository + public class DepartmentSecurityPolicyRepository : AuditedConfigurationRepository, IDepartmentSecurityPolicyRepository { private readonly IConnectionProvider _connectionProvider; private readonly SqlConfiguration _sqlConfiguration; @@ -23,8 +23,8 @@ public DepartmentSecurityPolicyRepository( IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, - IQueryFactory queryFactory) - : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + IQueryFactory queryFactory, Resgrid.Model.AdminAssist.IConfigurationChangeJournal configurationJournal = null) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory, configurationJournal) { _connectionProvider = connectionProvider; _sqlConfiguration = sqlConfiguration; diff --git a/Repositories/Resgrid.Repositories.DataRepository/DepartmentSettingsRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/DepartmentSettingsRepository.cs index 8e812c140..71e969644 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/DepartmentSettingsRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/DepartmentSettingsRepository.cs @@ -13,7 +13,7 @@ namespace Resgrid.Repositories.DataRepository { - public class DepartmentSettingsRepository : RmsRepositoryBase, IDepartmentSettingsRepository + public class DepartmentSettingsRepository : AuditedConfigurationRepository, IDepartmentSettingsRepository { public async Task SaveRecordsRetentionPolicyAsync(int departmentId, RecordsRetentionPolicy policy, System.Threading.CancellationToken cancellationToken = default) { @@ -39,8 +39,8 @@ public async Task SaveRecordsRetentionPolicyAsync(int departm private readonly IQueryFactory _queryFactory; private readonly IUnitOfWork _unitOfWork; - public DepartmentSettingsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) - : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + public DepartmentSettingsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory, Resgrid.Model.AdminAssist.IConfigurationChangeJournal configurationJournal = null) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory, configurationJournal) { _connectionProvider = connectionProvider; _sqlConfiguration = sqlConfiguration; diff --git a/Repositories/Resgrid.Repositories.DataRepository/DepartmentSsoConfigRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/DepartmentSsoConfigRepository.cs index bfcd76415..9f7828a25 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/DepartmentSsoConfigRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/DepartmentSsoConfigRepository.cs @@ -13,7 +13,7 @@ namespace Resgrid.Repositories.DataRepository { - public class DepartmentSsoConfigRepository : RepositoryBase, IDepartmentSsoConfigRepository + public class DepartmentSsoConfigRepository : AuditedConfigurationRepository, IDepartmentSsoConfigRepository { private readonly IConnectionProvider _connectionProvider; private readonly SqlConfiguration _sqlConfiguration; @@ -24,8 +24,8 @@ public DepartmentSsoConfigRepository( IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, - IQueryFactory queryFactory) - : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + IQueryFactory queryFactory, Resgrid.Model.AdminAssist.IConfigurationChangeJournal configurationJournal = null) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory, configurationJournal) { _connectionProvider = connectionProvider; _sqlConfiguration = sqlConfiguration; diff --git a/Repositories/Resgrid.Repositories.DataRepository/DispatchProtocolAttachmentRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/DispatchProtocolAttachmentRepository.cs index 416d910b0..533053e73 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/DispatchProtocolAttachmentRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/DispatchProtocolAttachmentRepository.cs @@ -13,15 +13,15 @@ namespace Resgrid.Repositories.DataRepository { - public class DispatchProtocolAttachmentRepository : RepositoryBase, IDispatchProtocolAttachmentRepository + public class DispatchProtocolAttachmentRepository : AuditedConfigurationRepository, IDispatchProtocolAttachmentRepository { private readonly IConnectionProvider _connectionProvider; private readonly SqlConfiguration _sqlConfiguration; private readonly IQueryFactory _queryFactory; private readonly IUnitOfWork _unitOfWork; - public DispatchProtocolAttachmentRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) - : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + public DispatchProtocolAttachmentRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory, Resgrid.Model.AdminAssist.IConfigurationChangeJournal configurationJournal = null) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory, configurationJournal) { _connectionProvider = connectionProvider; _sqlConfiguration = sqlConfiguration; diff --git a/Repositories/Resgrid.Repositories.DataRepository/DispatchProtocolQuestionAnswersRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/DispatchProtocolQuestionAnswersRepository.cs index 4e2d65168..bf634f7f7 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/DispatchProtocolQuestionAnswersRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/DispatchProtocolQuestionAnswersRepository.cs @@ -6,15 +6,15 @@ namespace Resgrid.Repositories.DataRepository { - public class DispatchProtocolQuestionAnswersRepository : RepositoryBase, IDispatchProtocolQuestionAnswersRepository + public class DispatchProtocolQuestionAnswersRepository : AuditedConfigurationRepository, IDispatchProtocolQuestionAnswersRepository { private readonly IConnectionProvider _connectionProvider; private readonly SqlConfiguration _sqlConfiguration; private readonly IQueryFactory _queryFactory; private readonly IUnitOfWork _unitOfWork; - public DispatchProtocolQuestionAnswersRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) - : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + public DispatchProtocolQuestionAnswersRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory, Resgrid.Model.AdminAssist.IConfigurationChangeJournal configurationJournal = null) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory, configurationJournal) { _connectionProvider = connectionProvider; _sqlConfiguration = sqlConfiguration; diff --git a/Repositories/Resgrid.Repositories.DataRepository/DispatchProtocolQuestionsRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/DispatchProtocolQuestionsRepository.cs index b8b0febae..8dd6c6051 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/DispatchProtocolQuestionsRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/DispatchProtocolQuestionsRepository.cs @@ -14,15 +14,15 @@ namespace Resgrid.Repositories.DataRepository { - public class DispatchProtocolQuestionsRepository : RepositoryBase, IDispatchProtocolQuestionsRepository + public class DispatchProtocolQuestionsRepository : AuditedConfigurationRepository, IDispatchProtocolQuestionsRepository { private readonly IConnectionProvider _connectionProvider; private readonly SqlConfiguration _sqlConfiguration; private readonly IQueryFactory _queryFactory; private readonly IUnitOfWork _unitOfWork; - public DispatchProtocolQuestionsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) - : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + public DispatchProtocolQuestionsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory, Resgrid.Model.AdminAssist.IConfigurationChangeJournal configurationJournal = null) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory, configurationJournal) { _connectionProvider = connectionProvider; _sqlConfiguration = sqlConfiguration; diff --git a/Repositories/Resgrid.Repositories.DataRepository/DispatchProtocolRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/DispatchProtocolRepository.cs index a9b6b68db..822c21596 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/DispatchProtocolRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/DispatchProtocolRepository.cs @@ -15,15 +15,15 @@ namespace Resgrid.Repositories.DataRepository { - public class DispatchProtocolRepository : RepositoryBase, IDispatchProtocolRepository + public class DispatchProtocolRepository : AuditedConfigurationRepository, IDispatchProtocolRepository { private readonly IConnectionProvider _connectionProvider; private readonly SqlConfiguration _sqlConfiguration; private readonly IQueryFactory _queryFactory; private readonly IUnitOfWork _unitOfWork; - public DispatchProtocolRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) - : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + public DispatchProtocolRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory, Resgrid.Model.AdminAssist.IConfigurationChangeJournal configurationJournal = null) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory, configurationJournal) { _connectionProvider = connectionProvider; _sqlConfiguration = sqlConfiguration; diff --git a/Repositories/Resgrid.Repositories.DataRepository/DispatchProtocolTriggersRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/DispatchProtocolTriggersRepository.cs index 5b2ffc3a9..6ea8e3e0f 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/DispatchProtocolTriggersRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/DispatchProtocolTriggersRepository.cs @@ -13,15 +13,15 @@ namespace Resgrid.Repositories.DataRepository { - public class DispatchProtocolTriggersRepository : RepositoryBase, IDispatchProtocolTriggersRepository + public class DispatchProtocolTriggersRepository : AuditedConfigurationRepository, IDispatchProtocolTriggersRepository { private readonly IConnectionProvider _connectionProvider; private readonly SqlConfiguration _sqlConfiguration; private readonly IQueryFactory _queryFactory; private readonly IUnitOfWork _unitOfWork; - public DispatchProtocolTriggersRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) - : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + public DispatchProtocolTriggersRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory, Resgrid.Model.AdminAssist.IConfigurationChangeJournal configurationJournal = null) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory, configurationJournal) { _connectionProvider = connectionProvider; _sqlConfiguration = sqlConfiguration; diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs index dd0766230..938660379 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs @@ -15,6 +15,7 @@ public class DataModule : Module { protected override void Load(ContainerBuilder builder) { + builder.RegisterType().As().As().As().As().As().As().As().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().As().InstancePerLifetimeScope(); @@ -359,6 +360,9 @@ 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(); + // Protected Workflows (ADP push model): per-workflow releases and the per-department disclosure hash chain. builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/PersonnelRoleUsersRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/PersonnelRoleUsersRepository.cs index 333026a48..fbaa8db20 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/PersonnelRoleUsersRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/PersonnelRoleUsersRepository.cs @@ -13,15 +13,15 @@ namespace Resgrid.Repositories.DataRepository { - public class PersonnelRoleUsersRepository : RepositoryBase, IPersonnelRoleUsersRepository + public class PersonnelRoleUsersRepository : AuditedConfigurationRepository, IPersonnelRoleUsersRepository { private readonly IConnectionProvider _connectionProvider; private readonly SqlConfiguration _sqlConfiguration; private readonly IQueryFactory _queryFactory; private readonly IUnitOfWork _unitOfWork; - public PersonnelRoleUsersRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) - : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + public PersonnelRoleUsersRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory, Resgrid.Model.AdminAssist.IConfigurationChangeJournal configurationJournal = null) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory, configurationJournal) { _connectionProvider = connectionProvider; _sqlConfiguration = sqlConfiguration; diff --git a/Repositories/Resgrid.Repositories.DataRepository/PersonnelRolesRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/PersonnelRolesRepository.cs index 0090278d7..a337ae587 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/PersonnelRolesRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/PersonnelRolesRepository.cs @@ -16,15 +16,15 @@ namespace Resgrid.Repositories.DataRepository { - public class PersonnelRolesRepository : RepositoryBase, IPersonnelRolesRepository + public class PersonnelRolesRepository : AuditedConfigurationRepository, IPersonnelRolesRepository { private readonly IConnectionProvider _connectionProvider; private readonly SqlConfiguration _sqlConfiguration; private readonly IQueryFactory _queryFactory; private readonly IUnitOfWork _unitOfWork; - public PersonnelRolesRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) - : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + public PersonnelRolesRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory, Resgrid.Model.AdminAssist.IConfigurationChangeJournal configurationJournal = null) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory, configurationJournal) { _connectionProvider = connectionProvider; _sqlConfiguration = sqlConfiguration; @@ -214,6 +214,26 @@ public async Task> GetPersonnelRolesByDepartmentIdAsy } public async Task DeleteRoleDependenciesAsync(int personnelRoleId, CancellationToken cancellationToken = default(CancellationToken)) + { + if (!HasConfigurationJournal) return await DeleteRoleDependenciesCoreAsync(personnelRoleId, cancellationToken); + var role = await GetByIdAsync(personnelRoleId); + if (role == null) return false; + async Task Read() + { + var counts = new SortedDictionary(StringComparer.Ordinal); + foreach (var reference in new[] { ("PersonnelRoleUsers", "PersonnelRoleId"), ("CallDispatchRoles", "RoleId"), + ("ShiftGroupRoles", "PersonnelRoleId"), ("CommandDefinitionRolePersonnelRoles", "PersonnelRoleId"), + ("RunCardRoleRequirements", "PersonnelRoleId"), ("StationCoverageRequirements", "PersonnelRoleId"), + ("ChatChannelAccessRules", "PersonnelRoleId"), ("UnitRoles", "PersonnelRoleId") }) + counts[reference.Item1] = await ScalarAsync($"SELECT COUNT(*) FROM {Tbl(reference.Item1)} WHERE {Col(reference.Item2)}={P}Id", new { Id = personnelRoleId }, cancellationToken); + var value = Newtonsoft.Json.JsonConvert.SerializeObject(counts); + return new(Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(value))), value); + } + return await ExecuteConfigurationMutationAsync(role.DepartmentId, "PersonnelRoles.Dependencies", Read, + () => DeleteRoleDependenciesCoreAsync(personnelRoleId, cancellationToken), cancellationToken); + } + + private async Task DeleteRoleDependenciesCoreAsync(int personnelRoleId, CancellationToken cancellationToken) { try { diff --git a/Repositories/Resgrid.Repositories.DataRepository/RmsRetentionRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/RmsRetentionRepository.cs index 8a35cf884..33dd1694f 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/RmsRetentionRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/RmsRetentionRepository.cs @@ -102,7 +102,7 @@ private async Task PurgeLockedAsync(int departmentId, string rec var policy = string.IsNullOrEmpty(setting?.Setting) ? new RecordsRetentionPolicy() : ObjectSerialization.Deserialize(setting.Setting); if (policy == null) throw new InvalidOperationException("Retention policy is unreadable."); var years = policy.ResolveYears(definition, finalized.Value); - if (years <= 0 || years > 9999 - finalized.Value.Year || finalized.Value.AddYears(years) > now) return new RmsPurgeResult { Reason = "Retention has not expired." }; + if (!RecordsRetentionWindow.HasExpired(finalized, years, now)) return new RmsPurgeResult { Reason = "Retention has not expired." }; var analyses = operational ? new List() : (await QueryAsync($"SELECT * FROM {Tbl("RmsIncidentAnalyses")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("IncidentReportId")} = {P}Id", key, cancellationToken)).ToList(); var ids = new[] { recordId }.Concat(analyses.Select(a => a.RmsIncidentAnalysisId)).ToArray(); foreach (var analysis in analyses.OrderBy(a => a.RmsIncidentAnalysisId, StringComparer.Ordinal)) @@ -111,7 +111,7 @@ private async Task PurgeLockedAsync(int departmentId, string rec var current = await QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("RmsIncidentAnalyses")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsIncidentAnalysisId")} = {P}Id", new { DepartmentId = departmentId, Id = analysis.RmsIncidentAnalysisId }, cancellationToken); if (current.DeletedOn.HasValue) continue; var childYears = policy.ResolveYears(definition, current.FinalizedOn ?? current.CreatedOn); - if (!current.FinalizedOn.HasValue || childYears <= 0 || childYears > 9999 - current.FinalizedOn.Value.Year || current.FinalizedOn.Value.AddYears(childYears) > now) + if (!RecordsRetentionWindow.HasExpired(current.FinalizedOn, childYears, now)) return new RmsPurgeResult { Held = true, Reason = "The analysis is open or has an unexpired retention obligation." }; } foreach (var permanentClass in new[] { "RmsCasualtyRescues", "RmsExposures" }) diff --git a/Repositories/Resgrid.Repositories.DataRepository/ShiftsRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/ShiftsRepository.cs index 74eeaa62a..16c77926f 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/ShiftsRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/ShiftsRepository.cs @@ -17,15 +17,15 @@ namespace Resgrid.Repositories.DataRepository { - public class ShiftsRepository : RepositoryBase, IShiftsRepository + public class ShiftsRepository : AuditedConfigurationRepository, IShiftsRepository { private readonly IConnectionProvider _connectionProvider; private readonly SqlConfiguration _sqlConfiguration; private readonly IQueryFactory _queryFactory; private readonly IUnitOfWork _unitOfWork; - public ShiftsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) - : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + public ShiftsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory, Resgrid.Model.AdminAssist.IConfigurationChangeJournal configurationJournal = null) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory, configurationJournal) { _connectionProvider = connectionProvider; _sqlConfiguration = sqlConfiguration; diff --git a/Repositories/Resgrid.Repositories.DataRepository/UnitRolesRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/UnitRolesRepository.cs index 3bfae4aab..af9f5f112 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/UnitRolesRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/UnitRolesRepository.cs @@ -13,15 +13,15 @@ namespace Resgrid.Repositories.DataRepository { - public class UnitRolesRepository : RepositoryBase, IUnitRolesRepository + public class UnitRolesRepository : AuditedConfigurationRepository, IUnitRolesRepository { private readonly IConnectionProvider _connectionProvider; private readonly SqlConfiguration _sqlConfiguration; private readonly IQueryFactory _queryFactory; private readonly IUnitOfWork _unitOfWork; - public UnitRolesRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) - : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + public UnitRolesRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory, Resgrid.Model.AdminAssist.IConfigurationChangeJournal configurationJournal = null) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory, configurationJournal) { _connectionProvider = connectionProvider; _sqlConfiguration = sqlConfiguration; diff --git a/Repositories/Resgrid.Repositories.DataRepository/UnitsRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/UnitsRepository.cs index 4b5f347b8..85dcbc45e 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/UnitsRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/UnitsRepository.cs @@ -14,15 +14,15 @@ namespace Resgrid.Repositories.DataRepository { - public class UnitsRepository : RepositoryBase, IUnitsRepository + public class UnitsRepository : AuditedConfigurationRepository, IUnitsRepository { private readonly IConnectionProvider _connectionProvider; private readonly SqlConfiguration _sqlConfiguration; private readonly IQueryFactory _queryFactory; private readonly IUnitOfWork _unitOfWork; - public UnitsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) - : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + public UnitsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory, Resgrid.Model.AdminAssist.IConfigurationChangeJournal configurationJournal = null) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory, configurationJournal) { _connectionProvider = connectionProvider; _sqlConfiguration = sqlConfiguration; diff --git a/Repositories/Resgrid.Repositories.DataRepository/WeatherAlertZoneRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/WeatherAlertZoneRepository.cs index 75b1514cd..153a35512 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/WeatherAlertZoneRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/WeatherAlertZoneRepository.cs @@ -1,4 +1,4 @@ -using Dapper; +using Dapper; using Resgrid.Framework; using Resgrid.Model; using Resgrid.Model.Repositories; @@ -13,15 +13,15 @@ namespace Resgrid.Repositories.DataRepository { - public class WeatherAlertZoneRepository : RepositoryBase, IWeatherAlertZoneRepository + public class WeatherAlertZoneRepository : AuditedConfigurationRepository, IWeatherAlertZoneRepository { private readonly IConnectionProvider _connectionProvider; private readonly SqlConfiguration _sqlConfiguration; private readonly IQueryFactory _queryFactory; private readonly IUnitOfWork _unitOfWork; - public WeatherAlertZoneRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) - : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + public WeatherAlertZoneRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory, Resgrid.Model.AdminAssist.IConfigurationChangeJournal configurationJournal = null) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory, configurationJournal) { _connectionProvider = connectionProvider; _sqlConfiguration = sqlConfiguration; diff --git a/Resgrid.sln b/Resgrid.sln index e27dcf1a2..ed7cc7d2c 100644 --- a/Resgrid.sln +++ b/Resgrid.sln @@ -128,6 +128,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Resgrid.Providers.Neris", " EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Resgrid.Providers.Payments", "Providers\Resgrid.Providers.Payments\Resgrid.Providers.Payments.csproj", "{BDF50FEA-B18D-4E8F-9E5F-4BCA99FCB692}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Resgrid.AdminAssist", "Core\Resgrid.AdminAssist\Resgrid.AdminAssist.csproj", "{D34686EE-9BBA-418D-AC65-5406CFFE4DE4}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Azure|Any CPU = Azure|Any CPU @@ -1914,6 +1916,42 @@ Global {BDF50FEA-B18D-4E8F-9E5F-4BCA99FCB692}.Staging|x86.Build.0 = Debug|Any CPU {BDF50FEA-B18D-4E8F-9E5F-4BCA99FCB692}.Staging|x64.ActiveCfg = Debug|Any CPU {BDF50FEA-B18D-4E8F-9E5F-4BCA99FCB692}.Staging|x64.Build.0 = Debug|Any CPU + {D34686EE-9BBA-418D-AC65-5406CFFE4DE4}.Azure|Any CPU.ActiveCfg = Debug|Any CPU + {D34686EE-9BBA-418D-AC65-5406CFFE4DE4}.Azure|Any CPU.Build.0 = Debug|Any CPU + {D34686EE-9BBA-418D-AC65-5406CFFE4DE4}.Azure|x86.ActiveCfg = Debug|Any CPU + {D34686EE-9BBA-418D-AC65-5406CFFE4DE4}.Azure|x86.Build.0 = Debug|Any CPU + {D34686EE-9BBA-418D-AC65-5406CFFE4DE4}.Azure|x64.ActiveCfg = Debug|Any CPU + {D34686EE-9BBA-418D-AC65-5406CFFE4DE4}.Azure|x64.Build.0 = Debug|Any CPU + {D34686EE-9BBA-418D-AC65-5406CFFE4DE4}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU + {D34686EE-9BBA-418D-AC65-5406CFFE4DE4}.Cloud|Any CPU.Build.0 = Debug|Any CPU + {D34686EE-9BBA-418D-AC65-5406CFFE4DE4}.Cloud|x86.ActiveCfg = Debug|Any CPU + {D34686EE-9BBA-418D-AC65-5406CFFE4DE4}.Cloud|x86.Build.0 = Debug|Any CPU + {D34686EE-9BBA-418D-AC65-5406CFFE4DE4}.Cloud|x64.ActiveCfg = Debug|Any CPU + {D34686EE-9BBA-418D-AC65-5406CFFE4DE4}.Cloud|x64.Build.0 = Debug|Any CPU + {D34686EE-9BBA-418D-AC65-5406CFFE4DE4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D34686EE-9BBA-418D-AC65-5406CFFE4DE4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D34686EE-9BBA-418D-AC65-5406CFFE4DE4}.Debug|x86.ActiveCfg = Debug|Any CPU + {D34686EE-9BBA-418D-AC65-5406CFFE4DE4}.Debug|x86.Build.0 = Debug|Any CPU + {D34686EE-9BBA-418D-AC65-5406CFFE4DE4}.Debug|x64.ActiveCfg = Debug|Any CPU + {D34686EE-9BBA-418D-AC65-5406CFFE4DE4}.Debug|x64.Build.0 = Debug|Any CPU + {D34686EE-9BBA-418D-AC65-5406CFFE4DE4}.Docker|Any CPU.ActiveCfg = Docker|Any CPU + {D34686EE-9BBA-418D-AC65-5406CFFE4DE4}.Docker|Any CPU.Build.0 = Docker|Any CPU + {D34686EE-9BBA-418D-AC65-5406CFFE4DE4}.Docker|x86.ActiveCfg = Docker|Any CPU + {D34686EE-9BBA-418D-AC65-5406CFFE4DE4}.Docker|x86.Build.0 = Docker|Any CPU + {D34686EE-9BBA-418D-AC65-5406CFFE4DE4}.Docker|x64.ActiveCfg = Docker|Any CPU + {D34686EE-9BBA-418D-AC65-5406CFFE4DE4}.Docker|x64.Build.0 = Docker|Any CPU + {D34686EE-9BBA-418D-AC65-5406CFFE4DE4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D34686EE-9BBA-418D-AC65-5406CFFE4DE4}.Release|Any CPU.Build.0 = Release|Any CPU + {D34686EE-9BBA-418D-AC65-5406CFFE4DE4}.Release|x86.ActiveCfg = Release|Any CPU + {D34686EE-9BBA-418D-AC65-5406CFFE4DE4}.Release|x86.Build.0 = Release|Any CPU + {D34686EE-9BBA-418D-AC65-5406CFFE4DE4}.Release|x64.ActiveCfg = Release|Any CPU + {D34686EE-9BBA-418D-AC65-5406CFFE4DE4}.Release|x64.Build.0 = Release|Any CPU + {D34686EE-9BBA-418D-AC65-5406CFFE4DE4}.Staging|Any CPU.ActiveCfg = Debug|Any CPU + {D34686EE-9BBA-418D-AC65-5406CFFE4DE4}.Staging|Any CPU.Build.0 = Debug|Any CPU + {D34686EE-9BBA-418D-AC65-5406CFFE4DE4}.Staging|x86.ActiveCfg = Debug|Any CPU + {D34686EE-9BBA-418D-AC65-5406CFFE4DE4}.Staging|x86.Build.0 = Debug|Any CPU + {D34686EE-9BBA-418D-AC65-5406CFFE4DE4}.Staging|x64.ActiveCfg = Debug|Any CPU + {D34686EE-9BBA-418D-AC65-5406CFFE4DE4}.Staging|x64.Build.0 = Debug|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1969,6 +2007,7 @@ Global {7D1F3C2A-5B8E-4E61-9A0C-3F2B6C9D8E41} = {D43D1D6B-66A9-4A57-9EA3-8DECC92FA583} {3C9E5B7A-2F14-4D8C-9E6B-7A1D5C3F8B92} = {D43D1D6B-66A9-4A57-9EA3-8DECC92FA583} {BDF50FEA-B18D-4E8F-9E5F-4BCA99FCB692} = {F06D475C-635C-4DE4-82BA-C49A90BA8FCD} + {D34686EE-9BBA-418D-AC65-5406CFFE4DE4} = {D43D1D6B-66A9-4A57-9EA3-8DECC92FA583} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {156116FF-243E-45E8-8717-DB72E95F56AF} diff --git a/Tests/Resgrid.Tests/AdminAssist/AdminAssistDatabaseTests.cs b/Tests/Resgrid.Tests/AdminAssist/AdminAssistDatabaseTests.cs new file mode 100644 index 000000000..9cc7780ed --- /dev/null +++ b/Tests/Resgrid.Tests/AdminAssist/AdminAssistDatabaseTests.cs @@ -0,0 +1,381 @@ +using System; +using System.Data.Common; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Dapper; +using FluentMigrator; +using FluentMigrator.Runner; +using FluentMigrator.Runner.Initialization; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Npgsql; +using NUnit.Framework; +using Resgrid.Config; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Providers.Migrations.Migrations; +using Resgrid.Providers.MigrationsPg.Migrations; +using Resgrid.Repositories.DataRepository; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Servers.SqlServer; +using Resgrid.Repositories.DataRepository.Transactions; + +namespace Resgrid.Tests.AdminAssist +{ + [TestFixture(DatabaseTypes.SqlServer), TestFixture(DatabaseTypes.Postgres), NonParallelizable] + public class AdminAssistDatabaseTests(DatabaseTypes type) + { + private string _master, _connection, _database; + private DatabaseTypes _previous; + private bool _configured, _created; + private ServiceProvider _runner; + private string Q(string name) => type == DatabaseTypes.Postgres ? name.ToLowerInvariant() : "[" + name + "]"; + private DbConnection Connect(string connection) => type == DatabaseTypes.Postgres ? new NpgsqlConnection(connection) : new SqlConnection(connection); + private IConnectionProvider Connections() + { + var connections = new Mock(); connections.Setup(c => c.Create()).Returns(() => Connect(_connection)); return connections.Object; + } + private AdminAssistRepository Repository(IUnitOfWork unit) => new(Connections(), type == DatabaseTypes.Postgres ? new PostgreSqlConfiguration() : new SqlServerConfiguration(), unit, Mock.Of()); + [OneTimeSetUp] + public async Task Create_isolated_database() + { + var configured = Environment.GetEnvironmentVariable(type == DatabaseTypes.Postgres ? "RESGRID_ADMINASSIST_POSTGRES_TEST_CONNECTION" : "RESGRID_ADMINASSIST_SQLSERVER_TEST_CONNECTION"); + if (string.IsNullOrWhiteSpace(configured)) Assert.Ignore("Configure an Admin Assist test server to execute the real database fixture."); + if (type == DatabaseTypes.Postgres) + { + var builder = new NpgsqlConnectionStringBuilder(configured); + if (!string.IsNullOrEmpty(builder.Database) && builder.Database != "postgres") throw new InvalidOperationException("Use an administrative test database, not an application database."); + builder.Database = "postgres"; builder.IncludeErrorDetail = false; _master = builder.ConnectionString; + } + else + { + var builder = new SqlConnectionStringBuilder(configured); + if (!string.IsNullOrEmpty(builder.InitialCatalog) && !string.Equals(builder.InitialCatalog, "master", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("Use a test server master connection."); + builder.InitialCatalog = "master"; _master = builder.ConnectionString; + } + _previous = DataConfig.DatabaseType; _configured = true; DataConfig.DatabaseType = type; + _database = "adminassist_verification_" + Guid.NewGuid().ToString("N"); + await using (var master = Connect(_master)) { await master.ExecuteAsync("CREATE DATABASE " + _database); _created = true; } + _connection = type == DatabaseTypes.Postgres ? new NpgsqlConnectionStringBuilder(_master) { Database = _database }.ConnectionString : new SqlConnectionStringBuilder(_master) { InitialCatalog = _database }.ConnectionString; + await using (var db = Connect(_connection)) + { + var text = type == DatabaseTypes.Postgres ? "varchar" : "nvarchar"; + var date = type == DatabaseTypes.Postgres ? "timestamp" : "datetime2"; + var boolean = type == DatabaseTypes.Postgres ? "boolean" : "bit"; + await db.ExecuteAsync($@"CREATE TABLE {Q("Departments")} ({Q("DepartmentId")} int PRIMARY KEY); +CREATE TABLE {Q("FeatureFlags")} ({Q("FlagKey")} {text}(128) PRIMARY KEY,{Q("Name")} {text}(128),{Q("Description")} {text}(512),{Q("Category")} {text}(128),{Q("IsEnabledGlobally")} {boolean}); +CREATE TABLE {Q("RmsRecordLegalHolds")} ({Q("DepartmentId")} int,{Q("ReleasedOn")} {date},{Q("RmsRecordLegalHoldId")} {text}(128),{Q("RecordId")} {text}(128),{Q("DefinitionKey")} {text}(128)); +CREATE TABLE {Q("DepartmentGroups")} ({Q("DepartmentId")} int,{Q("DepartmentGroupId")} int); +CREATE TABLE {Q("Documents")} ({Q("DepartmentId")} int,{Q("DocumentId")} int,{Q("RemoveOn")} {date}); +CREATE TABLE {Q("DepartmentMembers")} ({Q("DepartmentMemberId")} int,{Q("DepartmentId")} int,{Q("UserId")} {text}(128),{Q("IsDeleted")} {boolean},{Q("IsDisabled")} {boolean},{Q("IsHidden")} {boolean},{Q("PasswordLastSetOn")} {date}); +CREATE TABLE {Q("AspNetUsers")} ({Q("Id")} {text}(128) PRIMARY KEY,{Q("TwoFactorEnabled")} {boolean},{Q("AuthenticationGeneration")} bigint); +CREATE TABLE {Q("ActionLogs")} ({Q("ActionLogId")} int PRIMARY KEY,{Q("UserId")} {text}(128),{Q("DepartmentId")} int,{Q("ActionTypeId")} int,{Q("Timestamp")} {date},{Q("GeoLocationData")} {text}(128)); +INSERT INTO {Q("Departments")} VALUES (7),(8),(9),(10),(11),(12),(13);"); + } + var source = new Mock(); source.Setup(s => s.GetMigrations()).Returns(new IMigration[] { type == DatabaseTypes.Postgres ? new M0235_AddAdminAssistFoundationPg() : new M0235_AddAdminAssistFoundation() }); + _runner = new ServiceCollection().AddFluentMigratorCore().ConfigureRunner(r => { if (type == DatabaseTypes.Postgres) r.AddPostgres(); else r.AddSqlServer(); r.WithGlobalConnectionString(_connection); }).AddSingleton(source.Object).BuildServiceProvider(); + _runner.GetRequiredService().MigrateUp(); + } + [Test] + public async Task Trace_replay_is_idempotent_tenant_bound_and_respects_department_removal() + { + await using var db = Connect(_connection); + await db.ExecuteAsync($"INSERT INTO {Q("Departments")} VALUES (715),(716)"); + using var unit = new UnitOfWork(Connections()); var repository = Repository(unit); + var row = DispatchTraceQueueTests.Row(); row.DepartmentId = 715; + row.Content = System.Text.Json.JsonSerializer.Serialize(System.Text.Json.JsonSerializer.Deserialize(row.Content) with { DepartmentId = 715 }); + await repository.SaveTraceAsync(row, CancellationToken.None); await repository.SaveTraceAsync(row, CancellationToken.None); + Assert.That(await db.ExecuteScalarAsync($"SELECT COUNT(*) FROM {Q("AdminAssistDispatchTraces")} WHERE {Q("DepartmentId")}=715"), Is.EqualTo(1)); + row.DepartmentId = 716; + Assert.ThrowsAsync(async () => await repository.SaveTraceAsync(row, CancellationToken.None)); + Assert.That(await repository.TraceDepartmentExistsAsync(715, CancellationToken.None), Is.True); + await db.ExecuteAsync($"DELETE FROM {Q("AdminAssistDispatchTraces")} WHERE {Q("DepartmentId")}=715; DELETE FROM {Q("Departments")} WHERE {Q("DepartmentId")}=715"); + Assert.That(await repository.TraceDepartmentExistsAsync(715, CancellationToken.None), Is.False); + } + + [Test] + public async Task Bulk_group_membership_change_audit_and_revision_roll_back_together_on_sink_failure() + { + await using var db = Connect(_connection); + var text = type == DatabaseTypes.Postgres ? "varchar" : "nvarchar"; + await db.ExecuteAsync($"CREATE TABLE {Q("DepartmentGroupMembers")} ({Q("DepartmentGroupMemberId")} int,{Q("DepartmentGroupId")} int,{Q("DepartmentId")} int,{Q("UserId")} {text}(128)); CREATE TABLE {Q("AdminAssistAuditProof")} ({Q("Data")} {text}(4000)); INSERT INTO {Q("Departments")} VALUES (711); INSERT INTO {Q("DepartmentGroups")} VALUES (711,7001); INSERT INTO {Q("DepartmentGroupMembers")} VALUES (1,7001,711,'private-user-a'),(2,7001,711,'private-user-b')"); + var configuration = type == DatabaseTypes.Postgres ? (SqlConfiguration)new PostgreSqlConfiguration() : new SqlServerConfiguration(); + var queries = new Mock(); + queries.Setup(q => q.GetDeleteQuery()).Returns(new Resgrid.Repositories.DataRepository.Queries.DepartmentGroups.DeleteGroupMembersByGroupIdDidQuery(configuration).GetQuery()); + foreach (var fail in new[] { true, false }) + { + using var unit = new UnitOfWork(Connections()); var metadata = Repository(unit); + var audits = new Mock(); + audits.Setup(a => a.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), false)) + .Returns(async (row, token, firstLevelOnly) => { + await unit.Connection.ExecuteAsync($"INSERT INTO {Q("AdminAssistAuditProof")} VALUES (@Data)", new { row.Data }, unit.Transaction); + if (fail) throw new InvalidOperationException("Injected audit sink failure."); return row; + }); + var journal = new Resgrid.Services.AdminAssist.ConfigurationChangeJournal(unit, metadata, audits.Object, Mock.Of(), TimeProvider.System); + var groups = new DepartmentGroupMembersRepository(Connections(), configuration, unit, queries.Object, journal); + Assert.ThrowsAsync(async () => await groups.DeleteGroupMembersByGroupIdAsync(7001,712)); + if (fail) Assert.ThrowsAsync(async () => await groups.DeleteGroupMembersByGroupIdAsync(7001,711)); + else Assert.That(await groups.DeleteGroupMembersByGroupIdAsync(7001,711), Is.True); + Assert.That(await db.ExecuteScalarAsync($"SELECT COUNT(*) FROM {Q("DepartmentGroupMembers")}"), Is.EqualTo(fail ? 2 : 0)); + Assert.That(await db.ExecuteScalarAsync($"SELECT COUNT(*) FROM {Q("AdminAssistAuditProof")}"), Is.EqualTo(fail ? 0 : 1)); + Assert.That(await metadata.GetConfigurationRevisionAsync(711, CancellationToken.None), Is.EqualTo(fail ? 0 : 1)); + } + var data = await db.ExecuteScalarAsync($"SELECT {Q("Data")} FROM {Q("AdminAssistAuditProof")}"); + Assert.That(data, Does.Contain("BulkDelete").And.Not.Contain("private-user")); + } + + [Test] + public async Task Security_projection_reads_only_bounded_tenant_policy_member_and_session_metadata() + { + await using var db = Connect(_connection); + var text = type == DatabaseTypes.Postgres ? "varchar" : "nvarchar"; + var date = type == DatabaseTypes.Postgres ? "timestamp" : "datetime2"; + var boolean = type == DatabaseTypes.Postgres ? "boolean" : "bit"; + await db.ExecuteAsync($"CREATE TABLE {Q("DepartmentSecurityPolicies")} ({Q("DepartmentId")} int,{Q("RequireMfa")} {boolean},{Q("RequireSso")} {boolean},{Q("SessionTimeoutMinutes")} int,{Q("MaxConcurrentSessions")} int,{Q("PasswordExpirationDays")} int,{Q("MinPasswordLength")} int)"); + await db.ExecuteAsync($"CREATE TABLE {Q("DepartmentSsoConfigs")} ({Q("DepartmentId")} int,{Q("IsEnabled")} {boolean})"); + await db.ExecuteAsync($"CREATE TABLE {Q("UserSessions")} ({Q("UserSessionId")} {text}(128),{Q("UserId")} {text}(128),{Q("DepartmentId")} int,{Q("State")} int,{Q("CreatedOn")} {date},{Q("LastActiveOn")} {date},{Q("ExpiresOn")} {date},{Q("AuthenticationGeneration")} bigint)"); + var now = new DateTime(2026,9,24,12,0,0); + var args = new { False = false, True = true, Now = now, Old = now.AddHours(-1), Future = now.AddHours(1) }; + await db.ExecuteAsync($"INSERT INTO {Q("DepartmentMembers")} ({Q("DepartmentMemberId")},{Q("DepartmentId")},{Q("UserId")},{Q("IsDeleted")},{Q("IsDisabled")},{Q("IsHidden")},{Q("PasswordLastSetOn")}) VALUES (1,709,'security-a',@False,@False,@True,@Old),(2,709,'security-missing',@False,@False,@False,NULL),(3,709,'security-disabled',@False,@True,@False,NULL),(4,710,'security-other',@False,@False,@False,NULL)",args); + await db.ExecuteAsync($"INSERT INTO {Q("AspNetUsers")} ({Q("Id")},{Q("TwoFactorEnabled")},{Q("AuthenticationGeneration")}) VALUES ('security-a',@True,4); INSERT INTO {Q("DepartmentSsoConfigs")} VALUES (709,@True),(709,@False),(710,@True); INSERT INTO {Q("DepartmentSecurityPolicies")} VALUES (709,@True,@False,30,2,90,12)",args); + await db.ExecuteAsync($"INSERT INTO {Q("UserSessions")} VALUES ('a','security-a',709,0,@Old,@Now,@Future,4),('b','security-a',709,0,@Old,@Now,@Future,3),('expired','security-a',709,0,@Old,@Old,@Old,4),('revoked','security-a',709,1,@Old,@Now,@Future,4),('disabled','security-disabled',709,0,@Old,@Now,@Future,4),('foreign','security-other',710,0,@Old,@Now,@Future,4)",args); + using var unit = new UnitOfWork(Connections()); var repository = Repository(unit); + var policy = await repository.ReadSecurityPolicyAsync(709, CancellationToken.None); + Assert.That(policy.RequireMfa, Is.True); Assert.That(policy.MinPasswordLength, Is.EqualTo(12)); Assert.That(await repository.ReadSecurityPolicyAsync(710, CancellationToken.None), Is.Null); + var evidence = await repository.ReadSecurityImpactAsync(709, now, 10, CancellationToken.None); + Assert.That(evidence.Members.Count, Is.EqualTo(2)); Assert.That(evidence.Members[0].TwoFactorEnabled, Is.True); Assert.That(evidence.Members[1].TwoFactorEnabled, Is.Null); + Assert.That(evidence.Sessions.Select(s => s.Id), Is.EqualTo(new[] { "a", "b" })); Assert.That(evidence.Sessions[1].CurrentGeneration, Is.EqualTo(4)); Assert.That(evidence.EnabledSsoProviders, Is.EqualTo(1)); + Assert.That((await repository.ReadSecurityImpactAsync(709, now, 1, CancellationToken.None)).Members.Count, Is.EqualTo(2)); + } + + [Test] + public async Task Notification_projection_is_bounded_and_does_not_return_foreign_staffing_or_contact_content() + { + await using var db = Connect(_connection); + var text = type == DatabaseTypes.Postgres ? "varchar" : "nvarchar"; + var boolean = type == DatabaseTypes.Postgres ? "boolean" : "bit"; + await db.ExecuteAsync($"CREATE TABLE {Q("UserProfiles")} ({Q("UserProfileId")} int,{Q("UserId")} {text}(128),{Q("SendNotificationSms")} {boolean},{Q("MobileNumberVerified")} {boolean},{Q("SendNotificationEmail")} {boolean},{Q("EmailVerified")} {boolean},{Q("SendNotificationPush")} {boolean})"); + await db.ExecuteAsync($"CREATE TABLE {Q("UserStates")} ({Q("UserStateId")} int PRIMARY KEY,{Q("UserId")} {text}(128),{Q("DepartmentId")} int,{Q("State")} int)"); + await db.ExecuteAsync($"INSERT INTO {Q("DepartmentMembers")} ({Q("DepartmentMemberId")},{Q("DepartmentId")},{Q("UserId")},{Q("IsDeleted")},{Q("IsDisabled")},{Q("IsHidden")}) VALUES (1,707,'notify-a',@False,@False,@False),(2,707,'notify-b',@False,@False,@True),(3,707,'notify-c',@False,@False,@False),(4,707,'notify-disabled',@False,@True,@False),(5,708,'notify-other',@False,@False,@False)", new { False = false, True = true }); + await db.ExecuteAsync($"INSERT INTO {Q("UserProfiles")} VALUES (1,'notify-a',@True,NULL,@True,@False,@True); INSERT INTO {Q("UserStates")} VALUES (1,'notify-a',707,2),(2,'notify-a',708,42),(3,'notify-b',707,1)", new { False = false, True = true }); + using var unit = new UnitOfWork(Connections()); + var rows = await Repository(unit).ReadNotificationMembersAsync(707, 10, CancellationToken.None); + Assert.That(rows.Count, Is.EqualTo(3)); Assert.That(rows[0].StaffingKnown, Is.False); Assert.That(rows[0].Staffing, Is.Null); + Assert.That(rows[0].Sms, Is.True); Assert.That(rows[0].MobileVerified, Is.Null); Assert.That(rows[0].EmailVerified, Is.False); + Assert.That(rows[1].Staffing, Is.EqualTo(1)); Assert.That(rows[1].ProfileId, Is.Null); + Assert.That(rows[2].StaffingKnown, Is.True); Assert.That(rows[2].Staffing, Is.Zero); // matches owning getter's default Available + Assert.That((await Repository(unit).ReadNotificationMembersAsync(707, 1, CancellationToken.None)).Count, Is.EqualTo(2)); + } + + [Test] + public async Task Retention_preview_is_tenant_bounded_metadata_only_and_excludes_sticky_and_period_holds() + { + await using var db = Connect(_connection); + var text = type == DatabaseTypes.Postgres ? "varchar" : "nvarchar"; + var date = type == DatabaseTypes.Postgres ? "timestamp" : "datetime2"; + foreach (var table in new[] { "RmsOperationalRecords", "RmsIncidentReports" }) + { + var id = table == "RmsOperationalRecords" ? "RmsOperationalRecordId" : "RmsIncidentReportId"; + await db.ExecuteAsync($"CREATE TABLE {Q(table)} ({Q(id)} {text}(128),{Q("DepartmentId")} int,{Q("DefinitionKey")} {text}(128),{Q("State")} int,{Q("FinalizedOn")} {date},{Q("ModifiedOn")} {date},{Q("AmendsRevisionId")} {text}(128),{Q("RowVersion")} bigint,{Q("PurgedOn")} {date})"); + } + await db.ExecuteAsync($"CREATE TABLE {Q("RmsRecordLegalHoldMembers")} ({Q("DepartmentId")} int,{Q("HoldId")} {text}(128),{Q("RecordId")} {text}(128))"); + foreach (var table in new[] { "RmsCasualtyRescues", "RmsExposures" }) await db.ExecuteAsync($"CREATE TABLE {Q(table)} ({Q("DepartmentId")} int,{Q("RecordId")} {text}(128))"); + await db.ExecuteAsync($"INSERT INTO {Q("RmsOperationalRecords")} ({Q("RmsOperationalRecordId")},{Q("DepartmentId")},{Q("DefinitionKey")},{Q("State")},{Q("ModifiedOn")},{Q("RowVersion")}) VALUES ('a',705,'training',1,@Now,5),('b',705,'training',1,@Now,6),('c',705,'training',1,@Now,7),('foreign',706,'training',1,@Now,8)", new { Now = new DateTime(2020, 1, 1) }); + await db.ExecuteAsync($"INSERT INTO {Q("RmsRecordLegalHolds")} ({Q("DepartmentId")},{Q("RmsRecordLegalHoldId")},{Q("RecordId")},{Q("DefinitionKey")}) VALUES (705,'sticky','old',NULL),(705,'period',NULL,'training'),(706,'foreign','b',NULL)"); + await db.ExecuteAsync($"INSERT INTO {Q("RmsRecordLegalHoldMembers")} ({Q("DepartmentId")},{Q("HoldId")},{Q("RecordId")}) VALUES (705,'sticky','a')"); + using var unit = new UnitOfWork(Connections()); + var rows = await Repository(unit).ReadRetentionHeadersAsync(705, 1, CancellationToken.None); + Assert.That(rows.Select(r => r.RecordId), Is.EqualTo(new[] { "a", "b" })); // bound + one detects an incomplete sample + Assert.That(rows[0].HoldOrPermanentContent, Is.EqualTo(1)); Assert.That(rows[1].HoldOrPermanentContent, Is.Zero); + Assert.That(rows.All(r => r.HistoricalHoldUncertainty == 1), Is.True); + Assert.That(await db.ExecuteScalarAsync($"SELECT SUM({Q("RowVersion")}) FROM {Q("RmsOperationalRecords")}"), Is.EqualTo(26)); + } + + [Test] + public async Task Administrative_references_detect_cross_tenant_missing_and_expired_documents_without_loading_content() + { + await using var db = Connect(_connection); var now = new DateTime(2026, 9, 24, 12, 0, 0); + await db.ExecuteAsync($"INSERT INTO {Q("Documents")} ({Q("DepartmentId")},{Q("DocumentId")},{Q("RemoveOn")}) VALUES (703,9101,@Expired),(703,9102,@Soon),(703,9103,NULL),(704,9104,NULL)", new { Expired = now.AddDays(-1), Soon = now.AddDays(3) }); + await db.ExecuteAsync($"INSERT INTO {Q("DepartmentGroups")} ({Q("DepartmentId")},{Q("DepartmentGroupId")}) VALUES (703,9101),(704,9102)"); + using var unit = new UnitOfWork(Connections()); + var counts = await Repository(unit).ReadAdministrativeReferencesAsync(703, new[] { 9101,9102,9103,9104,9105 }, new[] { 9101,9102 }, now, CancellationToken.None); + Assert.That(counts.PolicyReferences, Is.EqualTo(5)); Assert.That(counts.UnavailablePolicies, Is.EqualTo(3)); + Assert.That(counts.ExpiringPolicies, Is.EqualTo(1)); Assert.That(counts.UnavailableSites, Is.EqualTo(1)); + } + + [Test] + public async Task Module_preview_counts_are_bounded_tenant_metadata_and_keep_hidden_current_members() + { + await using var db = Connect(_connection); + await db.ExecuteAsync($"INSERT INTO {Q("DepartmentMembers")} ({Q("DepartmentId")},{Q("UserId")},{Q("IsDeleted")},{Q("IsDisabled")},{Q("IsHidden")}) VALUES (701,'current',@False,@False,@False),(701,'hidden',@False,@False,@True),(701,'disabled',@False,@True,@False),(702,'other',@False,@False,@False)", new { False = false, True = true }); + await db.ExecuteAsync($"INSERT INTO {Q("Documents")} ({Q("DepartmentId")},{Q("DocumentId")}) VALUES (701,9001),(702,9002)"); + using var unit = new UnitOfWork(Connections()); + var result = await Repository(unit).ReadModuleImpactCountsAsync(701, "Documents", 20, CancellationToken.None); + Assert.That(result.Members, Is.EqualTo(2)); Assert.That(result.ContentRows, Is.EqualTo(1)); + Assert.ThrowsAsync(async () => await Repository(unit).ReadModuleImpactCountsAsync(701, "Documents", 1, CancellationToken.None)); + Assert.That(await db.ExecuteScalarAsync($"SELECT COUNT(*) FROM {Q("Documents")} WHERE {Q("DepartmentId")}=701"), Is.EqualTo(1)); + } + + [Test] + public async Task Legacy_workspace_upgrade_preserves_scope_and_review_uses_the_current_configuration_revision() + { + await using var db = Connect(_connection); + await db.ExecuteAsync($"INSERT INTO {Q("Departments")} VALUES (717); INSERT INTO {Q("AdminAssistWorkspaces")} ({Q("DepartmentId")},{Q("Revision")},{Q("Mode")},{Q("AreasJson")},{Q("CatalogVersion")},{Q("ModifiedOn")}) VALUES (717,3,0,@Areas,'previous',@Now)", + new { Areas = "{\"home\":0,\"security\":0,\"personnel\":1}", Now = DateTime.UtcNow }); + using var unit = new UnitOfWork(Connections()); var repository = Repository(unit); + var actor = new AdminAssistActor(717, "setup-admin"); + var original = await repository.GetWorkspaceAsync(717, actor.UserId, "current", CancellationToken.None); + Assert.That(original.Areas["personnel"], Is.EqualTo(SetupAreaChoice.LearnLater)); + var scoped = await repository.UpdateWorkspaceAsync(actor, new(3,"area","personnel","NotApplicable","current","OtherSystem"), CancellationToken.None); + Assert.That(scoped.AreaReasons["personnel"], Is.EqualTo(SetupAreaReason.OtherSystem)); + var revisited = await repository.UpdateWorkspaceAsync(actor, new(4,"revisit",CatalogVersion:"current",RevisitOnUtc:DateTime.UtcNow.AddDays(10)), CancellationToken.None); + Assert.That(revisited.RevisitOnUtc?.Kind, Is.EqualTo(DateTimeKind.Utc)); + var review = new SetupReviewEvidence("current", "0", DateTime.UtcNow, 12, 3, 4, 5, scoped.ScopeRevision); + var reviewed = await repository.UpdateWorkspaceAsync(actor, new(5,"review",CatalogVersion:"current") { ReviewEvidence = review }, CancellationToken.None); + Assert.That(reviewed.ReviewEvidence, Is.EqualTo(review)); + Assert.That(reviewed.ReviewedOnUtc?.Kind, Is.EqualTo(DateTimeKind.Utc)); + Assert.ThrowsAsync(async () => await repository.UpdateWorkspaceAsync(actor, + new(6,"review",CatalogVersion:"current") { ReviewEvidence = review with { SnapshotRevision = "1" } }, CancellationToken.None)); + Assert.That((await repository.GetWorkspaceAsync(717, actor.UserId, "current", CancellationToken.None)).Revision, Is.EqualTo(6)); + await repository.UpdateWorkspaceAsync(actor, new(6,"area","personnel","UseNow","current"), CancellationToken.None); + var updated = await repository.GetWorkspaceAsync(717, actor.UserId, "current", CancellationToken.None); + Assert.That(updated.AreaReasons.ContainsKey("personnel"), Is.False); + Assert.That(updated.ReviewEvidence.Unknown, Is.EqualTo(5)); + Assert.That(updated.ScopeRevision, Is.GreaterThan(updated.ReviewEvidence.ScopeRevision)); + Assert.That((await repository.GetHistoryAsync(717, actor.UserId, 0, 20, CancellationToken.None)).Any(h => h.BeforeCode == "NotApplicable:OtherSystem" && h.AfterCode == "UseNow"), Is.True); + Assert.That((await repository.GetWorkspaceAsync(718, actor.UserId, "current", CancellationToken.None)).ReviewEvidence, Is.Null); + } + + [Test] + public async Task Concurrent_first_workspace_save_has_one_winner() + { + async Task Save() + { + var unit = new UnitOfWork(Connections()); + try { await Repository(unit).UpdateWorkspaceAsync(new AdminAssistActor(7, "admin"), new SetupProgressCommand(0, "mode", Choice: "Review", CatalogVersion: "test"), CancellationToken.None); return true; } + catch (AdminAssistConcurrencyException) { return false; } + finally { unit.DiscardChanges(); } + } + var result = await Task.WhenAll(Save(), Save()); Assert.That(result.Count(winner => winner), Is.EqualTo(1)); + } + [Test] + public async Task Configuration_revision_and_history_roll_back_together() + { + var unit = new UnitOfWork(Connections()); var repository = Repository(unit); + await unit.CreateOrGetConnectionAsync(); await repository.LockConfigurationAsync(8, CancellationToken.None); + await repository.AppendConfigurationChangeAsync(8, "admin", "setting.EnableTextToCall", "false", "true", "correlation", CancellationToken.None); + unit.DiscardChanges(); + Assert.That(await repository.GetConfigurationRevisionAsync(8, CancellationToken.None), Is.Zero); + Assert.That(await repository.GetHistoryAsync(8, "admin", 0, 10, CancellationToken.None), Is.Empty); + + } + [Test] + public async Task Personal_history_is_filtered_before_pagination_and_never_crosses_tenant() + { + var repository = Repository(new UnitOfWork(Connections())); + await repository.UpdateWorkspaceAsync(new AdminAssistActor(9, "other"), new SetupProgressCommand(0, "learn", "feature", "true", "test"), CancellationToken.None); + await repository.UpdateWorkspaceAsync(new AdminAssistActor(9, "admin"), new SetupProgressCommand(1, "learn", "feature", "true", "test"), CancellationToken.None); + Assert.That((await repository.GetHistoryAsync(9, "admin", 0, 1, CancellationToken.None)).Single().ActorId, Is.EqualTo("admin")); + Assert.That(await repository.GetHistoryAsync(9, "admin", 1, 1, CancellationToken.None), Is.Empty); + Assert.That(await repository.GetHistoryAsync(8, "admin", 0, 10, CancellationToken.None), Is.Empty); + var dismissed = await repository.UpdateWorkspaceAsync(new AdminAssistActor(9, "other"), new SetupProgressCommand(2, "dismiss", Choice: "true", CatalogVersion: "test"), CancellationToken.None); + Assert.That(dismissed.SetupPromptDismissed, Is.True); + Assert.That(dismissed.LearnedCapabilityIds, Is.EquivalentTo(new[] { "feature" })); + Assert.That((await repository.GetWorkspaceAsync(9, "admin", "test", CancellationToken.None)).SetupPromptDismissed, Is.False); + Assert.That((await repository.GetHistoryAsync(9, "admin", 0, 20, CancellationToken.None)).Select(h => h.Action), Is.EquivalentTo(new[] { "learn" })); + } + [Test] + public async Task Administrative_status_projection_is_tenant_scoped_bounded_and_applies_the_reset_window() + { + await using var db = Connect(_connection); + var now = new DateTime(2026, 9, 24, 12, 0, 0, DateTimeKind.Utc); + await db.ExecuteAsync($"INSERT INTO {Q("AspNetUsers")} ({Q("Id")}) VALUES ('status-person'),('status-hidden'),('status-other'); INSERT INTO {Q("DepartmentMembers")} ({Q("DepartmentId")},{Q("UserId")},{Q("IsDeleted")},{Q("IsDisabled")},{Q("IsHidden")}) VALUES (13,'status-person',@False,@False,@False),(13,'status-hidden',@False,@False,@True),(7,'status-other',@False,@False,@False); INSERT INTO {Q("ActionLogs")} VALUES (1,'status-person',13,2,@Old,NULL),(2,'status-person',13,3,@Recent,NULL),(3,'status-person',7,4,@Recent,NULL),(4,'status-hidden',13,2,@Recent,NULL),(5,'status-other',7,2,@Recent,NULL)", + new { False = false, True = true, Old = DateTime.SpecifyKind(now.AddHours(-2), DateTimeKind.Unspecified), Recent = DateTime.SpecifyKind(now.AddMinutes(-30), DateTimeKind.Unspecified) }); + var unit = new UnitOfWork(Connections()); + var repository = new ActionLogsRepository(Connections(), type == DatabaseTypes.Postgres ? new PostgreSqlConfiguration() : new SqlServerConfiguration(), unit, Mock.Of()); + var current = await repository.ReadLatestForAdministrationAsync(13, false, now, 1, CancellationToken.None); + Assert.That(current.Single().ActionLogId, Is.EqualTo(2)); + await db.ExecuteAsync($"DELETE FROM {Q("ActionLogs")} WHERE {Q("ActionLogId")}=2"); + Assert.That(await repository.ReadLatestForAdministrationAsync(13, false, now, 1, CancellationToken.None), Is.Empty); + Assert.That((await repository.ReadLatestForAdministrationAsync(13, true, now, 1, CancellationToken.None)).Single().ActionLogId, Is.EqualTo(1)); + await db.ExecuteAsync($"INSERT INTO {Q("AspNetUsers")} ({Q("Id")}) VALUES ('status-second'); INSERT INTO {Q("DepartmentMembers")} ({Q("DepartmentId")},{Q("UserId")},{Q("IsDeleted")},{Q("IsDisabled")},{Q("IsHidden")}) VALUES (13,'status-second',@False,@False,@False); INSERT INTO {Q("ActionLogs")} VALUES (6,'status-second',13,2,@Recent,NULL)", new { False = false, Recent = DateTime.SpecifyKind(now.AddMinutes(-5), DateTimeKind.Unspecified) }); + Assert.ThrowsAsync(() => repository.ReadLatestForAdministrationAsync(13, true, now, 1, CancellationToken.None)); + } + [Test] + public async Task Profile_references_reject_cross_tenant_expired_and_non_numeric_identifiers() + { + await using var db = Connect(_connection); + await db.ExecuteAsync($"INSERT INTO {Q("DepartmentGroups")} VALUES (7,101),(8,102); INSERT INTO {Q("Documents")} VALUES (7,201,NULL),(8,202,NULL),(7,203,@Expired)", new { Expired = DateTime.SpecifyKind(DateTime.UtcNow.AddDays(-1), DateTimeKind.Unspecified) }); + var unit = new UnitOfWork(Connections()); var repository = Repository(unit); + await unit.CreateOrGetConnectionAsync(); + try + { + var profile = new DepartmentOperatingProfile { SiteGroupReferences = new() { "101" }, StaffingPolicyReferences = new() { "201" } }; + Assert.That(await repository.ValidateOperatingProfileReferencesAsync(7, profile, DateTime.UtcNow, CancellationToken.None), Is.True); + profile.SiteGroupReferences[0] = "102"; + Assert.That(await repository.ValidateOperatingProfileReferencesAsync(7, profile, DateTime.UtcNow, CancellationToken.None), Is.False); + profile.SiteGroupReferences[0] = "101"; + foreach (var invalid in new[] { "202", "203", "-1", "policy-name" }) + { + profile.StaffingPolicyReferences[0] = invalid; + Assert.That(await repository.ValidateOperatingProfileReferencesAsync(7, profile, DateTime.UtcNow, CancellationToken.None), Is.False); + } + } + finally { unit.DiscardChanges(); } + } + [Test] + public async Task Digest_cursor_rotates_past_a_full_page_even_when_recipients_are_quiet() + { + var repository = Repository(new UnitOfWork(Connections())); + Assert.That(await repository.TryLeaseAsync(12, Guid.NewGuid().ToString("D"), DateTime.UtcNow, CancellationToken.None), Is.True); + for (var i = 0; i < 55; i++) + await repository.SavePreferencesAsync(new AdminAssistActor(12, i.ToString("D3")), new AdminAssistPreferencesCommand(0, true, 0, 23), CancellationToken.None); + var first = await repository.GetDigestPreferencesAsync(12, CancellationToken.None); + Assert.That(first.Count, Is.EqualTo(50)); + await repository.AdvanceDigestCursorAsync(12, first.Last().UserId, CancellationToken.None); + var second = await repository.GetDigestPreferencesAsync(12, CancellationToken.None); + Assert.That(second.Count, Is.EqualTo(5)); + Assert.That(second.Select(p => p.UserId).Intersect(first.Select(p => p.UserId)), Is.Empty); + await repository.AdvanceDigestCursorAsync(12, second.Last().UserId, CancellationToken.None); + Assert.That((await repository.GetDigestPreferencesAsync(12, CancellationToken.None)).First().UserId, Is.EqualTo("000")); + } + [Test] + public async Task Digest_claim_is_single_winner_and_revoked_preferences_cannot_be_claimed() + { + var repository = Repository(new UnitOfWork(Connections())); var actor = new AdminAssistActor(10, "admin"); + await repository.SavePreferencesAsync(actor, new AdminAssistPreferencesCommand(0, true, 20, 8), CancellationToken.None); + var preference = await repository.GetPreferencesAsync(10, "admin", CancellationToken.None); + async Task Claim() => await Repository(new UnitOfWork(Connections())).ClaimDigestAsync(preference, "2026-09-21", DateTime.UtcNow, CancellationToken.None); + Assert.That((await Task.WhenAll(Claim(), Claim())).Count(v => v), Is.EqualTo(1)); + await repository.SavePreferencesAsync(actor, new AdminAssistPreferencesCommand(1, false, 20, 8), CancellationToken.None); + Assert.That(await repository.ClaimDigestAsync(preference, "2026-09-28", DateTime.UtcNow, CancellationToken.None), Is.False); + } + [Test] + public async Task Trace_write_is_idempotent_and_active_hold_prevents_retention() + { + var repository = Repository(new UnitOfWork(Connections())); + var row = new AdminAssistDispatchTraceRow { AdminAssistDispatchTraceId = Guid.NewGuid().ToString("D"), DepartmentId = 11, CallId = 1, AttemptId = Guid.NewGuid().ToString("D"), Stage = "Selected", ResolverVersion = "1", OccurredOn = DateTime.UtcNow.AddYears(-5), Content = "{}" }; + await repository.SaveTraceAsync(row, CancellationToken.None); await repository.SaveTraceAsync(row, CancellationToken.None); + await using var db = Connect(_connection); + Assert.That(await db.ExecuteScalarAsync($"SELECT COUNT(*) FROM {Q("AdminAssistDispatchTraces")}"), Is.EqualTo(1)); + await db.ExecuteAsync($"INSERT INTO {Q("RmsRecordLegalHolds")} ({Q("DepartmentId")}) VALUES (11)"); + Assert.That(await repository.PurgeExpiredMetadataAsync(11, DateTime.UtcNow, CancellationToken.None), Is.Zero); + await db.ExecuteAsync($"DELETE FROM {Q("RmsRecordLegalHolds")} WHERE {Q("DepartmentId")}=11"); + Assert.That(await repository.PurgeExpiredMetadataAsync(11, DateTime.UtcNow, CancellationToken.None), Is.EqualTo(1)); + } + [OneTimeTearDown] + public async Task Remove_only_this_fixture_database() + { + _runner?.Dispose(); if (_configured) DataConfig.DatabaseType = _previous; + if (!_created) return; + const string prefix = "adminassist_verification_"; + if (!_database.StartsWith(prefix, StringComparison.Ordinal) || !Guid.TryParseExact(_database.Substring(prefix.Length), "N", out _)) throw new InvalidOperationException("Unexpected disposable database name."); + if (type == DatabaseTypes.Postgres) NpgsqlConnection.ClearAllPools(); else SqlConnection.ClearAllPools(); + await using var master = Connect(_master); + await master.ExecuteAsync(type == DatabaseTypes.Postgres ? "DROP DATABASE " + _database + " WITH (FORCE)" : "ALTER DATABASE " + _database + " SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE " + _database); + } + } +} diff --git a/Tests/Resgrid.Tests/AdminAssist/AdminIdentityEvidenceTests.cs b/Tests/Resgrid.Tests/AdminAssist/AdminIdentityEvidenceTests.cs new file mode 100644 index 000000000..9954a9c0e --- /dev/null +++ b/Tests/Resgrid.Tests/AdminAssist/AdminIdentityEvidenceTests.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Identity; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services.AdminAssist; + +namespace Resgrid.Tests.AdminAssist +{ + [TestFixture] + public class AdminIdentityEvidenceTests + { + [TestCase(false)] [TestCase(true)] + public async Task Hidden_current_admins_are_counted_group_admins_are_not_double_counted_and_missing_group_data_is_unknown(bool missingGroups) + { + var members = new Mock(); var groups = new Mock(); var departments = new Mock(); var users = new Mock(); var visibility = new Mock(); + departments.Setup(d => d.GetDepartmentByIdAsync(7, true)).ReturnsAsync(new Department { DepartmentId = 7, ManagingUserId = "owner" }); + members.Setup(m => m.GetAllDepartmentMembersUnlimitedAsync(7)).ReturnsAsync(new List { + new() { DepartmentId = 7, UserId = "owner", IsHidden = true }, new() { DepartmentId = 7, UserId = "admin", IsAdmin = true }, + new() { DepartmentId = 7, UserId = "group" }, new() { DepartmentId = 7, UserId = "disabled", IsAdmin = true, IsDisabled = true } + }); + groups.Setup(g => g.GetAllGroupsByDepartmentIdAsync(7)).ReturnsAsync(new List { new() { DepartmentId = 7, DepartmentGroupId = 1, Members = missingGroups ? null : new List { + new() { DepartmentId = 7, DepartmentGroupId = 1, UserId = "admin", IsAdmin = true }, new() { DepartmentId = 7, DepartmentGroupId = 1, UserId = "group", IsAdmin = true } + } } }); + users.Setup(u => u.GetUserById(It.IsAny(), true)).Returns((string id, bool _) => new IdentityUser { UserId = id, TwoFactorEnabled = id == "admin" }); + visibility.Setup(v => v.CanUserViewPersonAsync("viewer", It.IsAny(), 7)).ReturnsAsync(true); + var source = new AdminIdentityEvidenceSource(members.Object, groups.Object, departments.Object, users.Object, visibility.Object); + var facts = (await source.ReadAsync(new(7, "viewer"), DateTime.UtcNow, CancellationToken.None)).ToDictionary(f => f.Id); + Assert.That(facts["activeAdminCount"].Number, Is.EqualTo(2)); + Assert.That(facts["adminsWithoutMfa"].Number, Is.EqualTo(1)); + Assert.That(facts["groupOnlyAdminsWithoutMfa"].State, Is.EqualTo(missingGroups ? EvidenceState.Unknown : EvidenceState.Known)); + Assert.That(facts["groupOnlyAdminsWithoutMfa"].Number, Is.EqualTo(missingGroups ? (decimal?)null : 1)); + users.Verify(u => u.GetUserById("disabled", true), Times.Never); + } + } +} diff --git a/Tests/Resgrid.Tests/AdminAssist/AdministrativeReferenceTests.cs b/Tests/Resgrid.Tests/AdminAssist/AdministrativeReferenceTests.cs new file mode 100644 index 000000000..5b1e5dd43 --- /dev/null +++ b/Tests/Resgrid.Tests/AdminAssist/AdministrativeReferenceTests.cs @@ -0,0 +1,41 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Services; +using Resgrid.Services.AdminAssist; + +namespace Resgrid.Tests.AdminAssist +{ + [TestFixture] + public class AdministrativeReferenceTests + { + [Test] + public async Task Duplicate_policy_links_are_checked_once_and_only_metadata_counts_leave_the_adapter() + { + var settings = new Mock(); var store = new Mock(); var now = DateTime.UtcNow; + settings.Setup(s => s.GetOperatingProfileAsync(7)).ReturnsAsync(new DepartmentOperatingProfile { + StaffingPolicyReferences = new() { "1234567" }, QualificationPolicyReferences = new() { "1234567" }, ContinuityProcedureReferences = new() { "9876543" }, SiteGroupReferences = new() { "7" }, AuthoritativeSystemReferences = new() { "private-system" } }); + store.Setup(s => s.ReadAdministrativeReferencesAsync(7, It.Is(ids => ids.Length == 2 && ids.Contains(1234567) && ids.Contains(9876543)), It.Is(ids => ids.SequenceEqual(new[] { 7 })), now, It.IsAny())) + .ReturnsAsync(new AdministrativeReferenceCounts(2, 1, 1, 1, 0)); + var result = await new AdministrativeReferenceEvidenceSource(settings.Object, store.Object).ReadAsync(new(7, "admin"), now, CancellationToken.None); + Assert.That(result.Single(e => e.Id == "unavailablePolicyReferences").Number, Is.EqualTo(1)); + Assert.That(result.Single(e => e.Id == "declaredContinuityReferences").Number, Is.EqualTo(1)); + Assert.That(Newtonsoft.Json.JsonConvert.SerializeObject(result), Does.Not.Contain("1234567").And.Not.Contain("9876543").And.Not.Contain("private-system")); + } + [Test] + public void Missing_or_changing_reference_source_is_not_known_zero() + { + var settings = new Mock(); var store = new Mock(); + settings.Setup(s => s.GetOperatingProfileAsync(7)).ReturnsAsync(new DepartmentOperatingProfile()); + var source = new AdministrativeReferenceEvidenceSource(settings.Object, store.Object); + Assert.ThrowsAsync(async () => await source.ReadAsync(new(7, "admin"), DateTime.UtcNow, CancellationToken.None)); + store.SetupSequence(s => s.ReadAdministrativeReferencesAsync(7, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new AdministrativeReferenceCounts(1, 0, 0, 0, 0)).ReturnsAsync(new AdministrativeReferenceCounts(1, 1, 0, 0, 0)); + Assert.ThrowsAsync(async () => await source.ReadAsync(new(7, "admin"), DateTime.UtcNow, CancellationToken.None)); + } + } +} diff --git a/Tests/Resgrid.Tests/AdminAssist/CapabilityAccessTests.cs b/Tests/Resgrid.Tests/AdminAssist/CapabilityAccessTests.cs new file mode 100644 index 000000000..9cc012ff7 --- /dev/null +++ b/Tests/Resgrid.Tests/AdminAssist/CapabilityAccessTests.cs @@ -0,0 +1,35 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Resgrid.AdminAssist; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Services; +using Resgrid.Services.AdminAssist; + +namespace Resgrid.Tests.AdminAssist +{ + [TestFixture] + public class CapabilityAccessTests + { + [TestCase(true)] [TestCase(false)] + public async Task Addon_education_keeps_billing_handoff_separate_from_entitlement(bool managingMember) + { + var actor = new AdminAssistActor(7, "admin"); + var membership = new Mock(); + membership.Setup(m => m.IsActiveMemberAsync(actor.UserId, actor.DepartmentId)).ReturnsAsync(true); + membership.Setup(m => m.IsDepartmentAdminAsync(actor.UserId, actor.DepartmentId)).ReturnsAsync(true); + var authorization = new Mock(); + authorization.Setup(a => a.CanUserManageSubscriptionAsync(actor.UserId, actor.DepartmentId)).ReturnsAsync(managingMember); + var service = new AdminAssistAccessService(membership.Object, Mock.Of(), Mock.Of(), + Mock.Of(), Mock.Of(), Mock.Of(), + Mock.Of(), new ConfigurationCatalog(), TimeProvider.System, authorization.Object); + var features = await service.GetCapabilitiesAsync(actor); + var addon = features.Single(f => f.CapabilityId == "addon-readiness"); + Assert.That(addon.CanConfigure, Is.False); + Assert.That(addon.SubscriptionDestination, Is.EqualTo(managingMember ? "/User/Subscription/Index" : null)); + Assert.That(features.Single(f => f.CapabilityId == "addon-ai").SubscriptionDestination, Is.Null); + } + } +} diff --git a/Tests/Resgrid.Tests/AdminAssist/CapabilitySetupTests.cs b/Tests/Resgrid.Tests/AdminAssist/CapabilitySetupTests.cs new file mode 100644 index 000000000..e0b24541b --- /dev/null +++ b/Tests/Resgrid.Tests/AdminAssist/CapabilitySetupTests.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using Resgrid.AdminAssist; +using Resgrid.Model.AdminAssist; + +namespace Resgrid.Tests.AdminAssist +{ + [TestFixture] + public class CapabilitySetupTests + { + private readonly ConfigurationCatalog _catalog = new(); + private readonly DateTime _now = new(2026, 9, 24, 12, 0, 0, DateTimeKind.Utc); + private CapabilitySetupAssessment Evaluate(decimal? count, RuleResult result = RuleResult.Pass, EvidenceState factState = EvidenceState.Known, + bool consistent = true, bool subscribed = false, EvidenceState accessState = EvidenceState.Known, EvidenceState? commercial = null, bool stale = false) + { + var capability = _catalog.Capabilities.Single(c => c.Id == "units"); + if (subscribed) capability = capability with { Requirements = new[] { new CapabilityRequirement("addon", "ReadinessPro") } }; + var fact = new ConfigurationEvidence("unitCount", factState, "test", "1", stale ? _now.AddMinutes(-5) : _now, Number: count); + var snapshot = new ConfigurationSnapshot(7,"admin","19",_now,consistent,new Dictionary{{fact.Id,fact}}); + var findings = capability.Setup.RuleIds.Select(id => new ConfigurationFinding(id, "location", FindingSeverity.Warning, result, "title", "why", "next", "/User/Units/Index", Array.Empty(), "19", _now)).ToArray(); + var access = new CapabilityAccess(capability.Id, accessState, Array.Empty(), true, "/User/Units/Index", _now, CommercialState: commercial); + return CapabilitySetupEvaluator.Evaluate(capability, access, new ConfigurationReport(snapshot,findings,new[]{"location"}),_now,TimeSpan.FromMinutes(1)); + } + [Test] + public void No_configuration_does_not_pass_even_if_absence_makes_the_health_rules_pass() + { + var result = Evaluate(0); + Assert.That(result.State, Is.EqualTo(CapabilitySetupState.NotConfigured)); + Assert.That(result.OpportunityKey, Is.EqualTo("Ui.OpportunityIncludedNotConfigured")); + } + [TestCase(RuleResult.Pass, CapabilitySetupState.ChecksPassed)] + [TestCase(RuleResult.Fail, CapabilitySetupState.NeedsAttention)] + [TestCase(RuleResult.Unknown, CapabilitySetupState.ConfigurationPresent)] + [TestCase(RuleResult.NotApplicable, CapabilitySetupState.ConfigurationPresent)] + public void Only_all_current_applicable_checks_verify_recorded_configuration(RuleResult rule, CapabilitySetupState expected) => + Assert.That(Evaluate(2, rule).State, Is.EqualTo(expected)); + [TestCase(EvidenceState.Unknown)][TestCase(EvidenceState.Redacted)][TestCase(EvidenceState.Unavailable)] + public void An_unavailable_source_is_neither_empty_nor_verified(EvidenceState state) => + Assert.That(Evaluate(null, factState: state).State, Is.EqualTo(CapabilitySetupState.NotAssessed)); + [Test] + public void Stale_and_inconsistent_observations_cannot_verify_setup() + { + Assert.That(Evaluate(2, stale: true).State, Is.EqualTo(CapabilitySetupState.NotAssessed)); + Assert.That(Evaluate(2, consistent: false).State, Is.EqualTo(CapabilitySetupState.NotAssessed)); + Assert.That(Evaluate(2, accessState: EvidenceState.Unavailable).State, Is.EqualTo(CapabilitySetupState.NotAssessed)); + } + [Test] + public void Subscription_and_setup_are_independent_dimensions() + { + Assert.That(Evaluate(0, subscribed:true, commercial:EvidenceState.Known).OpportunityKey, Is.EqualTo("Ui.OpportunitySubscribedNotConfigured")); + Assert.That(Evaluate(null, subscribed:true, factState:EvidenceState.Unknown, commercial:EvidenceState.Known).OpportunityKey, Is.EqualTo("Ui.OpportunitySubscribed")); + Assert.That(Evaluate(null, subscribed:true, accessState:EvidenceState.Unavailable, commercial:EvidenceState.Unavailable).OpportunityKey, Is.EqualTo("Ui.OpportunityNotOwned")); + Assert.That(Evaluate(null, subscribed:true, commercial:EvidenceState.Unknown).OpportunityKey, Is.EqualTo("Ui.OpportunityUnknown")); + } + } +} diff --git a/Tests/Resgrid.Tests/AdminAssist/CapacityImpactTests.cs b/Tests/Resgrid.Tests/AdminAssist/CapacityImpactTests.cs new file mode 100644 index 000000000..ab09984a5 --- /dev/null +++ b/Tests/Resgrid.Tests/AdminAssist/CapacityImpactTests.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Resgrid.AdminAssist; +using Resgrid.Model; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.AdminAssist +{ + [TestFixture, NonParallelizable] + public class CapacityImpactTests + { + private readonly DateTime _now = new(2026, 9, 24, 12, 0, 0, DateTimeKind.Utc); + private ConfigurationSnapshot Snapshot(string kind, params (string, decimal)[] values) + { + var facts = values.ToDictionary(v => v.Item1, v => new ConfigurationEvidence(v.Item1, EvidenceState.Known, "test", "1", _now, Number: v.Item2)); + facts["capacityKind"] = new("capacityKind", EvidenceState.Known, "test", "1", _now, Code: kind); + return new(7, "admin", "3", _now, true, facts); + } + [Test] + public void Entity_plan_combines_personnel_and_units_and_shows_exceeded_capacity() + { + var snapshot = Snapshot("entities", ("capacityPersonnelCount", 8), ("capacityUnitCount", 2), ("capacityEntityLimit", 15)); + var report = CapacityImpactEvaluator.Evaluate(snapshot, new("3", 12, 6), _now, TimeSpan.FromMinutes(1)); + var headroom = report.Metrics.Single(m => m.LabelKey == "Impact.SharedHeadroom"); + Assert.That(headroom.Before, Is.EqualTo(5)); Assert.That(headroom.After, Is.EqualTo(-3)); + Assert.That(snapshot.Find("capacityPersonnelCount").Number, Is.EqualTo(8)); + } + [Test] + public void Separate_allowances_include_zero_capacity_without_interpreting_it_as_unlimited() + { + var snapshot = Snapshot("separate", ("capacityPersonnelCount", 8), ("capacityUnitCount", 0), ("capacityPersonnelLimit", 10), ("capacityUnitLimit", 0)); + var report = CapacityImpactEvaluator.Evaluate(snapshot, new("3", 9, 1), _now, TimeSpan.FromMinutes(1)); + Assert.That(report.Metrics.Single(m => m.LabelKey == "Impact.PersonnelHeadroom").After, Is.EqualTo(1)); + Assert.That(report.Metrics.Single(m => m.LabelKey == "Impact.UnitHeadroom").After, Is.EqualTo(-1)); + } + [Test] + public void Missing_and_stale_limits_are_unknown_and_proposed_counts_are_still_identified() + { + var snapshot = Snapshot("entities", ("capacityPersonnelCount", 8), ("capacityUnitCount", 2)); + var report = CapacityImpactEvaluator.Evaluate(snapshot, new("3", 10, 3), _now, TimeSpan.FromMinutes(1)); + Assert.That(report.Metrics.Last().State, Is.EqualTo(EvidenceState.Unknown)); Assert.That(report.Metrics.Last().After, Is.Null); + var stale = CapacityImpactEvaluator.Evaluate(snapshot, new("3", 10, 3), _now.AddMinutes(2), TimeSpan.FromMinutes(1)); + Assert.That(stale.Metrics.Last().After, Is.Null); Assert.That(stale.Metrics[0].Before, Is.Null); Assert.That(stale.Metrics[0].After, Is.EqualTo(10)); + } + [Test] + public void Invalid_totals_and_stale_revision_are_rejected() + { + Assert.Throws(() => CapacityImpactEvaluator.Evaluate(Snapshot("entities"), new("3", -1, 0), _now, TimeSpan.FromMinutes(1))); + Assert.Throws(() => CapacityImpactEvaluator.Evaluate(Snapshot("entities"), new("3", 0, 1000001), _now, TimeSpan.FromMinutes(1))); + Assert.Throws(() => CapacityImpactEvaluator.Evaluate(Snapshot("entities"), new("2", 1, 1), _now, TimeSpan.FromMinutes(1))); + } + [Test] + public async Task Fresh_limit_read_also_bypasses_the_underlying_plan_cache() + { + var previous = Resgrid.Config.SystemBehaviorConfig.RedirectHomeToLogin; + Resgrid.Config.SystemBehaviorConfig.RedirectHomeToLogin = false; + try + { + var subscription = new Mock(); + subscription.Setup(s => s.GetCurrentPlanForDepartmentAsync(7, true)).ReturnsAsync(new Plan { PlanId = 36, PlanLimits = new List { new() { LimitType = (int)PlanLimitTypes.Entities, LimitValue = 25 } } }); + subscription.Setup(s => s.GetPlanCountsForDepartmentAsync(7)).ReturnsAsync(new DepartmentPlanCount { UsersCount = 10, UnitsCount = 3 }); + var service = new LimitsService(subscription.Object, Mock.Of()); + var limits = await service.GetLimitsForEntityPlanWithFallbackAsync(7, true); + Assert.That(limits.EntityTotal, Is.EqualTo(25)); + subscription.Verify(s => s.GetCurrentPlanForDepartmentAsync(7, true), Times.Once); + subscription.Verify(s => s.GetCurrentPlanForDepartmentAsync(7, false), Times.Never); + } + finally { Resgrid.Config.SystemBehaviorConfig.RedirectHomeToLogin = previous; } + } + } +} diff --git a/Tests/Resgrid.Tests/AdminAssist/CatalogTests.cs b/Tests/Resgrid.Tests/AdminAssist/CatalogTests.cs new file mode 100644 index 000000000..8fdd4c14b --- /dev/null +++ b/Tests/Resgrid.Tests/AdminAssist/CatalogTests.cs @@ -0,0 +1,140 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Reflection; +using System.Resources; +using Microsoft.AspNetCore.Mvc; +using NUnit.Framework; +using Resgrid.AdminAssist; +using Resgrid.Model; +using Resgrid.Model.AdminAssist; +using Resgrid.Search; + +namespace Resgrid.Tests.AdminAssist +{ + [TestFixture] + public class CatalogTests + { + private static readonly ConfigurationCatalog Catalog = new(); + [Test] + public void Every_department_setting_has_an_inventory_entry() + { + Assert.That(Catalog.Settings.Where(s => s.Binding.StartsWith("DepartmentSettingTypes.")).Select(s => s.Binding.Substring("DepartmentSettingTypes.".Length)), Is.EquivalentTo(Enum.GetNames())); + Assert.That(Catalog.Rules.Count, Is.GreaterThanOrEqualTo(25)); + Assert.That(Catalog.Packs.Select(p => p.Id), Is.EquivalentTo(DepartmentOperatingProfile.ArchetypeCodes)); + } + [Test] + public void Every_permission_has_a_reviewed_entry_or_an_owned_unexpired_inventory_gap() + { + var permissions = Catalog.Settings.Where(s => s.Binding.StartsWith("PermissionTypes.")).ToArray(); + Assert.That(permissions.Select(s => s.Binding.Substring("PermissionTypes.".Length)), Is.EquivalentTo(Enum.GetNames())); + foreach (var entry in permissions.Where(s => s.Availability == "review-required")) + { + Assert.That(entry.Owner, Is.Not.Null.And.Not.Empty, entry.Id); + Assert.That(entry.ReviewGap, Is.Not.Null.And.Not.Empty, entry.Id); + Assert.That(entry.ReviewGapExpiresOn, Is.Not.Null, entry.Id); + Assert.That(entry.ReviewGapExpiresOn.Value.Date, Is.GreaterThanOrEqualTo(DateTime.UtcNow.Date), "Expired permission consumer-review gap: " + entry.Id); + } + } + + [Test] + public void Public_search_actions_addons_and_explicit_requirement_ids_have_catalog_coverage() + { + var ids = Catalog.Capabilities.Select(c => c.Id).ToHashSet(StringComparer.Ordinal); + foreach (var action in Resgrid.Services.Search.SystemActionCatalog.All) + Assert.That(ids.Contains(action.Key), Is.True, action.Key); + var addons = Catalog.Capabilities.Where(c => c.Id.StartsWith("addon-")).SelectMany(c => c.Requirements).Where(r => r.Kind == "addon").Select(r => r.Id).ToHashSet(); + foreach (var addon in Enum.GetNames()) Assert.That(addons.Contains(addon), Is.True, addon); + foreach (var requirement in Catalog.Capabilities.SelectMany(c => c.Requirements).Where(r => r.Kind == "permission")) + Assert.That(Enum.TryParse(requirement.Id, out var permission) && Enum.IsDefined(permission), Is.True, requirement.Id); + } + [Test] + public void Serialized_configuration_fields_have_independent_catalog_entries_including_nested_list_elements() + { + foreach (var type in new[] { typeof(DepartmentModuleSettings), typeof(DepartmentOperatingProfile), typeof(PersonnelListStatusOrderSetting), + typeof(PersonnelListStatusOrder), typeof(DepartmentSuppressStaffingInfo), typeof(UnitTypeCallStatusOverrideSetting), typeof(UnitTypeCallStatusOverride), + typeof(UnitStatusThresholds), typeof(UnitStatusThreshold), typeof(NewCallFieldPolicy), typeof(NewCallFieldRule), typeof(GroupDispatchScopeConfig), + typeof(DispatchRecommendationConfig), typeof(RecordsNumberingConfig), typeof(RecordsSearchConfig), typeof(RecordsRetentionPolicy), + typeof(RecordsRetentionOverride), typeof(RecordsRetentionPolicyVersion), typeof(RecordsDisclosureConfig) }) + { + var fields = type.GetProperties().Where(p => p.GetCustomAttribute() != null).Select(p => type.Name + "." + p.Name); + Assert.That(Catalog.Settings.Where(s => s.Binding.StartsWith(type.Name + ".")).Select(s => s.Binding), Is.EquivalentTo(fields)); + } + } + [Test] + public void Dedicated_configuration_tables_have_field_inventory_without_secret_values() + { + var metadata = new[] { "DepartmentSecurityPolicyId", "DepartmentSsoConfigId", "DepartmentCallEmailId", "WeatherAlertZoneId", "Id", "DepartmentNotificationId", "DepartmentId", "CreatedOn", "UpdatedOn", "CreatedAt", "UpdatedAt", "CreatedByUserId", "UpdatedByUserId", "ReferringDepartmentId", "AffiliateCode" }.ToHashSet(); + foreach (var type in new[] { typeof(Department), typeof(DepartmentSecurityPolicy), typeof(DepartmentSsoConfig), typeof(DepartmentCallEmail), typeof(WeatherAlertZone), typeof(ChatbotDepartmentConfig), typeof(DepartmentNotification) }) + { + var fields = type.GetProperties().Where(p => p.CanWrite && p.GetCustomAttribute() == null && + !metadata.Contains(p.Name) && (p.PropertyType.IsValueType || p.PropertyType == typeof(string))).Select(p => type.Name + "." + p.Name); + Assert.That(Catalog.Settings.Where(s => s.Id.StartsWith("table.") && s.Binding.StartsWith(type.Name + ".")).Select(s => s.Binding), Is.EquivalentTo(fields), type.Name); + } + foreach (var field in new[] { "Department.ApiKey", "Department.SharedSecret", "DepartmentSsoConfig.EncryptedClientSecret", "DepartmentSsoConfig.EncryptedSigningCertificate", "DepartmentSsoConfig.EncryptedScimBearerToken", "DepartmentCallEmail.Password", "ChatbotDepartmentConfig.LlmApiKey" }) + Assert.That(Catalog.Settings.Single(s => s.Binding == field).Secret, Is.True, field); + } + + [Test] + public void Source_destinations_are_existing_GET_actions() + { + var assembly = typeof(Resgrid.Web.Areas.User.Controllers.DepartmentController).Assembly; + foreach (var location in Catalog.Settings.Select(s => s.Location).Concat(Catalog.Capabilities.Select(c => c.Location)).Concat(Catalog.Rules.Select(r => r.Location)).Distinct()) + { + var type = assembly.GetType("Resgrid.Web.Areas.User.Controllers." + location.Controller + "Controller"); + Assert.That(type, Is.Not.Null, location.Url); + Assert.That(type.GetMethods().Any(m => m.Name == location.Action && m.GetCustomAttribute() == null && m.GetCustomAttribute() == null), Is.True, location.Url); + } + } + [TestCase("ar")][TestCase("de")][TestCase("el")][TestCase("es")][TestCase("fr")][TestCase("it")][TestCase("pl")][TestCase("sv")][TestCase("uk")] + public void Core_navigation_and_guidance_boundary_have_real_locale_resources(string locale) + { + var resources = new ResourceManager(typeof(Resgrid.Localization.Areas.User.AdminAssist.AdminAssist)); + var culture = CultureInfo.GetCultureInfo(locale); + foreach (var name in new[] { "overview", "wizard", "report", "explore", "health", "worklist", "reference", "history", "ReportBoundary", "Unknown", "Redacted" }) + { + var localized = resources.GetString("Ui." + name, culture); + Assert.That(localized, Is.Not.Null.And.Not.Empty, locale + ": " + name); + Assert.That(localized, Is.Not.EqualTo(resources.GetString("Ui." + name, CultureInfo.GetCultureInfo("en"))), locale + ": " + name); + } + Assert.That(culture.TextInfo.IsRightToLeft, Is.EqualTo(locale == "ar")); + } + + [Test] + public void English_catalog_resource_references_resolve_without_key_fallback() + { + var resources = new ResourceManager(typeof(Resgrid.Localization.Areas.User.AdminAssist.AdminAssist)); + var objects = Catalog.Settings.Cast().Concat(Catalog.Capabilities).Concat(Catalog.Areas).Concat(Catalog.Packs).Concat(Catalog.Rules).Concat(Catalog.Settings.Select(s => s.Impact)); + foreach (var item in objects) + foreach (var property in item.GetType().GetProperties().Where(p => p.Name.EndsWith("Key") && p.PropertyType == typeof(string))) + { + var key = (string)property.GetValue(item); + Assert.That(resources.GetString(key, CultureInfo.GetCultureInfo("en")), Is.Not.Null.And.Not.Empty, key); + } + } + [Test] + public void Reference_search_is_literal_bounded_and_cites_the_release_pack() + { + using var search = new AdminAssistReferenceSearch(Catalog); + var results = search.Search("Mapbox token", "en", 100); + Assert.That(results, Is.Not.Empty); Assert.That(results.Count, Is.LessThanOrEqualTo(25)); + Assert.That(results.All(r => r.PackVersion == Catalog.Version && r.SourcePath.StartsWith("docs/admin-assist/") && !string.IsNullOrEmpty(r.Anchor)), Is.True); + Assert.DoesNotThrow(() => search.Search("( token : * ) OR", "en")); + Assert.Throws(() => search.Search(new string('x', 257), "en")); + } + [Test] + public void Embedded_documentation_anchors_resolve_and_onboarding_is_searchable() + { + var root = new System.IO.DirectoryInfo(TestContext.CurrentContext.TestDirectory); + while (root != null && !System.IO.File.Exists(System.IO.Path.Combine(root.FullName, "Resgrid.sln"))) root = root.Parent; + Assert.That(root, Is.Not.Null); + foreach (var article in Catalog.Articles) + { + var path = System.IO.Path.Combine(root.FullName, article.SourcePath); + Assert.That(System.IO.File.ReadAllText(path), Does.Contain(""), article.Id); + } + using var search = new AdminAssistReferenceSearch(Catalog); + Assert.That(search.Search("resume setup", "en").Any(hit => hit.Id.StartsWith("guide.")), Is.True); + } + } +} diff --git a/Tests/Resgrid.Tests/AdminAssist/ConfigurationAuditTests.cs b/Tests/Resgrid.Tests/AdminAssist/ConfigurationAuditTests.cs new file mode 100644 index 000000000..cbfea4fb8 --- /dev/null +++ b/Tests/Resgrid.Tests/AdminAssist/ConfigurationAuditTests.cs @@ -0,0 +1,40 @@ +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Repositories.DataRepository; + +namespace Resgrid.Tests.AdminAssist +{ + [TestFixture] + public class ConfigurationAuditTests + { + [Test] + public void Secret_rotation_is_detected_without_persisting_secret_or_digest() + { + var row = new DepartmentSetting { DepartmentId = 7, SettingType = (int)DepartmentSettingTypes.MappingMapboxAccessToken, Setting = "secret-before" }; + var before = ConfigurationAuditProjection.Project(row); row.Setting = "secret-after"; + var after = ConfigurationAuditProjection.Project(row); + Assert.That(before.Fingerprint, Is.Not.EqualTo(after.Fingerprint)); + Assert.That(before.Values, Is.EqualTo(after.Values)); + Assert.That(after.Values, Does.Not.Contain("secret").And.Not.Contain(after.Fingerprint)); + } + [Test] + public void Organization_configuration_diffs_do_not_persist_names_identifiers_or_contact_data() + { + var units = new Unit { DepartmentId = 7, Name = "Private apparatus", VIN = "Sensitive VIN", PlateNumber = "Private plate", StationGroupId = 87654321 }; + var before = ConfigurationAuditProjection.Project(units); units.Name = "Renamed apparatus"; + var after = ConfigurationAuditProjection.Project(units); + Assert.That(before.Fingerprint, Is.Not.EqualTo(after.Fingerprint)); + Assert.That(after.Values, Does.Not.Contain("apparatus").And.Not.Contain("Sensitive VIN").And.Not.Contain("Private plate").And.Not.Contain("87654321")); + foreach (var row in new IEntity[] { new DepartmentGroup { DepartmentId = 7, Name = "Sensitive group name" }, new PersonnelRole { DepartmentId = 7, Name = "Sensitive role name" }, new Shift { DepartmentId = 7, Name = "Sensitive shift name", Code = "Sensitive code" } }) + Assert.That(ConfigurationAuditProjection.Project(row).Values, Does.Not.Contain("Sensitive")); + } + [Test] + public void Allowlisted_boolean_diff_is_useful_but_malformed_strings_remain_redacted() + { + var row = new DepartmentSetting { DepartmentId = 7, SettingType = (int)DepartmentSettingTypes.EnableTextToCall, Setting = "true" }; + Assert.That(ConfigurationAuditProjection.Project(row).Values, Does.Contain("\"Setting\":true")); + row.Setting = "unexpected secret"; + Assert.That(ConfigurationAuditProjection.Project(row).Values, Does.Contain("InvalidStoredValue").And.Not.Contain("unexpected secret")); + } + } +} diff --git a/Tests/Resgrid.Tests/AdminAssist/ConfigurationImpactTests.cs b/Tests/Resgrid.Tests/AdminAssist/ConfigurationImpactTests.cs new file mode 100644 index 000000000..f32add70f --- /dev/null +++ b/Tests/Resgrid.Tests/AdminAssist/ConfigurationImpactTests.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Resgrid.AdminAssist; +using Resgrid.Model.AdminAssist; +using Resgrid.Services.AdminAssist; + +namespace Resgrid.Tests.AdminAssist +{ + [TestFixture] + public class ConfigurationImpactTests + { + private static readonly DateTime Now = new(2026, 9, 24, 12, 0, 0, DateTimeKind.Utc); + private static readonly ConfigurationCatalog Catalog = new(); + private static ConfigurationSnapshot Snapshot(params ConfigurationEvidence[] evidence) => new(7, "admin", "3", Now, true, evidence.ToDictionary(e => e.Id)); + private static ConfigurationEvidence Boolean(string id, bool value) => new(id, EvidenceState.Known, "test", "1", Now, Boolean: value); + private static ConfigurationEvidence Number(string id, decimal value) => new(id, EvidenceState.Known, "test", "1", Now, Number: value); + private ConfigurationImpactReport Evaluate(ConfigurationSnapshot snapshot, string id, bool? boolean = null, decimal? number = null) => + new ConfigurationImpactEvaluator(Catalog).Evaluate(snapshot, new("setting." + id, "3", boolean, number), Now, TimeSpan.FromMinutes(1)); + + [Test] + public void Preview_has_no_mutation_and_shows_rule_dependencies() + { + var snapshot = Snapshot(Boolean("AutoSetStatusForShiftDispatchPersonnel", true), Boolean("DispatchShiftInsteadOfGroup", false)); + var result = Evaluate(snapshot, "DispatchShiftInsteadOfGroup", true); + Assert.That(snapshot.Find("DispatchShiftInsteadOfGroup").Boolean, Is.False); + var change = result.RuleChanges.Single(r => r.RuleId == "shift-auto-without-dispatch"); + Assert.That(change.Before, Is.EqualTo(RuleResult.Fail)); Assert.That(change.After, Is.EqualTo(RuleResult.Pass)); + Assert.That(result.RuleChanges.Single(r => r.RuleId == "shift-coverage").After, Is.EqualTo(RuleResult.Unknown)); + } + [Test] + public void Map_override_overlay_models_credential_deletion_without_returning_secrets() + { + var snapshot = Snapshot(Boolean("MappingUseMapboxOverride", true), Boolean("mapTokenPresent", true), Boolean("mapStylePresent", true)); + var result = Evaluate(snapshot, "MappingUseMapboxOverride", false); + Assert.That(result.LimitKeys, Does.Contain("Impact.MapCredentialsRemoved")); + Assert.That(snapshot.Find("mapTokenPresent").Boolean, Is.True); + Assert.That(result.RuleChanges.Where(r => r.RuleId.StartsWith("map-")).All(r => r.After == RuleResult.NotApplicable), Is.True); + } + [Test] + public void Missing_enrollment_evidence_is_not_zero_and_known_enrollment_has_bounded_scope() + { + Assert.That(Evaluate(Snapshot(Number("Require2FAForAdmins", 0)), "Require2FAForAdmins", number: 1).Metrics[1].After, Is.Null); + var result = Evaluate(Snapshot(Number("Require2FAForAdmins", 0), Number("adminsWithoutMfa", 2)), "Require2FAForAdmins", number: 1); + Assert.That(result.Metrics[1].Before, Is.Zero); Assert.That(result.Metrics[1].After, Is.EqualTo(2)); + Assert.That(result.LimitKeys, Does.Contain("Impact.MfaRecoveryUnknown")); + } + [Test] + public void Group_admin_scope_uses_disjoint_enrollment_counts_and_missing_groups_remain_unknown() + { + var evidence = Snapshot(Number("Require2FAForAdmins", 1), Number("adminsWithoutMfa", 2), Number("groupOnlyAdminsWithoutMfa", 3)); + var result = Evaluate(evidence, "Require2FAForAdmins", number: 2); + Assert.That(result.Metrics[1].Before, Is.EqualTo(2)); Assert.That(result.Metrics[1].After, Is.EqualTo(5)); + var unknown = Evaluate(Snapshot(Number("Require2FAForAdmins", 1), Number("adminsWithoutMfa", 2)), "Require2FAForAdmins", number: 2); + Assert.That(unknown.Metrics[1].Before, Is.EqualTo(2)); Assert.That(unknown.Metrics[1].After, Is.Null); + } + [TestCase(-1)] [TestCase(3)] [TestCase(0.5)] + public void Invalid_MFA_policy_is_rejected(decimal value) => Assert.Throws(() => Evaluate(Snapshot(), "Require2FAForAdmins", number: value)); + [Test] + public void Unsupported_secret_and_stale_revision_are_rejected() + { + Assert.Throws(() => Evaluate(Snapshot(), "MappingMapboxAccessToken", true)); + Assert.Throws(() => Evaluate(Snapshot() with { Revision = "4" }, "EnableTextToCall", true)); + Assert.Throws(() => Evaluate(Snapshot() with { Consistent = false }, "EnableTextToCall", true)); + } + [Test] + public void Mid_preview_revocation_rejects_the_result() + { + var actor = new AdminAssistActor(7, "admin"); var access = new Mock(); + access.SetupSequence(a => a.CanAccessAsync(actor, false, It.IsAny())).ReturnsAsync(true).ReturnsAsync(false); + var snapshots = new Mock(); snapshots.Setup(s => s.ReadAsync(actor, It.IsAny())).ReturnsAsync(Snapshot()); + var repository = new Mock(); repository.Setup(r => r.GetConfigurationRevisionAsync(7, It.IsAny())).ReturnsAsync(3); + var service = new ConfigurationImpactService(access.Object, snapshots.Object, Catalog, repository.Object, TimeProvider.System); + Assert.ThrowsAsync(() => service.PreviewAsync(actor, new("setting.EnableTextToCall", "3", true))); + } + } +} diff --git a/Tests/Resgrid.Tests/AdminAssist/ConfigurationRuleTests.cs b/Tests/Resgrid.Tests/AdminAssist/ConfigurationRuleTests.cs new file mode 100644 index 000000000..71df5f39d --- /dev/null +++ b/Tests/Resgrid.Tests/AdminAssist/ConfigurationRuleTests.cs @@ -0,0 +1,165 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using Resgrid.AdminAssist; +using Resgrid.Model.AdminAssist; + +namespace Resgrid.Tests.AdminAssist +{ + [TestFixture] + public class ConfigurationRuleTests + { + private static readonly DateTime Now = new(2026, 9, 24, 12, 0, 0, DateTimeKind.Utc); + private static readonly ConfigurationCatalog Catalog = new(); + + [TestCase("shift-auto-without-dispatch", "DispatchShiftInsteadOfGroup", false, true)] + [TestCase("text-sources", "textSourcePresent", false, true)] + [TestCase("command-sources", "textSourcePresent", false, true)] + [TestCase("map-token", "mapTokenPresent", false, true)] + [TestCase("map-style", "mapStylePresent", false, true)] + [TestCase("password-recovery", "RequirePasswordResetViaEmail", false, true)] + [TestCase("import-heartbeat", "importHeartbeatMissing", true, false)] + public void Boolean_rules_distinguish_the_documented_failure_from_correct_configuration(string ruleId, string fact, bool failing, bool passing) + { + var rule = Rule(ruleId); + var evidence = Applicable(rule); + evidence[fact] = Value(fact, boolean: failing); + Assert.That(rule.Evaluate(Snapshot(evidence), Now, TimeSpan.FromMinutes(1)).Result, Is.EqualTo(RuleResult.Fail)); + evidence[fact] = Value(fact, boolean: passing); + Assert.That(rule.Evaluate(Snapshot(evidence), Now, TimeSpan.FromMinutes(1)).Result, Is.EqualTo(RuleResult.Pass)); + } + + [TestCase("email-import-failures", "emailImportFailureCount", 1, 0)] + [TestCase("policy-references", "unavailablePolicyReferences", 1, 0)] + [TestCase("policy-expiry", "policyReferencesExpiring30Days", 1, 0)] + [TestCase("site-references", "unavailableSiteReferences", 1, 0)] + [TestCase("continuity-reference", "declaredContinuityReferences", 0, 1)] + [TestCase("shift-coverage", "groupsWithoutShiftCoverage", 1, 0)] + [TestCase("admin-mfa", "Require2FAForAdmins", 0, 1)] + [TestCase("admin-enrollment", "adminsWithoutMfa", 1, 0)] + [TestCase("admin-succession", "activeAdminCount", 1, 2)] + [TestCase("empty-groups", "emptyGroupCount", 1, 0)] + [TestCase("unit-type", "unitsWithoutType", 1, 0)] + [TestCase("unit-group", "unitsWithoutGroup", 1, 0)] + [TestCase("station-address", "stationsWithoutLocation", 1, 0)] + [TestCase("person-location-age", "MappingPersonnelLocationTTL", 0, 30)] + [TestCase("unit-location-age", "MappingUnitLocationTTL", 0, 30)] + [TestCase("personnel-limit", "personnelUtilization", 0.9, 0.8)] + [TestCase("unit-limit", "unitUtilization", 0.9, 0.8)] + [TestCase("run-cards", "runCardCount", 0, 1)] + [TestCase("checkin-timers", "checkInTimerCount", 0, 1)] + [TestCase("weather-zones", "weatherZoneCount", 0, 1)] + [TestCase("communication-tests", "communicationTestAgeDays", 31, 30)] + [TestCase("qualified-coverage", "uncoveredQualificationCount", 1, 0)] + [TestCase("credential-expiry", "qualificationsExpiring30Days", 1, 0)] + [TestCase("checklist-overdue", "overdueChecklistCount", 1, 0)] + [TestCase("equipment-holds", "activeSafetyHoldCount", 1, 0)] + [TestCase("stock-expiry", "stockExpiring30Days", 1, 0)] + [TestCase("workflow-failures", "failedWorkflowCount", 1, 0)] + [TestCase("record-review", "overdueRecordReviewCount", 1, 0)] + [TestCase("shift-open-slots", "upcomingOpenShiftSlots", 1, 0)] + [TestCase("shift-overlaps", "overlappingShiftPersonnel", 1, 0)] + [TestCase("shift-trades", "unfilledShiftTrades", 1, 0)] + public void Numeric_rules_preserve_thresholds(string ruleId, string fact, double failing, double passing) + { + var rule = Rule(ruleId); + var evidence = Applicable(rule); + evidence[fact] = Value(fact, number: (decimal)failing); + Assert.That(rule.Evaluate(Snapshot(evidence), Now, TimeSpan.FromMinutes(1)).Result, Is.EqualTo(RuleResult.Fail)); + evidence[fact] = Value(fact, number: (decimal)passing); + Assert.That(rule.Evaluate(Snapshot(evidence), Now, TimeSpan.FromMinutes(1)).Result, Is.EqualTo(RuleResult.Pass)); + } + + [Test] + public void Every_rule_has_an_explicit_passing_and_failing_example() + { + var examples = GetType().GetMethods().SelectMany(method => method.GetCustomAttributes(typeof(TestCaseAttribute), false).Cast()) + .Where(test => test.Arguments.Length == 4).Select(test => test.Arguments[0].ToString()); + Assert.That(examples, Is.EquivalentTo(Catalog.Rules.Select(rule => rule.Id))); + } + [Test] + public void Member_text_commands_do_not_require_dispatch_source_patterns_when_text_calls_are_off() + { + var evidence = Applicable(Rule("command-sources")); + evidence["EnableTextToCall"] = Value("EnableTextToCall", boolean: false); + evidence["textSourcePresent"] = Value("textSourcePresent", boolean: false); + Assert.That(Rule("command-sources").Evaluate(Snapshot(evidence), Now, TimeSpan.FromMinutes(1)).Result, Is.EqualTo(RuleResult.NotApplicable)); + } + + [Test] + public void Partial_compound_evidence_cannot_produce_a_pass() + { + var definition = Catalog.Rules.First() with { AppliesWhen = Array.Empty(), FailsWhen = new[] { + new EvidenceCondition("first", EvidenceComparison.IsTrue), new EvidenceCondition("second", EvidenceComparison.IsTrue) } }; + var rule = new ConfigurationRule(definition); + Assert.That(rule.Evaluate(Snapshot(new Dictionary { ["first"] = Value("first", false) }), Now, TimeSpan.FromMinutes(1)).Result, + Is.EqualTo(RuleResult.Unknown)); + } + [Test] + public void Every_rule_keeps_missing_redacted_unavailable_stale_and_future_evidence_unknown() + { + foreach (var definition in Catalog.Rules) + { + var rule = new ConfigurationRule(definition); + var evidence = Applicable(rule); + Assert.That(rule.Evaluate(Snapshot(evidence), Now, TimeSpan.FromMinutes(1)).Result, Is.EqualTo(RuleResult.Unknown), definition.Id); + foreach (var state in new[] { EvidenceState.Unknown, EvidenceState.Redacted, EvidenceState.Unavailable }) + { + foreach (var condition in definition.FailsWhen) + evidence[condition.EvidenceId] = Value(condition.EvidenceId, false, 0) with { State = state }; + Assert.That(rule.Evaluate(Snapshot(evidence), Now, TimeSpan.FromMinutes(1)).Result, Is.EqualTo(RuleResult.Unknown), definition.Id); + } + foreach (var offset in new[] { -61, 1 }) + { + foreach (var condition in definition.FailsWhen) + evidence[condition.EvidenceId] = Value(condition.EvidenceId, false, 0) with { AsOfUtc = Now.AddSeconds(offset) }; + Assert.That(rule.Evaluate(Snapshot(evidence), Now, TimeSpan.FromMinutes(1)).Result, Is.EqualTo(RuleResult.Unknown), definition.Id); + } + } + } + + [Test] + public void Disabled_text_intake_is_not_a_missing_source_failure() + { + var evidence = new Dictionary { ["EnableTextToCall"] = Value("EnableTextToCall", false) }; + Assert.That(Rule("text-sources").Evaluate(Snapshot(evidence), Now, TimeSpan.FromMinutes(1)).Result, Is.EqualTo(RuleResult.NotApplicable)); + } + + [Test] + public void Concurrent_mutation_prevents_a_passing_snapshot() + { + var snapshot = Snapshot(new Dictionary { ["Require2FAForAdmins"] = Value("Require2FAForAdmins", number: 1) }) with { Consistent = false }; + Assert.That(Rule("admin-mfa").Evaluate(snapshot, Now, TimeSpan.FromMinutes(1)).Result, Is.EqualTo(RuleResult.Unknown)); + } + + [Test] + public void Optional_areas_do_not_reduce_selected_completion_and_security_cannot_be_hidden() + { + var findings = new[] + { + Finding("admin-mfa", "security", RuleResult.Unknown, FindingSeverity.Critical), + Finding("home", "home", RuleResult.Pass), Finding("optional", "business", RuleResult.Fail) + }; + var report = new ConfigurationReport(Snapshot(new Dictionary()), findings, new[] { "home" }); + Assert.Multiple(() => + { + Assert.That(report.Required, Is.EqualTo(2)); Assert.That(report.Verified, Is.EqualTo(1)); + Assert.That(report.Failed, Is.Zero); Assert.That(report.HasCriticalUncertainty, Is.True); + }); + } + + private static ConfigurationFinding Finding(string id, string area, RuleResult result, FindingSeverity severity = FindingSeverity.Warning) => + new(id, area, severity, result, id, id, id, "/User/Department/Settings", Array.Empty(), "1", Now); + private static ConfigurationRule Rule(string id) => new(Catalog.Rules.Single(r => r.Id == id)); + private static ConfigurationEvidence Value(string id, bool? boolean = null, decimal? number = null) => new(id, EvidenceState.Known, "fixture", "1", Now, boolean, number); + private static ConfigurationSnapshot Snapshot(Dictionary evidence) => new(1, "admin", "1", Now, true, evidence); + private static Dictionary Applicable(ConfigurationRule rule) + { + var evidence = new Dictionary(); + foreach (var condition in rule.Definition.AppliesWhen) + evidence[condition.EvidenceId] = Value(condition.EvidenceId, true, 1); + return evidence; + } + } +} diff --git a/Tests/Resgrid.Tests/AdminAssist/DigestScheduleTests.cs b/Tests/Resgrid.Tests/AdminAssist/DigestScheduleTests.cs new file mode 100644 index 000000000..fd6f83fa0 --- /dev/null +++ b/Tests/Resgrid.Tests/AdminAssist/DigestScheduleTests.cs @@ -0,0 +1,31 @@ +using System; +using NUnit.Framework; +using Resgrid.Model.AdminAssist; + +namespace Resgrid.Tests.AdminAssist +{ + [TestFixture] + public class DigestScheduleTests + { + [TestCase(7, true)] [TestCase(8, false)] [TestCase(19, false)] [TestCase(20, true)] + public void Overnight_quiet_period_includes_start_and_excludes_end(int hour, bool quiet) => + Assert.That(AdminAssistDigestSchedule.IsQuiet(new DateTime(2026, 9, 24, hour, 0, 0, DateTimeKind.Utc), TimeZoneInfo.Utc, 20, 8), Is.EqualTo(quiet)); + [Test] + public void Repeated_DST_hour_stays_quiet_and_uses_one_weekly_dedup_key() + { + var zone = TimeZoneInfo.FindSystemTimeZoneById("America/Los_Angeles"); + var first = new DateTime(2026, 11, 1, 8, 30, 0, DateTimeKind.Utc); + var second = first.AddHours(1); + Assert.That(AdminAssistDigestSchedule.IsQuiet(first, zone, 20, 8), Is.True); + Assert.That(AdminAssistDigestSchedule.IsQuiet(second, zone, 20, 8), Is.True); + Assert.That(AdminAssistDigestSchedule.Week(first, zone), Is.EqualTo("2026-10-26")); + Assert.That(AdminAssistDigestSchedule.Week(second, zone), Is.EqualTo(AdminAssistDigestSchedule.Week(first, zone))); + } + [Test] + public void Local_week_does_not_roll_over_at_UTC_midnight() + { + var zone = TimeZoneInfo.FindSystemTimeZoneById("America/Los_Angeles"); + Assert.That(AdminAssistDigestSchedule.Week(new DateTime(2026, 9, 28, 1, 0, 0, DateTimeKind.Utc), zone), Is.EqualTo("2026-09-21")); + } + } +} diff --git a/Tests/Resgrid.Tests/AdminAssist/DispatchImpactTests.cs b/Tests/Resgrid.Tests/AdminAssist/DispatchImpactTests.cs new file mode 100644 index 000000000..e36245823 --- /dev/null +++ b/Tests/Resgrid.Tests/AdminAssist/DispatchImpactTests.cs @@ -0,0 +1,129 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Resgrid.AdminAssist; +using Resgrid.Model; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services.AdminAssist; + +namespace Resgrid.Tests.AdminAssist +{ + [TestFixture] + public class DispatchImpactTests + { + private sealed class Fixture + { + public readonly DateTime Now = DateTime.UtcNow; + public readonly Mock Access = new(); + public readonly Mock Store = new(); + public readonly Mock Calls = new(); + public readonly Mock Departments = new(); + public readonly Mock Groups = new(); + public readonly Mock Units = new(); + public readonly Mock Crews = new(); + public readonly Mock Roles = new(); + public readonly Mock RoleMembers = new(); + public readonly Mock Settings = new(); + public readonly Mock Shifts = new(); + public readonly Mock Visibility = new(); + public readonly Mock Authorization = new(); + public readonly Mock Direct = new(); + public readonly Mock GroupRoutes = new(); + public readonly Mock UnitRoutes = new(); + public readonly Mock RoleRoutes = new(); + public DispatchImpactRequest Request => new("0", 14, Now, true, true, true); + public DispatchImpactService Service => new(Access.Object, Store.Object, new ConfigurationCatalog(), Calls.Object, + Departments.Object, Groups.Object, Units.Object, Crews.Object, Roles.Object, RoleMembers.Object, Settings.Object, + Shifts.Object, Visibility.Object, Authorization.Object, TimeProvider.System, Direct.Object, GroupRoutes.Object, UnitRoutes.Object, RoleRoutes.Object); + public Fixture() + { + Access.Setup(a => a.CanAccessAsync(It.IsAny(), false, It.IsAny())).ReturnsAsync(true); + Calls.Setup(c => c.GetCallByIdAsync(14, true)).ReturnsAsync(new Call { CallId = 14, DepartmentId = 7, NatureOfCall = "Must not appear" }); + Authorization.Setup(a => a.CanReadSourceCallAsync("admin", 7, It.IsAny())).ReturnsAsync(true); + Authorization.Setup(a => a.IsAssignableMemberAsync(It.IsAny(), 7)).ReturnsAsync(true); + Visibility.Setup(a => a.CanUserViewPersonAsync("admin", It.IsAny(), 7)).ReturnsAsync(true); + Visibility.Setup(a => a.CanUserViewUnitAsync("admin", 20)).ReturnsAsync(true); + Settings.Setup(s => s.GetAllByDepartmentIdAsync(7)).ReturnsAsync(Array.Empty()); + Departments.Setup(d => d.GetDepartmentByIdAsync(7, true)).ReturnsAsync(new Department { DepartmentId = 7, TimeZone = "UTC" }); + Groups.Setup(g => g.GetAllGroupsByDepartmentIdAsync(7)).ReturnsAsync(new[] { new DepartmentGroup { DepartmentGroupId = 10, DepartmentId = 7, + Members = new List { new() { UserId = "a" }, new() { UserId = "b" } } } }); + Shifts.Setup(s => s.ReadSchedulesForAdministrationAsync(7, It.IsAny(), It.IsAny(), Now, It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { new() { IsActive = true, Roster = new List { + new() { UserId = "c", DepartmentGroupId = 10, Source = ShiftRosterSources.Trade }, + new() { UserId = "pending", DepartmentGroupId = 10, ApprovalPending = true } } } }); + Units.Setup(u => u.GetByIdAsync(20)).ReturnsAsync(new Unit { UnitId = 20, DepartmentId = 7, StationGroupId = 10 }); + Crews.Setup(c => c.GetCurrentRolesForUnitAsync(20)).ReturnsAsync(new[] { new UnitStateRole { UserId = "b" }, new UnitStateRole { UserId = "d" } }); + Roles.Setup(r => r.GetRoleByRoleIdAsync(30)).ReturnsAsync(new PersonnelRole { PersonnelRoleId = 30, DepartmentId = 7 }); + RoleMembers.Setup(r => r.GetAllMembersOfRoleAsync(30)).ReturnsAsync(new[] { new PersonnelRoleUser { UserId = "e" }, new PersonnelRoleUser { UserId = "d" } }); + Direct.Setup(r => r.GetCallDispatchesByCallIdAsync(14)).ReturnsAsync(new[] { new CallDispatch { UserId = "a" }, new CallDispatch { UserId = "a" } }); + GroupRoutes.Setup(r => r.GetAllCallDispatchGroupByCallIdAsync(14)).ReturnsAsync(new[] { new CallDispatchGroup { DepartmentGroupId = 10 } }); + UnitRoutes.Setup(r => r.GetCallUnitDispatchesByCallIdAsync(14)).ReturnsAsync(new[] { new CallDispatchUnit { UnitId = 20 } }); + RoleRoutes.Setup(r => r.GetCallRoleDispatchesByCallIdAsync(14)).ReturnsAsync(new[] { new CallDispatchRole { RoleId = 30 } }); + } + } + [Test] + public async Task All_route_families_share_deduplication_and_explicit_roster_time_without_exposing_identities() + { + var f = new Fixture(); var result = await f.Service.PreviewAsync(new(7, "admin"), f.Request); + ConfigurationImpactMetric Metric(string key) => result.Metrics.Single(m => m.LabelKey == "Impact." + key); + Assert.That(Metric("DispatchPeople").Before, Is.EqualTo(4)); Assert.That(Metric("DispatchPeople").After, Is.EqualTo(5)); + Assert.That(Metric("DispatchAttempts").Before, Is.EqualTo(5)); Assert.That(Metric("DispatchAttempts").After, Is.EqualTo(6)); + Assert.That(Metric("DispatchAdded").After, Is.EqualTo(1)); Assert.That(Metric("DispatchRemoved").After, Is.Zero); + Assert.That(result.AsOfUtc, Is.EqualTo(f.Now)); + Assert.That(Newtonsoft.Json.JsonConvert.SerializeObject(result), Does.Not.Contain("Must not appear")); + f.Shifts.Verify(s => s.ReadSchedulesForAdministrationAsync(7, It.IsAny(), It.IsAny(), f.Now, It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + [Test] + public async Task Empty_shift_falls_back_but_missing_roster_is_unknown() + { + var f = new Fixture(); + f.Shifts.Setup(s => s.ReadSchedulesForAdministrationAsync(7, It.IsAny(), It.IsAny(), f.Now, It.IsAny(), It.IsAny())).ReturnsAsync(new List()); + var result = await f.Service.PreviewAsync(new(7, "admin"), f.Request); + Assert.That(result.Metrics.Single(m => m.LabelKey == "Impact.DispatchFallback").After, Is.EqualTo(1)); + f.Shifts.Setup(s => s.ReadSchedulesForAdministrationAsync(7, It.IsAny(), It.IsAny(), f.Now, It.IsAny(), It.IsAny())).ReturnsAsync((List)null); + result = await f.Service.PreviewAsync(new(7, "admin"), f.Request); + Assert.That(result.Metrics.Single().State, Is.EqualTo(EvidenceState.Unknown)); Assert.That(result.Metrics.Single().After, Is.Null); + } + [Test] + public void Cross_tenant_call_is_rejected_before_routes_are_read() + { + var f = new Fixture(); f.Calls.Setup(c => c.GetCallByIdAsync(14, true)).ReturnsAsync(new Call { CallId = 14, DepartmentId = 8 }); + Assert.ThrowsAsync(async () => await f.Service.PreviewAsync(new(7, "admin"), f.Request)); + f.Direct.Verify(r => r.GetCallDispatchesByCallIdAsync(It.IsAny()), Times.Never); + } + [Test] + public void Restricted_person_is_not_included_in_a_partial_count() + { + var f = new Fixture(); f.Visibility.Setup(v => v.CanUserViewPersonAsync("admin", "c", 7)).ReturnsAsync(false); + Assert.ThrowsAsync(async () => await f.Service.PreviewAsync(new(7, "admin"), f.Request)); + } + [Test] + public void Current_route_change_during_preview_conflicts_even_without_a_configuration_revision_change() + { + var f = new Fixture(); f.Direct.SetupSequence(r => r.GetCallDispatchesByCallIdAsync(14)) + .ReturnsAsync(new[] { new CallDispatch { UserId = "a" } }).ReturnsAsync(new[] { new CallDispatch { UserId = "b" } }); + Assert.ThrowsAsync(async () => await f.Service.PreviewAsync(new(7, "admin"), f.Request)); + } + [Test] + public void Missing_current_route_source_and_invalid_time_are_not_silently_accepted() + { + var f = new Fixture(); + Assert.ThrowsAsync(async () => await f.Service.PreviewAsync(new(7, "admin"), f.Request with { SimulationTimeUtc = DateTime.SpecifyKind(f.Now, DateTimeKind.Unspecified) })); + Assert.ThrowsAsync(async () => await f.Service.PreviewAsync(new(7, "admin"), f.Request with { SimulationTimeUtc = f.Now.AddDays(8) })); + Assert.ThrowsAsync(async () => await f.Service.PreviewAsync(new(7, "admin"), f.Request with { ExpectedRevision = "1" })); + } + [Test] + public void Shared_group_projection_preserves_ungrouped_members_pending_approvals_and_case_insensitive_deduplication() + { + var roster = new[] { new ShiftDayRosterEntry { UserId = "A", DepartmentGroupId = 10 }, new() { UserId = "a" }, new() { UserId = "b" }, + new() { UserId = "c", DepartmentGroupId = 20 }, new() { UserId = "d", DepartmentGroupId = 10, ApprovalPending = true } }; + Assert.That(ShiftRosterGroups.Select(10, roster, new[] { "a", "b", "c", "d" }), Is.EquivalentTo(new[] { "A", "b" })); + } + } +} diff --git a/Tests/Resgrid.Tests/AdminAssist/DispatchRecipientResolverTests.cs b/Tests/Resgrid.Tests/AdminAssist/DispatchRecipientResolverTests.cs new file mode 100644 index 000000000..1be757759 --- /dev/null +++ b/Tests/Resgrid.Tests/AdminAssist/DispatchRecipientResolverTests.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using Resgrid.Model.AdminAssist; + +namespace Resgrid.Tests.AdminAssist +{ + [TestFixture] + public class DispatchRecipientResolverTests + { + private static readonly DateTime Now = new(2026, 11, 1, 9, 30, 0, DateTimeKind.Utc); + [TestCase(true)] + [TestCase(false)] + public void Incremental_production_resolution_matches_isolated_preview_with_overlapping_routes(bool useShift) + { + var routes = new[] { + new DispatchRoute(DispatchRouteKind.Direct, "direct", new[] { "a", "a" }), + new DispatchRoute(DispatchRouteKind.Group, "1", new[] { "a", "b", "off" }, useShift, new[] { "a", "trade" }), + new DispatchRoute(DispatchRouteKind.Group, "2", new[] { "b", "c" }, useShift, Array.Empty()), + new DispatchRoute(DispatchRouteKind.UnitCrew, "1", new[] { "c", "crew" }), + new DispatchRoute(DispatchRouteKind.UnitGroup, "2", new[] { "c", "crew", "d" }), + new DispatchRoute(DispatchRouteKind.Role, "1", new[] { "a", "d", "role" }) }; + var full = DispatchRecipientResolver.Resolve(Now, routes); + var sent = new List(); var decisions = new List(); + foreach (var route in routes) { var part = DispatchRecipientResolver.Resolve(Now, new[] { route }, sent); sent.AddRange(part.SelectedUserIds); decisions.AddRange(part.Decisions); } + Assert.That(sent, Is.EqualTo(full.SelectedUserIds)); + Assert.That(decisions, Is.EqualTo(full.Decisions)); + Assert.That(sent, Is.EqualTo(useShift ? new[] { "a", "a", "trade", "b", "c", "crew", "d", "role" } : new[] { "a", "a", "b", "off", "c", "crew", "d", "role" })); + Assert.That(full.Decisions.Where(d => d.EmptyShiftFallback).Select(d => d.UserId), Is.EqualTo(useShift ? new[] { "b", "c" } : Array.Empty())); + } + [Test] + public void Simulation_does_not_mutate_inputs_or_prior_selection() + { + var prior = new HashSet { "a" }; var members = new[] { "a", "b" }; + var route = new DispatchRoute(DispatchRouteKind.Role, "1", members); + Assert.That(DispatchRecipientResolver.Resolve(Now, new[] { route }, prior).SelectedUserIds, Is.EqualTo(new[] { "b" })); + Assert.That(prior, Is.EquivalentTo(new[] { "a" })); Assert.That(members, Is.EqualTo(new[] { "a", "b" })); + } + [Test] + public void Unknown_roster_and_implicit_local_time_cannot_be_previewed_as_known() + { + Assert.Throws(() => DispatchRecipientResolver.Resolve(Now, new[] { new DispatchRoute(DispatchRouteKind.Group, "1", new[] { "a" }, true) })); + Assert.Throws(() => DispatchRecipientResolver.Resolve(DateTime.SpecifyKind(Now, DateTimeKind.Unspecified), Array.Empty())); + } + } +} diff --git a/Tests/Resgrid.Tests/AdminAssist/DispatchTraceQueueTests.cs b/Tests/Resgrid.Tests/AdminAssist/DispatchTraceQueueTests.cs new file mode 100644 index 000000000..44f0041e7 --- /dev/null +++ b/Tests/Resgrid.Tests/AdminAssist/DispatchTraceQueueTests.cs @@ -0,0 +1,118 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using RabbitMQ.Client; +using Resgrid.Model.AdminAssist; +using Resgrid.Providers.Bus.Rabbit; + +namespace Resgrid.Tests.AdminAssist +{ + [TestFixture] + public class DispatchTraceQueueTests + { + private Mock _factory; + private Mock _connection; + private Mock _channel; + private RabbitAdminAssistTraceQueue _queue; + private byte[] _body; + private BasicProperties _properties; + [SetUp] + public void SetUp() + { + _body = null; _properties = null; + _factory = new(); _connection = new(); _channel = new(); + _connection.SetupGet(c => c.IsOpen).Returns(true); _channel.SetupGet(c => c.IsOpen).Returns(true); + _factory.Setup(f => f.CreateConnectionAsync(It.IsAny>(), It.IsAny(), It.IsAny())).ReturnsAsync(_connection.Object); + _connection.Setup(c => c.CreateChannelAsync(It.IsAny(), It.IsAny())).ReturnsAsync(_channel.Object); + _channel.Setup(c => c.BasicPublishAsync(It.IsAny(), It.IsAny(), true, It.IsAny(), It.IsAny>(), It.IsAny())) + .Callback, CancellationToken>((exchange, route, mandatory, properties, body, token) => { _body = body.ToArray(); _properties = properties; }) + .Returns(ValueTask.CompletedTask); + _channel.Setup(c => c.BasicGetAsync(It.IsAny(), false, It.IsAny())) + .ReturnsAsync(() => _body == null ? null : new BasicGetResult(17, true, "", "trace", 0, _properties, _body)); + _queue = new(_factory.Object); + } + [TearDown] public async Task TearDown() => await _queue.DisposeAsync(); + internal static AdminAssistDispatchTraceRow Row() + { + var observation = new DispatchTraceObservation(Guid.NewGuid().ToString("D"), 7, 19, null, Guid.NewGuid().ToString("D"), DateTime.UtcNow, + DispatchTraceStage.Attempted, DispatchTraceChannel.Email, DispatchTraceReason.None, "member-reference", null, null, null, + 1, 0, DispatchRecipientResolver.Version); + return new() { AdminAssistDispatchTraceId = observation.Id, DepartmentId = 7, CallId = 19, AttemptId = observation.AttemptId, + Stage = observation.Stage.ToString(), ResolverVersion = observation.ResolverVersion, OccurredOn = observation.OccurredOnUtc, + Content = JsonSerializer.Serialize(observation) }; + } + [Test] + public async Task Persistent_confirmed_envelope_is_acknowledged_only_after_the_store_commits() + { + var row = Row(); await _queue.EnqueueAsync(row, CancellationToken.None); + Assert.That(_properties.DeliveryMode, Is.EqualTo(DeliveryModes.Persistent)); + Assert.That(_properties.Expiration, Is.Null, "Queue expiry must not discard evidence under a hold."); + Assert.That(_properties.MessageId, Is.EqualTo(row.AdminAssistDispatchTraceId)); + Assert.That(System.Text.Encoding.UTF8.GetString(_body), Does.Not.Contain("IdValue").And.Not.Contain("TableName")); + var stored = false; + var result = await _queue.ProcessNextAsync((received, token) => + { + _channel.Verify(c => c.BasicAckAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + Assert.That(received.Content, Is.EqualTo(row.Content)); stored = true; return Task.CompletedTask; + }, CancellationToken.None); + Assert.That(result, Is.EqualTo(DispatchTraceReceiveResult.Persisted)); Assert.That(stored, Is.True); + _channel.Verify(c => c.BasicAckAsync(17, false, It.IsAny()), Times.Once); + _connection.Verify(c => c.CreateChannelAsync(It.Is(o => o.PublisherConfirmationsEnabled && o.PublisherConfirmationTrackingEnabled), It.IsAny()), Times.Exactly(2)); + _channel.Verify(c => c.QueueDeclareAsync(It.IsAny(), true, false, false, + It.Is>(a => (string)a["x-overflow"] == "reject-publish" && !a.ContainsKey("x-message-ttl")), false, false, It.IsAny()), Times.Exactly(2)); + } + [Test] + public async Task Store_outage_returns_unacknowledged_delivery_and_replay_keeps_the_same_observation_id() + { + var row = Row(); await _queue.EnqueueAsync(row, CancellationToken.None); + Assert.ThrowsAsync(async () => await _queue.ProcessNextAsync((_, _) => throw new IOException("simulated outage"), CancellationToken.None)); + _channel.Verify(c => c.BasicAckAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _channel.Verify(c => c.DisposeAsync(), Times.Once); + await _queue.ProcessNextAsync((received, _) => { Assert.That(received.AdminAssistDispatchTraceId, Is.EqualTo(row.AdminAssistDispatchTraceId)); return Task.CompletedTask; }, CancellationToken.None); + _channel.Verify(c => c.BasicAckAsync(17, false, It.IsAny()), Times.Once); + } + [Test] + public async Task Crash_after_commit_before_ack_replays_without_changing_the_idempotency_key() + { + await _queue.EnqueueAsync(Row(), CancellationToken.None); var committed = new HashSet(); var attempted = 0; + Task Store(AdminAssistDispatchTraceRow row, CancellationToken _) { attempted++; committed.Add(row.AdminAssistDispatchTraceId); return Task.CompletedTask; } + _channel.SetupSequence(c => c.BasicAckAsync(17, false, It.IsAny())) + .Throws(new IOException("lost ack")).Returns(ValueTask.CompletedTask); + Assert.ThrowsAsync(async () => await _queue.ProcessNextAsync(Store, CancellationToken.None)); + await _queue.ProcessNextAsync(Store, CancellationToken.None); + Assert.That(attempted, Is.EqualTo(2)); Assert.That(committed.Count, Is.EqualTo(1)); + } + [Test] + public async Task Malformed_or_mismatched_envelopes_are_never_acknowledged_or_persisted() + { + await _queue.EnqueueAsync(Row(), CancellationToken.None); _properties.MessageId = Guid.NewGuid().ToString("D"); + var stored = false; + Assert.ThrowsAsync(async () => await _queue.ProcessNextAsync((_, _) => { stored = true; return Task.CompletedTask; }, CancellationToken.None)); + Assert.That(stored, Is.False); _channel.Verify(c => c.BasicAckAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + [Test] + public async Task Empty_queue_does_not_invoke_the_writer() + { + Assert.That(await _queue.ProcessNextAsync((_, _) => throw new InvalidOperationException(), CancellationToken.None), Is.EqualTo(DispatchTraceReceiveResult.Empty)); + } + [Test] + public void Protection_flag_cannot_disguise_plaintext_as_an_envelope() + { + var row = Row(); row.IsProtected = true; row.ProtectedCatalogVersion = 30; + Assert.ThrowsAsync(async () => await _queue.EnqueueAsync(row, CancellationToken.None)); + Assert.That(_body, Is.Null); + } + [Test] + public void Invalid_tenant_metadata_cannot_be_queued() + { + var row = Row(); row.DepartmentId = 8; + Assert.ThrowsAsync(async () => await _queue.EnqueueAsync(row, CancellationToken.None)); + Assert.That(_body, Is.Null); + } + } +} diff --git a/Tests/Resgrid.Tests/AdminAssist/DispatchTraceTests.cs b/Tests/Resgrid.Tests/AdminAssist/DispatchTraceTests.cs new file mode 100644 index 000000000..e6886c7b0 --- /dev/null +++ b/Tests/Resgrid.Tests/AdminAssist/DispatchTraceTests.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using NUnit.Framework; +using Resgrid.Model.AdminAssist; + +namespace Resgrid.Tests.AdminAssist +{ + [TestFixture, NonParallelizable] + public class DispatchTraceTests + { + [SetUp, TearDown] public void Drain() { while (DispatchTraceTelemetry.Reader.TryRead(out _)) { } } + [Test] + public async Task Saturated_or_unconsumed_trace_storage_does_not_stop_sends() + { + var before = DispatchTraceTelemetry.Dropped; var sends = 0; + using (DispatchTraceTelemetry.Begin(7, 1, 19, true)) + { + for (int i = 0; i < 2200; i++) DispatchTraceTelemetry.Observe(DispatchTraceStage.Selected, recipientId: "member"); + var result = await DispatchTraceTelemetry.AttemptAsync(DispatchTraceChannel.Sms, "member", () => { sends++; return Task.FromResult(true); }); + Assert.That(result, Is.True); + } + Assert.That(sends, Is.EqualTo(1)); Assert.That(DispatchTraceTelemetry.Dropped, Is.GreaterThan(before)); + } + [Test] + public async Task Exceptions_preserve_sender_behavior_and_do_not_record_exception_content() + { + using (DispatchTraceTelemetry.Begin(7, 1, 19, true)) + { + Assert.ThrowsAsync(async () => await DispatchTraceTelemetry.AttemptAsync(DispatchTraceChannel.Email, "member", () => throw new InvalidOperationException("sensitive provider response"))); + await DispatchTraceTelemetry.AttemptAsync(DispatchTraceChannel.Push, "member", () => Task.FromResult(true)); + } + var rows = new List(); while (DispatchTraceTelemetry.Reader.TryRead(out var row)) rows.Add(row); + Assert.That(rows.Select(r => r.Stage), Is.EqualTo(new[] { DispatchTraceStage.Attempted, DispatchTraceStage.Failed, DispatchTraceStage.Attempted, DispatchTraceStage.ServiceCompleted })); + Assert.That(rows.Select(r => r.AttemptId).Distinct().Count(), Is.EqualTo(1)); + Assert.That(rows.All(r => r.DepartmentId == 7 && r.QueueItemId == 19), Is.True); + Assert.That(System.Text.Json.JsonSerializer.Serialize(rows), Does.Not.Contain("sensitive provider response")); + } + [Test] + public void Disabled_capture_and_scope_disposal_leave_no_observations() + { + using (DispatchTraceTelemetry.Begin(7, 1, 19, false)) DispatchTraceTelemetry.Observe(DispatchTraceStage.Selected); + DispatchTraceTelemetry.Observe(DispatchTraceStage.Selected); + Assert.That(DispatchTraceTelemetry.Reader.TryRead(out _), Is.False); + } + [Test] + public async Task Provider_handoff_has_a_logical_message_id_and_never_implies_delivery() + { + using (DispatchTraceTelemetry.Begin(7, 1, 19, true)) + await DispatchTraceTelemetry.AttemptAsync(DispatchTraceChannel.Sms, "member", () => { + DispatchTraceTelemetry.ProviderResult(DispatchTraceProvider.Twilio, DispatchTraceChannel.Sms, "SM" + new string('a',32), true); + return Task.FromResult(true); + }); + var rows = new List(); while (DispatchTraceTelemetry.Reader.TryRead(out var row)) rows.Add(row); + Assert.That(rows.Select(r => r.Stage), Is.EqualTo(new[] { DispatchTraceStage.Attempted, DispatchTraceStage.ProviderAccepted, DispatchTraceStage.ServiceCompleted })); + Assert.That(rows.Select(r => r.LogicalMessageId).Distinct().Count(), Is.EqualTo(1)); + Assert.That(Guid.TryParse(rows[0].LogicalMessageId, out _), Is.True); + Assert.That(rows.Select(r => r.Sequence), Is.EqualTo(new[] { 1,2,3 })); + Assert.That(rows[1].ProviderMessageId, Does.StartWith("SM")); + } + [Test] + public async Task Arbitrary_provider_response_content_is_not_captured_as_an_identifier() + { + using (DispatchTraceTelemetry.Begin(7, 1, 19, true)) + await DispatchTraceTelemetry.AttemptAsync(DispatchTraceChannel.Email, "member", () => { + DispatchTraceTelemetry.ProviderResult(DispatchTraceProvider.Postmark, DispatchTraceChannel.Email, "private@example.test secret response", true); + return Task.FromResult(true); + }); + var rows = new List(); while (DispatchTraceTelemetry.Reader.TryRead(out var row)) rows.Add(row); + Assert.That(rows[1].Stage, Is.EqualTo(DispatchTraceStage.ProviderResultUnknown)); Assert.That(rows[1].ProviderMessageId, Is.Null); + Assert.That(System.Text.Json.JsonSerializer.Serialize(rows), Does.Not.Contain("private@example.test").And.Not.Contain("secret response")); + DispatchTraceTelemetry.ProviderResult(DispatchTraceProvider.Postmark, DispatchTraceChannel.Email, Guid.NewGuid().ToString("D"), true); + Assert.That(DispatchTraceTelemetry.Reader.TryRead(out _), Is.False); + } + [Test] + public void Completed_observation_reports_capture_loss_when_the_per_broadcast_limit_is_reached() + { + using (DispatchTraceTelemetry.Begin(7, 1, 19, true)) + { + for (var i = 0; i < 10002; i++) { DispatchTraceTelemetry.Observe(DispatchTraceStage.Selected); while (DispatchTraceTelemetry.Reader.TryRead(out _)) { } } + DispatchTraceTelemetry.Observe(DispatchTraceStage.BroadcastCompleted); + } + Assert.That(DispatchTraceTelemetry.Reader.TryRead(out var terminal), Is.True); + Assert.That(terminal.Sequence, Is.EqualTo(10003)); Assert.That(terminal.PriorDropped, Is.EqualTo(2)); + } + [TestCase("queued", true)] [TestCase("completed", true)] [TestCase("failed", false)] [TestCase("busy", false)] + public void Provider_creation_states_are_normalized_without_changing_the_send_result(string status, bool expected) => + Assert.That(DispatchProviderOutcome.CreationStatus(status), Is.EqualTo(expected)); + [Test] + public void Missing_and_new_provider_states_remain_unknown() + { + Assert.That(DispatchProviderOutcome.CreationStatus(null), Is.Null); + Assert.That(DispatchProviderOutcome.CreationStatus("provider-added-new-state"), Is.Null); + } + } +} diff --git a/Tests/Resgrid.Tests/AdminAssist/DispatchTraceWriterTests.cs b/Tests/Resgrid.Tests/AdminAssist/DispatchTraceWriterTests.cs new file mode 100644 index 000000000..3c4244b2f --- /dev/null +++ b/Tests/Resgrid.Tests/AdminAssist/DispatchTraceWriterTests.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Services; +using Resgrid.Services.AdminAssist; + +namespace Resgrid.Tests.AdminAssist +{ + [TestFixture, NonParallelizable] + public class DispatchTraceWriterTests + { + private Mock _store; + private Mock _write; + private Mock _protection; + private AdminAssistTraceWriter _writer; + private Mock _flags; + private bool _wasCaptureEnabled; + [SetUp] + public void SetUp() + { + _store = new(); _write = new(); _protection = new(); + _wasCaptureEnabled = Resgrid.Config.AdminAssistConfig.CaptureDispatchTraces; + Resgrid.Config.AdminAssistConfig.CaptureDispatchTraces = true; + _flags = new(); _flags.Setup(f => f.EvaluateFreshAsync(FeatureFlagKeys.AdminAssist, 7)).ReturnsAsync(new FeatureFlagEvaluation { IsEnabled = true }); + _store.Setup(s => s.TraceDepartmentExistsAsync(7, It.IsAny())).ReturnsAsync(true); + _protection.Setup(p => p.GetPinnedCatalogVersionAsync(7)).ReturnsAsync(30); + SetWrite(false); _writer = new(_store.Object, _write.Object, _protection.Object, _flags.Object); + } + [TearDown] public void Reset() => Resgrid.Config.AdminAssistConfig.CaptureDispatchTraces = _wasCaptureEnabled; + [TestCase(false, true)] [TestCase(true, false)] + public async Task New_trace_requires_both_host_capture_and_department_assist_rollout(bool capture, bool enabled) + { + Resgrid.Config.AdminAssistConfig.CaptureDispatchTraces = capture; + _flags.Setup(f => f.EvaluateFreshAsync(FeatureFlagKeys.AdminAssist, 7)).ReturnsAsync(new FeatureFlagEvaluation { IsEnabled = enabled }); + var observation = JsonSerializer.Deserialize(DispatchTraceQueueTests.Row().Content); + Assert.That(await _writer.PrepareAsync(observation, CancellationToken.None), Is.Null); + await _writer.PersistAsync(observation, CancellationToken.None); + _write.VerifyNoOtherCalls(); _store.VerifyNoOtherCalls(); _protection.VerifyNoOtherCalls(); + } + [Test] + public async Task Flag_store_failure_does_not_create_new_durable_trace_evidence() + { + _flags.Setup(f => f.EvaluateFreshAsync(FeatureFlagKeys.AdminAssist, 7)).ThrowsAsync(new InvalidOperationException("Unavailable")); + Assert.That(await _writer.PrepareAsync(JsonSerializer.Deserialize(DispatchTraceQueueTests.Row().Content), CancellationToken.None), Is.Null); + _write.VerifyNoOtherCalls(); _store.VerifyNoOtherCalls(); + } + [Test] + public async Task Disabling_rollout_does_not_discard_already_queued_evidence_or_bypass_protection() + { + Resgrid.Config.AdminAssistConfig.CaptureDispatchTraces = false; + _flags.Setup(f => f.EvaluateFreshAsync(FeatureFlagKeys.AdminAssist, 7)).ReturnsAsync(new FeatureFlagEvaluation { IsEnabled = false }); + _protection.Setup(p => p.ShouldEncryptNewWritesAsync(7)).ReturnsAsync(true); SetWrite(true); + await _writer.PersistPreparedAsync(DispatchTraceQueueTests.Row(), CancellationToken.None); + _store.Verify(s => s.SaveTraceAsync(It.Is(r => r.IsProtected), It.IsAny()), Times.Once); + } + private void SetWrite(bool encrypt, bool fail = false) + { + _write.Setup(w => w.PrepareRecordsEntityWriteAsync(7, It.IsAny(), null, It.IsAny(), + It.IsAny, Action)>>(), + It.IsAny(), null, null, true, It.IsAny())) + .Callback(new InvocationAction(i => + { + if (encrypt && !fail) { ((AdminAssistDispatchTraceRow)i.Arguments[1]).Content = "rgdp:1:1:encrypted-test-payload"; ((Action)i.Arguments[5])(); } + })).ReturnsAsync(fail ? ProtectedWriteResult.Blocked("broker_unavailable") : ProtectedWriteResult.Allowed(encrypt)); + } + [Test] + public async Task Preparing_for_the_queue_encrypts_before_any_persistence() + { + _protection.Setup(p => p.ShouldEncryptNewWritesAsync(7)).ReturnsAsync(true); SetWrite(true); + var row = DispatchTraceQueueTests.Row(); + var prepared = await _writer.PrepareAsync(JsonSerializer.Deserialize(row.Content), CancellationToken.None); + Assert.That(prepared.IsProtected, Is.True); Assert.That(prepared.Content, Does.Not.Contain("member-reference")); + _store.Verify(s => s.SaveTraceAsync(It.IsAny(), It.IsAny()), Times.Never); + } + [Test] + public async Task Enrollment_after_queueing_rechecks_protection_before_storage() + { + var source = DispatchTraceQueueTests.Row(); + var prepared = await _writer.PrepareAsync(JsonSerializer.Deserialize(source.Content), CancellationToken.None); + Assert.That(prepared.IsProtected, Is.False); + _protection.Setup(p => p.ShouldEncryptNewWritesAsync(7)).ReturnsAsync(true); SetWrite(true); + await _writer.PersistPreparedAsync(prepared, CancellationToken.None); + _store.Verify(s => s.SaveTraceAsync(It.Is(r => r.IsProtected && r.ProtectedCatalogVersion == 30 && r.Content.StartsWith("rgdp:")), It.IsAny()), Times.Once); + } + [Test] + public void A_protection_outage_does_not_fall_back_to_plaintext() + { + SetWrite(true, true); + Assert.ThrowsAsync(async () => await _writer.PersistPreparedAsync(DispatchTraceQueueTests.Row(), CancellationToken.None)); + _store.Verify(s => s.SaveTraceAsync(It.IsAny(), It.IsAny()), Times.Never); + } + [Test] + public void Old_protection_catalog_requires_upgrade_before_queueing() + { + _protection.Setup(p => p.ShouldEncryptNewWritesAsync(7)).ReturnsAsync(true); _protection.Setup(p => p.GetPinnedCatalogVersionAsync(7)).ReturnsAsync(29); + Assert.ThrowsAsync(async () => await _writer.PrepareAsync(JsonSerializer.Deserialize(DispatchTraceQueueTests.Row().Content), CancellationToken.None)); + _write.VerifyNoOtherCalls(); + } + [Test] + public async Task Late_queue_delivery_does_not_recreate_deleted_department_evidence() + { + _store.Setup(s => s.TraceDepartmentExistsAsync(7, It.IsAny())).ReturnsAsync(false); + await _writer.PersistPreparedAsync(DispatchTraceQueueTests.Row(), CancellationToken.None); + _write.VerifyNoOtherCalls(); _store.Verify(s => s.SaveTraceAsync(It.IsAny(), It.IsAny()), Times.Never); + } + } +} diff --git a/Tests/Resgrid.Tests/AdminAssist/FeatureToggleTests.cs b/Tests/Resgrid.Tests/AdminAssist/FeatureToggleTests.cs new file mode 100644 index 000000000..6d2ab42c4 --- /dev/null +++ b/Tests/Resgrid.Tests/AdminAssist/FeatureToggleTests.cs @@ -0,0 +1,84 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Resgrid.AdminAssist; +using Resgrid.Model; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Services; +using Resgrid.Services.AdminAssist; + +namespace Resgrid.Tests.AdminAssist +{ + [TestFixture] + public class FeatureToggleTests + { + private static readonly AdminAssistActor Actor = new(7, "admin"); + private Mock _flags; + private Mock _membership; + private AdminAssistAccessService _access; + [SetUp] + public void SetUp() + { + _flags = new(); _membership = new(); + _membership.Setup(a => a.IsActiveMemberAsync("admin", 7)).ReturnsAsync(true); + _membership.Setup(a => a.IsDepartmentAdminAsync("admin", 7)).ReturnsAsync(true); + _access = new(_membership.Object, _flags.Object, Mock.Of(), Mock.Of(), + Mock.Of(), Mock.Of(), Mock.Of(), + new ConfigurationCatalog(), TimeProvider.System, Mock.Of()); + } + private void Set(string key, bool enabled) => _flags.Setup(f => f.EvaluateFreshAsync(key, Actor.DepartmentId)).ReturnsAsync(new FeatureFlagEvaluation { Key = key, IsEnabled = enabled }); + + [TestCase(false,false,false)] [TestCase(false,false,true)] + [TestCase(true,false,false)] [TestCase(true,false,true)] + [TestCase(false,true,false)] [TestCase(false,true,true)] + [TestCase(true,true,false)] [TestCase(true,true,true)] + public async Task Setup_and_assist_are_independent_and_AI_does_not_enable_either(bool setup, bool assist, bool ai) + { + Set(FeatureFlagKeys.AdminSetup,setup); Set(FeatureFlagKeys.AdminAssist,assist); Set(FeatureFlagKeys.AiAdminAssist,ai); + Assert.That(await _access.CanAccessAsync(Actor,true), Is.EqualTo(setup)); + Assert.That(await _access.CanAccessAsync(Actor,false), Is.EqualTo(assist)); + Assert.That(await AdminAssistFeatureAvailability.CanConfigureOperatingProfileAsync(_flags.Object,7), Is.EqualTo(setup || assist)); + _flags.Verify(f => f.EvaluateFreshAsync(FeatureFlagKeys.AiAdminAssist,7), Times.Never); + } + [TestCase(true)] [TestCase(false)] + public async Task Missing_flag_and_store_outage_fail_closed(bool setup) + { + Assert.That(await _access.CanAccessAsync(Actor,setup), Is.False); + _flags.Setup(f => f.EvaluateFreshAsync(It.IsAny(),7)).ThrowsAsync(new InvalidOperationException("Store unavailable")); + Assert.That(await _access.CanAccessAsync(Actor,setup), Is.False); + } + [TestCase(true)] [TestCase(false)] + public async Task Revocation_takes_effect_on_the_next_access_check(bool setup) + { + _flags.SetupSequence(f => f.EvaluateFreshAsync(setup ? FeatureFlagKeys.AdminSetup : FeatureFlagKeys.AdminAssist,7)) + .ReturnsAsync(new FeatureFlagEvaluation { IsEnabled = true }).ReturnsAsync(new FeatureFlagEvaluation { IsEnabled = false }); + Assert.That(await _access.CanAccessAsync(Actor,setup), Is.True); + Assert.That(await _access.CanAccessAsync(Actor,setup), Is.False); + } + [TestCase(true)] [TestCase(false)] + public async Task Enabling_a_flag_does_not_authorize_an_ordinary_member(bool setup) + { + Set(FeatureFlagKeys.AdminSetup,true); Set(FeatureFlagKeys.AdminAssist,true); + _membership.Setup(a => a.IsDepartmentAdminAsync("admin",7)).ReturnsAsync(false); + Assert.That(await _access.CanAccessAsync(Actor,setup), Is.False); + _flags.VerifyNoOtherCalls(); + } + [TestCase(true)] [TestCase(false)] + public void Disabled_overview_does_not_read_department_evidence_or_progress(bool setup) + { + var snapshots = new Mock(MockBehavior.Strict); + var repository = new Mock(MockBehavior.Strict); + var service = new AdminAssistService(_access,new ConfigurationCatalog(),snapshots.Object,repository.Object,TimeProvider.System); + Assert.ThrowsAsync(async () => await service.GetOverviewAsync(Actor,setup)); + snapshots.VerifyNoOtherCalls(); repository.VerifyNoOtherCalls(); + } + [Test] + public void Caller_cancellation_is_not_swallowed_as_a_disabled_flag() + { + using var cancellation = new CancellationTokenSource(); cancellation.Cancel(); + Assert.ThrowsAsync(async () => await AdminAssistFeatureAvailability.IsEnabledAsync(_flags.Object,7,true,cancellation.Token)); + } + } +} diff --git a/Tests/Resgrid.Tests/AdminAssist/FindingLifecycleTests.cs b/Tests/Resgrid.Tests/AdminAssist/FindingLifecycleTests.cs new file mode 100644 index 000000000..33476c5d0 --- /dev/null +++ b/Tests/Resgrid.Tests/AdminAssist/FindingLifecycleTests.cs @@ -0,0 +1,81 @@ +using System; +using System.Linq; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Resgrid.AdminAssist; +using Resgrid.Model; +using Resgrid.Model.AdminAssist; + +namespace Resgrid.Tests.AdminAssist +{ + [TestFixture] + public class FindingLifecycleTests + { + private static readonly DateTime Now = new(2026, 9, 24, 12, 0, 0, DateTimeKind.Utc); + private static ConfigurationFinding Finding(RuleResult result) => new("admin-mfa", "security", FindingSeverity.Critical, + result, "title", "help", "next", "/User/Department/Settings", Array.Empty(), "4", Now); + private static AdminAssistFindingRow Row() => new() { Result = (int)RuleResult.Unknown }; + [Test] + public void Unknown_then_failure_opens_once_and_verified_pass_resolves() + { + var row = Row(); + Assert.That(FindingLifecycle.Observe(row, Finding(RuleResult.Unknown), Now), Is.Null); + Assert.That(FindingLifecycle.Observe(row, Finding(RuleResult.Fail), Now), Is.EqualTo(WorkflowTriggerEventType.AdminAssistFindingOpened)); + Assert.That(row.Episode, Is.EqualTo(1)); + Assert.That(FindingLifecycle.Observe(row, Finding(RuleResult.Fail), Now), Is.Null); + Assert.That(FindingLifecycle.Observe(row, Finding(RuleResult.Unknown), Now), Is.Null); + Assert.That(row.ReviewStatus, Is.Not.EqualTo((int)FindingReviewStatus.Resolved)); + Assert.That(FindingLifecycle.Observe(row, Finding(RuleResult.Pass), Now), Is.EqualTo(WorkflowTriggerEventType.AdminAssistFindingResolved)); + Assert.That(FindingLifecycle.Observe(row, Finding(RuleResult.Pass), Now), Is.Null); + Assert.That(FindingLifecycle.Observe(row, Finding(RuleResult.Fail), Now), Is.EqualTo(WorkflowTriggerEventType.AdminAssistFindingReopened)); + Assert.That(row.Episode, Is.EqualTo(2)); + } + [Test] + public void Exception_is_not_resolution_and_expiry_reopens_once() + { + var row = Row(); + FindingLifecycle.Observe(row, Finding(RuleResult.Fail), Now); + row.ReviewStatus = (int)FindingReviewStatus.AcceptedException; + row.ExceptionUntil = Now.AddHours(1); + Assert.That(FindingLifecycle.Observe(row, Finding(RuleResult.Fail), Now), Is.Null); + Assert.That(row.Result, Is.EqualTo((int)RuleResult.Fail)); + Assert.That(FindingLifecycle.Observe(row, Finding(RuleResult.Fail), Now.AddHours(1)), Is.EqualTo(WorkflowTriggerEventType.AdminAssistFindingReopened)); + Assert.That(FindingLifecycle.Observe(row, Finding(RuleResult.Fail), Now.AddHours(2)), Is.Null); + Assert.That(row.ExceptionUntil, Is.Null); + } + [Test] + public void Evidence_outage_after_resolution_does_not_emit_another_resolution() + { + var row = Row(); + FindingLifecycle.Observe(row, Finding(RuleResult.Fail), Now); + FindingLifecycle.Observe(row, Finding(RuleResult.Pass), Now); + FindingLifecycle.Observe(row, Finding(RuleResult.Unknown), Now); + Assert.That(FindingLifecycle.Observe(row, Finding(RuleResult.Pass), Now), Is.Null); + } + [Test] + public void Exception_expiring_during_an_outage_reopens_on_the_next_verified_failure() + { + var row = Row(); + FindingLifecycle.Observe(row, Finding(RuleResult.Fail), Now); + row.ReviewStatus = (int)FindingReviewStatus.AcceptedException; + row.ExceptionUntil = Now.AddHours(1); + Assert.That(FindingLifecycle.Observe(row, Finding(RuleResult.Unknown), Now.AddHours(2)), Is.Null); + Assert.That(FindingLifecycle.Observe(row, Finding(RuleResult.Fail), Now.AddHours(3)), Is.EqualTo(WorkflowTriggerEventType.AdminAssistFindingReopened)); + Assert.That(FindingLifecycle.Observe(row, Finding(RuleResult.Fail), Now.AddHours(4)), Is.Null); + } + [Test] + public void Workflow_projection_strips_notes_owners_grants_unknown_rules_and_invalid_scalars() + { + var id = Guid.NewGuid().ToString("D"); + var input = JObject.FromObject(new { FindingId = id, RuleId = "admin-mfa", Episode = 1, Result = 1, Severity = 2, + ReviewStatus = 0, Content = "protected note", OwnerId = "private user", GrantToken = "secret" }); + var output = JObject.Parse(AdminAssistWorkflowPayload.Routing(input)); + Assert.That(output.Properties().Select(p => p.Name), Is.EquivalentTo(new[] { "FindingId", "RuleId", "Episode", "Result", "Severity", "ReviewStatus" })); + Assert.That(output["FindingId"].Value(), Is.EqualTo(id)); + input["RuleId"] = "private arbitrary string"; input["Episode"] = -1; input["Result"] = 4; input["Severity"] = "2"; + output = JObject.Parse(AdminAssistWorkflowPayload.Routing(input)); + Assert.That(output.Properties().Select(p => p.Name), Is.EquivalentTo(new[] { "FindingId", "ReviewStatus" })); + Assert.That(AdminAssistWorkflowPayload.RuleIds, Is.EquivalentTo(new ConfigurationCatalog().Rules.Select(r => r.Id))); + } + } +} diff --git a/Tests/Resgrid.Tests/AdminAssist/ImportEvidenceTests.cs b/Tests/Resgrid.Tests/AdminAssist/ImportEvidenceTests.cs new file mode 100644 index 000000000..90fc11823 --- /dev/null +++ b/Tests/Resgrid.Tests/AdminAssist/ImportEvidenceTests.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services.AdminAssist; + +namespace Resgrid.Tests.AdminAssist +{ + [TestFixture] + public class ImportEvidenceTests + { + [TestCase(null, 60, EvidenceState.NotApplicable, null)] + [TestCase(30, 60, EvidenceState.Known, true)] + [TestCase(30, 5, EvidenceState.Known, false)] + [TestCase(30, -5, EvidenceState.Unknown, null)] + [TestCase(30, 0, EvidenceState.Unknown, null)] + public async Task Polling_uses_declared_interval_and_recorded_poll_not_call_volume(int? expected, int age, EvidenceState state, bool? missing) + { + var now = DateTime.UtcNow; + var rows = new Mock(); var settings = new Mock(); + settings.Setup(s => s.GetOperatingProfileAsync(7)).ReturnsAsync(new DepartmentOperatingProfile { ExpectedEmailPollIntervalMinutes = expected }); + rows.Setup(r => r.GetAllByDepartmentIdAsync(7)).ReturnsAsync(new[] { new DepartmentCallEmail { DepartmentId = 7, + LastCheck = age == 0 ? null : now.AddMinutes(-age), IsFailure = true, Username = "private-mailbox", Password = "secret", ErrorMessage = "private-error" } }); + var source = new ImportEvidenceSource(rows.Object, settings.Object); + var facts = await source.ReadAsync(new(7, "admin"), now, CancellationToken.None); + var heartbeat = facts.Single(f => f.Id == "importHeartbeatMissing"); + Assert.That(heartbeat.State, Is.EqualTo(state)); Assert.That(heartbeat.Boolean, Is.EqualTo(missing)); + Assert.That(facts.Single(f => f.Id == "emailImportFailureCount").Number, Is.EqualTo(1)); + var json = Newtonsoft.Json.JsonConvert.SerializeObject(facts); + Assert.That(json, Does.Not.Contain("private-").And.Not.Contain("secret")); + } + [Test] + public async Task No_connector_with_declared_expectation_is_unknown_and_cross_tenant_evidence_is_rejected() + { + var rows = new Mock(); var settings = new Mock(); + settings.Setup(s => s.GetOperatingProfileAsync(7)).ReturnsAsync(new DepartmentOperatingProfile { ExpectedEmailPollIntervalMinutes = 30 }); + rows.Setup(r => r.GetAllByDepartmentIdAsync(7)).ReturnsAsync(Array.Empty()); + var source = new ImportEvidenceSource(rows.Object, settings.Object); + Assert.That((await source.ReadAsync(new(7, "admin"), DateTime.UtcNow, CancellationToken.None)).Single(f => f.Id == "importHeartbeatMissing").State, Is.EqualTo(EvidenceState.Unknown)); + rows.Setup(r => r.GetAllByDepartmentIdAsync(7)).ReturnsAsync(new[] { new DepartmentCallEmail { DepartmentId = 8 } }); + Assert.ThrowsAsync(async () => await source.ReadAsync(new(7, "admin"), DateTime.UtcNow, CancellationToken.None)); + } + } +} diff --git a/Tests/Resgrid.Tests/AdminAssist/MaintenanceTests.cs b/Tests/Resgrid.Tests/AdminAssist/MaintenanceTests.cs new file mode 100644 index 000000000..32db7deec --- /dev/null +++ b/Tests/Resgrid.Tests/AdminAssist/MaintenanceTests.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Services; +using Resgrid.Model.Identity; +using Resgrid.Services.AdminAssist; + +namespace Resgrid.Tests.AdminAssist +{ + [TestFixture, NonParallelizable] + public class MaintenanceTests + { + private static readonly DateTime Now = new(2026, 9, 21, 12, 0, 0, DateTimeKind.Utc); + private sealed class Clock : TimeProvider { public override DateTimeOffset GetUtcNow() => new(Now); } + private Mock _store; + private Mock _departments; + private Mock _communication; + private AdminAssistMaintenanceService _service; + private Mock _access; + private Mock _worklist; + private bool _wasEnabled; + [SetUp] + public void Setup() + { + _wasEnabled = Resgrid.Config.AdminAssistConfig.SendAdminDigests; Resgrid.Config.AdminAssistConfig.SendAdminDigests = true; + _store = new(); _departments = new(); _communication = new(); + _store.Setup(s => s.TryLeaseAsync(7, It.IsAny(), Now, It.IsAny())).ReturnsAsync(true); + _departments.Setup(d => d.GetActiveAdminsForDepartmentAsync(7)).ReturnsAsync(new List { new() { UserId = "admin" } }); + _departments.Setup(d => d.GetDepartmentByIdAsync(7, true)).ReturnsAsync(new Department { DepartmentId = 7, TimeZone = "UTC" }); + var preference = new AdminAssistPreferences { DepartmentId = 7, UserId = "admin", DigestEnabled = true, Revision = 1 }; + _store.Setup(s => s.GetDigestPreferencesAsync(7, It.IsAny())).ReturnsAsync(new[] { preference }); + _store.Setup(s => s.GetPreferencesAsync(7, "admin", It.IsAny())).ReturnsAsync(preference); + _access = new(); _access.Setup(a => a.CanAccessAsync(It.IsAny(), false, It.IsAny())).ReturnsAsync(true); + _worklist = new(); + var membership = new Mock(); membership.Setup(m => m.IsAssignableMemberAsync("admin", 7)).ReturnsAsync(true); + var profiles = new Mock(); profiles.Setup(p => p.GetProfileByUserIdAsync("admin", true)).ReturnsAsync(new UserProfile { UserId = "admin" }); + _service = new AdminAssistMaintenanceService(_store.Object, _access.Object, _worklist.Object, _departments.Object, + membership.Object, _communication.Object, Mock.Of(), profiles.Object, new Clock()); + } + [TearDown] public void Reset() => Resgrid.Config.AdminAssistConfig.SendAdminDigests = _wasEnabled; + [Test] + public async Task Disabled_assist_skips_evaluation_and_digests_but_keeps_retention_cleanup() + { + _access.Setup(a => a.CanAccessAsync(It.IsAny(), false, It.IsAny())).ReturnsAsync(false); + await _service.RunDepartmentAsync(7, CancellationToken.None); + _worklist.VerifyNoOtherCalls(); _communication.VerifyNoOtherCalls(); + _store.Verify(s => s.PurgeExpiredMetadataAsync(7, Now, It.IsAny()), Times.Once); + _store.Verify(s => s.GetDigestPreferencesAsync(7, It.IsAny()), Times.Never); + } + [Test] + public async Task No_administrator_still_runs_cleanup_and_releases_the_lease() + { + _departments.Setup(d => d.GetActiveAdminsForDepartmentAsync(7)).ReturnsAsync(new List()); + await _service.RunDepartmentAsync(7, CancellationToken.None); + _store.Verify(s => s.PurgeExpiredMetadataAsync(7, Now, It.IsAny()), Times.Once); + _store.Verify(s => s.CompleteLeaseAsync(7, It.IsAny(), null, It.IsAny()), Times.Once); + _communication.VerifyNoOtherCalls(); + } + [Test] + public async Task Revocation_after_claim_suppresses_the_provider_handoff() + { + _store.Setup(s => s.ClaimDigestAsync(It.IsAny(), It.IsAny(), Now, It.IsAny())).ReturnsAsync(true); + _store.Setup(s => s.GetPreferencesAsync(7, "admin", It.IsAny())).ReturnsAsync(new AdminAssistPreferences { DepartmentId = 7, UserId = "admin", Revision = 2, DigestEnabled = false }); + await _service.RunDepartmentAsync(7, CancellationToken.None); + _communication.VerifyNoOtherCalls(); + _store.Verify(s => s.CompleteDigestAsync(7, "admin", "2026-09-21", "Suppressed", Now, It.IsAny()), Times.Once); + } + [Test] + public async Task Ambiguous_provider_failure_is_not_retried_after_the_durable_weekly_claim() + { + _store.SetupSequence(s => s.ClaimDigestAsync(It.IsAny(), It.IsAny(), Now, It.IsAny())).ReturnsAsync(true).ReturnsAsync(false); + _communication.Setup(c => c.SendNotificationAsync("admin", 7, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), false)) + .ThrowsAsync(new InvalidOperationException("provider result unknown")); + await _service.RunDepartmentAsync(7, CancellationToken.None); + await _service.RunDepartmentAsync(7, CancellationToken.None); + _communication.Verify(c => c.SendNotificationAsync("admin", 7, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), false), Times.Once); + _store.Verify(s => s.CompleteDigestAsync(7, "admin", "2026-09-21", "HandoffUnconfirmed", Now, It.IsAny()), Times.Once); + } + } +} diff --git a/Tests/Resgrid.Tests/AdminAssist/MappingImpactTests.cs b/Tests/Resgrid.Tests/AdminAssist/MappingImpactTests.cs new file mode 100644 index 000000000..74c02b41d --- /dev/null +++ b/Tests/Resgrid.Tests/AdminAssist/MappingImpactTests.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services.AdminAssist; + +namespace Resgrid.Tests.AdminAssist +{ + [TestFixture] + public class MappingImpactTests + { + private readonly DateTime _now = new(2026, 9, 24, 12, 0, 0, DateTimeKind.Utc); + [Test] + public void Shared_selector_matches_legacy_branching_including_boundary_and_fallback() + { + var dates = new DateTime?[] { null, _now.AddMinutes(-61), _now.AddMinutes(-60), _now.AddMinutes(-5), _now }; + foreach (var ping in dates) + foreach (var status in dates) + foreach (var hasLocation in new[] { true, false }) + foreach (var overwrite in new[] { true, false }) + foreach (var ttl in new[] { 0, 60 }) + { + var expected = Legacy(ping, status, hasLocation, ttl, overwrite); + Assert.That(MappingMarkerSelection.Select(ping, status, hasLocation, ttl, overwrite, _now), Is.EqualTo(expected)); + } + } + private MappingMarkerSource Legacy(DateTime? ping, DateTime? status, bool hasLocation, int ttl, bool overwrite) + { + if (ttl > 0 && ping.HasValue && _now.AddMinutes(-ttl) > ping) ping = null; + if (ping.HasValue && status.HasValue) + { + if (ping > status) return MappingMarkerSource.LocationPing; + if (hasLocation) return MappingMarkerSource.Status; + if (!overwrite) return MappingMarkerSource.LocationPing; + } + else if (ping.HasValue) return MappingMarkerSource.LocationPing; + else if (status.HasValue && hasLocation) return MappingMarkerSource.Status; + return MappingMarkerSource.None; + } + [Test] + public void Newer_ping_does_not_parse_malformed_older_status() + { + Assert.That(MappingMarkerSelection.Select(_now, _now.AddMinutes(-1), () => throw new FormatException(), 60, true, _now), Is.EqualTo(MappingMarkerSource.LocationPing)); + } + [TestCase(false, false, EvidenceState.Known)] + [TestCase(true, false, EvidenceState.Redacted)] + [TestCase(false, true, EvidenceState.Unknown)] + public async Task Unit_preview_counts_fallbacks_and_never_converts_missing_or_restricted_data_to_zero(bool restricted, bool outage, EvidenceState expected) + { + var units = new Mock(); var rows = new Mock(); var states = new Mock(); var authorization = new Mock(); + rows.Setup(r => r.GetAllUnitsByDepartmentIdAsync(7)).ReturnsAsync(new List { new() { UnitId = 1, DepartmentId = 7 }, new() { UnitId = 2, DepartmentId = 7 } }); + units.Setup(u => u.ReadLatestLocationsForAdministrationAsync(7)).ReturnsAsync(new List { + new() { UnitId = 1, DepartmentId = 7, Timestamp = _now.AddMinutes(-90) }, new() { UnitId = 2, DepartmentId = 7, Timestamp = _now.AddMinutes(-90) } + }); + if (outage) units.Setup(u => u.ReadLatestLocationsForAdministrationAsync(7)).ThrowsAsync(new InvalidOperationException("source failed")); + states.Setup(s => s.GetLatestUnitStatesForDepartmentAsync(7)).ReturnsAsync(new List { + new() { UnitId = 1, Timestamp = _now.AddHours(-2), Latitude = 40, Longitude = -120 }, new() { UnitId = 2, Timestamp = _now.AddHours(-2) } + }); + authorization.Setup(a => a.CanUserViewUnitLocationViaMatrixAsync(It.IsAny(), "admin", 7)).ReturnsAsync(!restricted); + var service = new MappingImpactProvider(Mock.Of(), units.Object, rows.Object, states.Object, Mock.Of(), authorization.Object, Mock.Of()); + var facts = new Dictionary { + ["MappingUnitLocationTTL"] = new("MappingUnitLocationTTL", EvidenceState.Known, "test", "1", _now, Number: 0), + ["MappingUnitAllowStatusWithNoLocationToOverwrite"] = new("MappingUnitAllowStatusWithNoLocationToOverwrite", EvidenceState.Known, "test", "1", _now, Boolean: false) + }; + var report = await service.EvaluateAsync(new(7, "admin"), new(7, "admin", "1", _now, true, facts), new("setting.MappingUnitLocationTTL", "1", Number: 60), CancellationToken.None); + var metric = report.Metrics.First(); Assert.That(metric.State, Is.EqualTo(expected)); + if (expected == EvidenceState.Known) + { + Assert.That(metric.Before, Is.EqualTo(2)); Assert.That(metric.After, Is.EqualTo(1)); + Assert.That(report.Metrics.Single(m => m.LabelKey == "Impact.MarkersRemoved").After, Is.EqualTo(1)); + Assert.That(report.Metrics.Single(m => m.LabelKey == "Impact.StatusFallbackMarkers").After, Is.EqualTo(1)); + } + else { Assert.That(metric.Before, Is.Null); Assert.That(metric.After, Is.Null); } + } + } +} diff --git a/Tests/Resgrid.Tests/AdminAssist/ModuleImpactTests.cs b/Tests/Resgrid.Tests/AdminAssist/ModuleImpactTests.cs new file mode 100644 index 000000000..e49d7b876 --- /dev/null +++ b/Tests/Resgrid.Tests/AdminAssist/ModuleImpactTests.cs @@ -0,0 +1,85 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Resgrid.AdminAssist; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Repositories; +using Resgrid.Services.AdminAssist; + +namespace Resgrid.Tests.AdminAssist +{ + [TestFixture] + public class ModuleImpactTests + { + private readonly AdminAssistActor _actor = new(7, "admin"); + private sealed class Fixture + { + public readonly Mock Access = new(); + public readonly Mock Repository = new(); + public readonly Mock Settings = new(); + public readonly Mock Counts = new(); + public ModuleImpactService Service => new(Access.Object, Repository.Object, new ConfigurationCatalog(), Settings.Object, Counts.Object, TimeProvider.System); + public Fixture() + { + Access.Setup(a => a.CanAccessAsync(It.IsAny(), false, It.IsAny())).ReturnsAsync(true); + Settings.Setup(s => s.GetAllByDepartmentIdAsync(7)).ReturnsAsync(Array.Empty()); + Counts.Setup(c => c.ReadModuleImpactCountsAsync(7, It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(new ModuleImpactCounts(8, 3)); + } + } + [Test] + public async Task Hiding_navigation_preserves_stored_data_and_counts_current_member_impact() + { + var f = new Fixture(); var report = await f.Service.PreviewAsync(_actor, new("0", "Documents", true)); + var menus = report.Metrics.Single(m => m.LabelKey == "Impact.ModuleMenus"); + Assert.That(menus.Before, Is.EqualTo(8)); Assert.That(menus.After, Is.Zero); + var stored = report.Metrics.Single(m => m.LabelKey == "Impact.ModuleDataRows"); + Assert.That(stored.Before, Is.EqualTo(3)); Assert.That(stored.After, Is.EqualTo(3)); + Assert.That(report.LimitKeys, Does.Contain("Impact.ModuleTiming")); + f.Settings.Verify(s => s.GetAllByDepartmentIdAsync(7), Times.Exactly(2)); f.Settings.VerifyNoOtherCalls(); + } + [Test] + public async Task Enabling_a_hidden_module_restores_its_entry_without_claiming_to_create_data() + { + var f = new Fixture(); + f.Settings.Setup(s => s.GetAllByDepartmentIdAsync(7)).ReturnsAsync(new[] { new DepartmentSetting { DepartmentId = 7, SettingType = (int)DepartmentSettingTypes.ModuleSettings, + Setting = ObjectSerialization.Serialize(new DepartmentModuleSettings { DocumentsDisabled = true }) } }); + var report = await f.Service.PreviewAsync(_actor, new("0", "Documents", false)); + var menus = report.Metrics.Single(m => m.LabelKey == "Impact.ModuleMenus"); + Assert.That(menus.Before, Is.Zero); Assert.That(menus.After, Is.EqualTo(8)); + } + [Test] + public async Task Uncounted_derived_module_data_stays_unknown_while_verified_menu_effect_is_shown() + { + var f = new Fixture(); f.Counts.Setup(c => c.ReadModuleImpactCountsAsync(7, "Mapping", It.IsAny(), It.IsAny())).ReturnsAsync(new ModuleImpactCounts(8, null)); + var report = await f.Service.PreviewAsync(_actor, new("0", "Mapping", true)); + Assert.That(report.Metrics.Single(m => m.LabelKey == "Impact.ModuleDataRows").State, Is.EqualTo(EvidenceState.Unknown)); + Assert.That(report.Metrics.Single(m => m.LabelKey == "Impact.ModuleMenus").State, Is.EqualTo(EvidenceState.Known)); + } + [Test] + public async Task Missing_source_is_unknown_and_never_becomes_an_empty_department() + { + var f = new Fixture(); f.Counts.Setup(c => c.ReadModuleImpactCountsAsync(7, "Documents", It.IsAny(), It.IsAny())).ThrowsAsync(new InvalidOperationException()); + var report = await f.Service.PreviewAsync(_actor, new("0", "Documents", true)); + Assert.That(report.Metrics.Single().State, Is.EqualTo(EvidenceState.Unknown)); Assert.That(report.Metrics.Single().After, Is.Null); + } + [Test] + public void Concurrent_data_or_access_change_invalidates_the_preview() + { + var f = new Fixture(); f.Counts.SetupSequence(c => c.ReadModuleImpactCountsAsync(7, "Documents", It.IsAny(), It.IsAny())).ReturnsAsync(new ModuleImpactCounts(8, 3)).ReturnsAsync(new ModuleImpactCounts(8, 4)); + Assert.ThrowsAsync(async () => await f.Service.PreviewAsync(_actor, new("0", "Documents", true))); + f = new Fixture(); f.Access.SetupSequence(a => a.CanAccessAsync(It.IsAny(), false, It.IsAny())).ReturnsAsync(true).ReturnsAsync(false); + Assert.ThrowsAsync(async () => await f.Service.PreviewAsync(_actor, new("0", "Documents", true))); + } + [TestCase("BusinessOperations")][TestCase("Maintenance")][TestCase("Checklists")][TestCase("Notes; DELETE")] + public void Unreviewed_and_arbitrary_modules_are_rejected_before_reading_sources(string module) + { + var f = new Fixture(); Assert.ThrowsAsync(async () => await f.Service.PreviewAsync(_actor, new("0", module, true))); + f.Settings.VerifyNoOtherCalls(); f.Counts.VerifyNoOtherCalls(); + } + } +} diff --git a/Tests/Resgrid.Tests/AdminAssist/NotificationImpactTests.cs b/Tests/Resgrid.Tests/AdminAssist/NotificationImpactTests.cs new file mode 100644 index 000000000..a436acfc6 --- /dev/null +++ b/Tests/Resgrid.Tests/AdminAssist/NotificationImpactTests.cs @@ -0,0 +1,106 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Resgrid.AdminAssist; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Repositories; +using Resgrid.Services.AdminAssist; + +namespace Resgrid.Tests.AdminAssist +{ + [TestFixture, NonParallelizable] + public class NotificationImpactTests + { + private bool _broadcastDisabled; + [SetUp] public void SetUp() { _broadcastDisabled = Resgrid.Config.SystemBehaviorConfig.DoNotBroadcast; Resgrid.Config.SystemBehaviorConfig.DoNotBroadcast = false; } + [TearDown] public void TearDown() => Resgrid.Config.SystemBehaviorConfig.DoNotBroadcast = _broadcastDisabled; + private static readonly AdminAssistActor Actor = new(7, "admin"); + private static NotificationMemberEvidence Member(int id, int? staffing = 0) => new() { DepartmentId = 7, MemberId = id, UserId = "member-" + id, + ProfileId = id, Sms = true, Email = true, Push = true, StaffingKnown = staffing.HasValue, Staffing = staffing }; + private sealed class Fixture + { + public readonly Mock Access = new(); + public readonly Mock Repository = new(); + public readonly Mock Settings = new(); + public readonly Mock Store = new(); + public NotificationImpactService Service => new(Access.Object, Repository.Object, new ConfigurationCatalog(), Settings.Object, Store.Object, TimeProvider.System); + public Fixture(params NotificationMemberEvidence[] members) + { + Access.Setup(a => a.CanAccessAsync(Actor, false, It.IsAny())).ReturnsAsync(true); + Settings.Setup(s => s.GetAllByDepartmentIdAsync(7)).ReturnsAsync(new[] { new DepartmentSetting { DepartmentId = 7, + SettingType = (int)DepartmentSettingTypes.StaffingSuppressStaffingLevels, + Setting = ObjectSerialization.Serialize(new DepartmentSuppressStaffingInfo { StaffingLevelsToSupress = new() { 2 } }) } }); + Store.Setup(s => s.ReadNotificationMembersAsync(7, It.IsAny(), It.IsAny())).ReturnsAsync(members); + } + } + private static ConfigurationImpactMetric Metric(ConfigurationImpactReport report, string suffix) => report.Metrics.Single(m => m.LabelKey == "Impact.Notification" + suffix); + [Test] + public async Task Suppression_comparison_is_a_bounded_declared_window_and_never_a_historical_forecast() + { + var f = new Fixture(Member(1), Member(2, 2)); + var report = await f.Service.PreviewAsync(Actor, new("0", true, 7, 10)); + Assert.That(Metric(report, "RecipientsMinimum").Before, Is.EqualTo(2)); Assert.That(Metric(report, "RecipientsMinimum").After, Is.EqualTo(1)); + Assert.That(Metric(report, "Suppressed").After, Is.EqualTo(1)); + Assert.That(Metric(report, "VolumeMinimum").After, Is.Zero); Assert.That(Metric(report, "VolumeMaximum").Before, Is.EqualTo(60)); Assert.That(Metric(report, "VolumeMaximum").After, Is.EqualTo(30)); + Assert.That(Metric(report, "HistoricalSample").State, Is.EqualTo(EvidenceState.NotApplicable)); + Assert.That(report.LimitKeys, Does.Contain("Impact.NotificationChannelScope")); + f.Settings.Verify(s => s.GetAllByDepartmentIdAsync(7), Times.Exactly(2)); f.Settings.VerifyNoOtherCalls(); + Assert.That(Newtonsoft.Json.JsonConvert.SerializeObject(report), Does.Not.Contain("member-")); + } + [Test] + public async Task Unknown_profiles_and_foreign_staffing_widen_ranges_without_reading_foreign_state() + { + var noProfile = Member(1); noProfile.ProfileId = null; noProfile.Sms = noProfile.Email = noProfile.Push = null; + var f = new Fixture(noProfile, Member(2, null)); var report = await f.Service.PreviewAsync(Actor, new("0", true, 30, 1)); + Assert.That(Metric(report, "RecipientsMinimum").Before, Is.EqualTo(1)); Assert.That(Metric(report, "RecipientsMinimum").After, Is.Zero); + Assert.That(Metric(report, "RecipientsMaximum").After, Is.EqualTo(2)); Assert.That(Metric(report, "StaffingUnknown").After, Is.EqualTo(1)); + Assert.That(Metric(report, "MissingProfiles").After, Is.EqualTo(1)); Assert.That(Metric(report, "VolumeMaximum").After, Is.EqualTo(6)); + } + [TestCase(null, 1)][TestCase(true, 1)][TestCase(false, 0)] + public async Task Contact_verification_uses_the_production_tristate_gate(bool? verified, int allowed) + { + var member = Member(1); member.MobileVerified = member.EmailVerified = verified; member.Push = false; + var report = await new Fixture(member).Service.PreviewAsync(Actor, new("0", false, 1, 2)); + Assert.That(Metric(report, "SmsMinimum").After, Is.EqualTo(allowed)); Assert.That(Metric(report, "EmailMinimum").After, Is.EqualTo(allowed)); + Assert.That(Metric(report, "PushMaximum").After, Is.Zero); Assert.That(Metric(report, "NoChannels").After, Is.EqualTo(1 - allowed)); + } + [Test] + public async Task Missing_malformed_cross_tenant_and_duplicate_sources_never_become_zero_reachability() + { + var foreign = Member(1); foreign.DepartmentId = 8; + foreach (var members in new[] { new[] { foreign }, new[] { Member(1), Member(1) } }) + { + var report = await new Fixture(members).Service.PreviewAsync(Actor, new("0", true, 7, 1)); + Assert.That(Metric(report, "MemberSample").State, Is.EqualTo(EvidenceState.Unknown)); + Assert.That(report.Metrics.Any(m => m.LabelKey == "Impact.NotificationVolumeMaximum"), Is.False); + } + var f = new Fixture(Member(1)); f.Settings.Setup(s => s.GetAllByDepartmentIdAsync(7)).ReturnsAsync(new[] { new DepartmentSetting { DepartmentId = 7, SettingType = 27, Setting = null } }); + Assert.That(Metric(await f.Service.PreviewAsync(Actor, new("0", false, 7, 1)), "MemberSample").State, Is.EqualTo(EvidenceState.Unknown)); + } + [Test] + public void Drift_revocation_and_stale_revision_discard_the_result() + { + var f = new Fixture(Member(1)); f.Store.SetupSequence(s => s.ReadNotificationMembersAsync(7, It.IsAny(), It.IsAny())).ReturnsAsync(new[] { Member(1) }).ReturnsAsync(new[] { Member(1), Member(2) }); + Assert.ThrowsAsync(async () => await f.Service.PreviewAsync(Actor, new("0", true, 7, 1))); + f = new Fixture(Member(1)); f.Access.SetupSequence(a => a.CanAccessAsync(Actor, false, It.IsAny())).ReturnsAsync(true).ReturnsAsync(false); + Assert.ThrowsAsync(async () => await f.Service.PreviewAsync(Actor, new("0", true, 7, 1))); + f = new Fixture(Member(1)); Assert.ThrowsAsync(async () => await f.Service.PreviewAsync(Actor, new("99", true, 7, 1))); f.Store.VerifyNoOtherCalls(); + } + [TestCase(0, 1)][TestCase(31, 1)][TestCase(7, -1)][TestCase(7, 10001)] + public void Invalid_scenario_is_rejected_before_source_reads(int days, int events) + { + var f = new Fixture(Member(1)); Assert.ThrowsAsync(async () => await f.Service.PreviewAsync(Actor, new("0", true, days, events))); f.Store.VerifyNoOtherCalls(); f.Settings.VerifyNoOtherCalls(); + } + [Test] + public void Shared_gate_preserves_missing_profile_and_opt_out_behavior() + { + Assert.That(NotificationChannelSelection.From(null), Is.EqualTo(new NotificationChannelSelection(true, true, true))); + Assert.That(NotificationChannelSelection.From(new UserProfile()), Is.EqualTo(new NotificationChannelSelection(false, false, false))); + } + } +} diff --git a/Tests/Resgrid.Tests/AdminAssist/OperatingProfileEvidenceTests.cs b/Tests/Resgrid.Tests/AdminAssist/OperatingProfileEvidenceTests.cs new file mode 100644 index 000000000..bd6c6e1da --- /dev/null +++ b/Tests/Resgrid.Tests/AdminAssist/OperatingProfileEvidenceTests.cs @@ -0,0 +1,36 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Services; +using Resgrid.Services.AdminAssist; + +namespace Resgrid.Tests.AdminAssist +{ + [TestFixture] + public class OperatingProfileEvidenceTests + { + [Test] + public async Task Combined_packs_are_canonical_and_no_system_or_document_references_leave_adapter() + { + var settings = new Mock(); + settings.Setup(s => s.GetOperatingProfileAsync(7)).ReturnsAsync(new DepartmentOperatingProfile { + Archetypes = new() { "mental-health", "ems" }, AuthoritativeSystemReferences = new() { "private-system" }, StaffingPolicyReferences = new() { "123" }, Revision = 3, ReviewedOnUtc = DateTime.UtcNow + }); + var source = new OperatingProfileEvidenceSource(settings.Object); + var result = await source.ReadAsync(new(7, "admin"), DateTime.UtcNow, CancellationToken.None); + Assert.That(result.Single(e => e.Id == "operatingPackIds").Code, Is.EqualTo("ems,mental-health")); + Assert.That(string.Join(" ", result.Select(e => e.ToString())), Does.Not.Contain("private-system").And.Not.Contain("123")); + } + [Test] + public void Selected_area_without_rules_is_never_reported_as_covered() + { + var report = new ConfigurationReport(new(7, "admin", "1", DateTime.UtcNow, true, new System.Collections.Generic.Dictionary()), Array.Empty(), new[] { "business", "knowledge" }); + Assert.That(report.UncheckedAreaIds, Is.EquivalentTo(new[] { "business", "knowledge" })); + Assert.That(report.Verified, Is.Zero); + } + } +} diff --git a/Tests/Resgrid.Tests/AdminAssist/OperatingProfileTests.cs b/Tests/Resgrid.Tests/AdminAssist/OperatingProfileTests.cs new file mode 100644 index 000000000..3564324be --- /dev/null +++ b/Tests/Resgrid.Tests/AdminAssist/OperatingProfileTests.cs @@ -0,0 +1,99 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.AdminAssist +{ + [TestFixture] + public class OperatingProfileTests + { + private Mock _settings; + private Mock _metadata; + private Mock _authorization; + private Mock _unit; + private Mock _flags; + private DepartmentSettingsService _service; + private DepartmentSetting _saved; + [SetUp] + public void SetUp() + { + _saved = null; _settings = new(); _metadata = new(); _authorization = new(); _unit = new(); + _flags = new(); _flags.Setup(f => f.EvaluateFreshAsync(FeatureFlagKeys.AdminSetup, 7)).ReturnsAsync(new FeatureFlagEvaluation { IsEnabled = true }); + _settings.Setup(s => s.GetDepartmentSettingByIdTypeAsync(7, DepartmentSettingTypes.DepartmentOperatingProfile)).ReturnsAsync(() => _saved); + _settings.Setup(s => s.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((DepartmentSetting value, CancellationToken _, bool firstLevel) => _saved = value); + _metadata.Setup(r => r.ValidateOperatingProfileReferencesAsync(7, It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(true); + _authorization.Setup(a => a.IsActiveMemberAsync("admin", 7)).ReturnsAsync(true); + _authorization.Setup(a => a.IsDepartmentAdminAsync("admin", 7)).ReturnsAsync(true); + _service = new DepartmentSettingsService(_settings.Object, Mock.Of(), Mock.Of(), Mock.Of(), + moduleUnit: _unit.Object, moduleFlags: new Lazy(() => _flags.Object), operatingProfileRepository: _metadata.Object, operatingProfileAuthorization: new Lazy(() => _authorization.Object)); + } + [Test] + public async Task New_profile_advances_revision_without_mutating_proposal() + { + var proposal = new DepartmentOperatingProfile { Archetypes = new() { "ems", "mental-health" } }; + await _service.SetOperatingProfileAsync(7, proposal, "admin"); + var persisted = ObjectSerialization.Deserialize(_saved.Setting); + Assert.That(persisted.Revision, Is.EqualTo(1)); Assert.That(persisted.ReviewedOnUtc, Is.Not.Null); + Assert.That(persisted.Archetypes, Is.EquivalentTo(proposal.Archetypes)); + Assert.That(proposal.Revision, Is.Zero); Assert.That(proposal.ReviewedOnUtc, Is.Null); + _unit.Verify(u => u.CommitChanges(), Times.Once); + } + [Test] + public async Task Stale_profile_cannot_overwrite_another_administrators_save() + { + await _service.SetOperatingProfileAsync(7, new DepartmentOperatingProfile(), "admin"); + Assert.ThrowsAsync(() => _service.SetOperatingProfileAsync(7, new DepartmentOperatingProfile { WorkforceMix = "volunteer" }, "admin")); + Assert.That(ObjectSerialization.Deserialize(_saved.Setting).WorkforceMix, Is.EqualTo("unknown")); + _settings.Verify(s => s.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + _unit.Verify(u => u.DiscardChanges(), Times.Once); + } + [Test] + public void Invalid_references_cannot_be_saved() + { + _metadata.Setup(r => r.ValidateOperatingProfileReferencesAsync(7, It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(false); + Assert.ThrowsAsync(() => _service.SetOperatingProfileAsync(7, new DepartmentOperatingProfile { SiteGroupReferences = new() { "123" } }, "admin")); + Assert.That(_saved, Is.Null); _unit.Verify(u => u.DiscardChanges(), Times.Once); + } + [Test] + public void Revocation_during_validation_prevents_save() + { + _authorization.SetupSequence(a => a.IsDepartmentAdminAsync("admin", 7)).ReturnsAsync(true).ReturnsAsync(false); + Assert.ThrowsAsync(() => _service.SetOperatingProfileAsync(7, new DepartmentOperatingProfile(), "admin")); + Assert.That(_saved, Is.Null); _unit.Verify(u => u.DiscardChanges(), Times.Once); + } + [Test] + public void Disabled_rollout_prevents_profile_writes_before_opening_a_transaction() + { + _flags.Setup(f => f.EvaluateFreshAsync(FeatureFlagKeys.AdminSetup, 7)).ReturnsAsync(new FeatureFlagEvaluation { IsEnabled = false }); + Assert.ThrowsAsync(() => _service.SetOperatingProfileAsync(7, new DepartmentOperatingProfile(), "admin")); + Assert.That(_saved, Is.Null); _unit.VerifyNoOtherCalls(); _metadata.VerifyNoOtherCalls(); + } + [Test] + public void Rollout_revocation_during_validation_prevents_profile_save() + { + _flags.SetupSequence(f => f.EvaluateFreshAsync(FeatureFlagKeys.AdminSetup, 7)) + .ReturnsAsync(new FeatureFlagEvaluation { IsEnabled = true }).ReturnsAsync(new FeatureFlagEvaluation { IsEnabled = false }); + Assert.ThrowsAsync(() => _service.SetOperatingProfileAsync(7, new DepartmentOperatingProfile(), "admin")); + Assert.That(_saved, Is.Null); _unit.Verify(u => u.DiscardChanges(), Times.Once); + } + [Test] + public void Inactive_member_never_opens_transaction() + { + _authorization.Setup(a => a.IsActiveMemberAsync("admin", 7)).ReturnsAsync(false); + Assert.ThrowsAsync(() => _service.SetOperatingProfileAsync(7, new DepartmentOperatingProfile(), "admin")); + _unit.Verify(u => u.CreateOrGetConnectionAsync(It.IsAny()), Times.Never); + } + } +} diff --git a/Tests/Resgrid.Tests/AdminAssist/PermissionImpactTests.cs b/Tests/Resgrid.Tests/AdminAssist/PermissionImpactTests.cs new file mode 100644 index 000000000..d784a365e --- /dev/null +++ b/Tests/Resgrid.Tests/AdminAssist/PermissionImpactTests.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Resgrid.AdminAssist; +using Resgrid.Model; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; +using Resgrid.Services.AdminAssist; + +namespace Resgrid.Tests.AdminAssist +{ + [TestFixture] + public class PermissionImpactTests + { + private sealed class Fixture + { + public readonly Mock Access = new(); + public readonly Mock Store = new(); + public readonly Mock Departments = new(); + public readonly Mock Members = new(); + public readonly Mock Groups = new(); + public readonly Mock Roles = new(); + public readonly Mock Assignments = new(); + public readonly Mock Units = new(); + public readonly Mock Permissions = new(); + public readonly List People = new(); + public readonly List GroupRows = new(); + public PermissionImpactService Service => new(Access.Object, Store.Object, new ConfigurationCatalog(), Departments.Object, Members.Object, + Groups.Object, Roles.Object, Assignments.Object, Units.Object, Permissions.Object, + new PermissionsService(Permissions.Object, Mock.Of(), Mock.Of()), TimeProvider.System); + public PermissionImpactRequest Request => new("0", "ViewGroupUsers", 1, true, Array.Empty()); + public Fixture() + { + Access.Setup(a => a.CanAccessAsync(It.IsAny(), false, It.IsAny())).ReturnsAsync(true); + Departments.Setup(d => d.GetDepartmentByIdAsync(7, true)).ReturnsAsync(new Department { DepartmentId = 7, ManagingUserId = "owner" }); + foreach (var id in new[] { "owner", "supervisor", "member", "ungrouped", "disabled" }) People.Add(new DepartmentMember { DepartmentId = 7, UserId = id, IsDisabled = id == "disabled", IsHidden = id == "supervisor" }); + Members.Setup(m => m.GetAllDepartmentMembersUnlimitedAsync(7)).ReturnsAsync(People); + GroupRows.Add(new DepartmentGroup { DepartmentId = 7, DepartmentGroupId = 10, Members = new List { new() { DepartmentId = 7, DepartmentGroupId = 10, UserId = "supervisor", IsAdmin = true } } }); + GroupRows.Add(new DepartmentGroup { DepartmentId = 7, DepartmentGroupId = 11, ParentDepartmentGroupId = 10, Members = new List { new() { DepartmentId = 7, DepartmentGroupId = 11, UserId = "member" } } }); + Groups.Setup(g => g.GetAllGroupsByDepartmentIdAsync(7)).ReturnsAsync(GroupRows); + Roles.Setup(r => r.GetPersonnelRolesByDepartmentIdAsync(7)).ReturnsAsync(new[] { new PersonnelRole { DepartmentId = 7, PersonnelRoleId = 20 } }); + Assignments.Setup(r => r.GetAllRoleUsersForDepartmentAsync(7)).ReturnsAsync(new[] { new PersonnelRoleUser { PersonnelRoleId = 20, UserId = "member" } }); + Units.Setup(u => u.GetAllUnitsByDepartmentIdAsync(7)).ReturnsAsync(new[] { new Unit { DepartmentId = 7, UnitId = 1, StationGroupId = 11 }, new Unit { DepartmentId = 7, UnitId = 2 } }); + Permissions.Setup(p => p.GetAllByDepartmentIdAsync(7)).ReturnsAsync(Array.Empty()); + } + } + [Test] + public async Task Role_picker_returns_department_role_names_without_loading_people_or_assignments() + { + var f = new Fixture(); f.Roles.Setup(r => r.GetPersonnelRolesByDepartmentIdAsync(7)).ReturnsAsync(new[] { + new PersonnelRole { DepartmentId = 7, PersonnelRoleId = 21, Name = "Dispatcher" }, new PersonnelRole { DepartmentId = 7, PersonnelRoleId = 20, Name = "Crew" } }); + var result = await f.Service.GetRoleOptionsAsync(new(7, "owner"), "0"); + Assert.That(result.Select(r => r.Name), Is.EqualTo(new[] { "Crew", "Dispatcher" })); + f.Members.VerifyNoOtherCalls(); f.Assignments.VerifyNoOtherCalls(); f.Units.VerifyNoOtherCalls(); + } + [Test] + public void Role_picker_rejects_cross_tenant_sources_and_changed_or_revoked_access() + { + var f = new Fixture(); f.Roles.Setup(r => r.GetPersonnelRolesByDepartmentIdAsync(7)).ReturnsAsync(new[] { new PersonnelRole { DepartmentId = 8, PersonnelRoleId = 20, Name = "Foreign" } }); + Assert.ThrowsAsync(async () => await f.Service.GetRoleOptionsAsync(new(7, "owner"), "0")); + f = new Fixture(); f.Roles.SetupSequence(r => r.GetPersonnelRolesByDepartmentIdAsync(7)).ReturnsAsync(new[] { new PersonnelRole { DepartmentId = 7, PersonnelRoleId = 20, Name = "Crew" } }).ReturnsAsync(Array.Empty()); + Assert.ThrowsAsync(async () => await f.Service.GetRoleOptionsAsync(new(7, "owner"), "0")); + f = new Fixture(); f.Roles.Setup(r => r.GetPersonnelRolesByDepartmentIdAsync(7)).ReturnsAsync(Array.Empty()); + f.Access.SetupSequence(a => a.CanAccessAsync(It.IsAny(), false, It.IsAny())).ReturnsAsync(true).ReturnsAsync(false); + Assert.ThrowsAsync(async () => await f.Service.GetRoleOptionsAsync(new(7, "owner"), "0")); + } + private static ConfigurationImpactMetric Metric(ConfigurationImpactReport report, string name) => report.Metrics.Single(m => m.LabelKey == "Impact." + name); + [Test] + public async Task Hierarchical_resource_scope_counts_actors_and_pairs_including_hidden_current_members() + { + var f = new Fixture(); var report = await f.Service.PreviewAsync(new(7, "owner"), f.Request); + Assert.That(Metric(report, "PermissionSample").After, Is.EqualTo(4)); + Assert.That(Metric(report, "PermissionActors").Before, Is.EqualTo(4)); + Assert.That(Metric(report, "PermissionActors").After, Is.EqualTo(2)); + Assert.That(Metric(report, "PermissionEdges").Before, Is.EqualTo(16)); + Assert.That(Metric(report, "PermissionEdges").After, Is.EqualTo(6)); // owner: all four; area supervisor: own + child station + Assert.That(Metric(report, "PermissionLost").After, Is.EqualTo(10)); + Assert.That(Newtonsoft.Json.JsonConvert.SerializeObject(report), Does.Not.Contain("supervisor")); + } + [Test] + public async Task Unit_scope_does_not_treat_two_missing_groups_as_the_same_group() + { + var f = new Fixture(); var report = await f.Service.PreviewAsync(new(7, "owner"), f.Request with { PermissionType = "CanSeeUnitLocations", Action = 3 }); + Assert.That(Metric(report, "PermissionEdges").Before, Is.EqualTo(8)); + Assert.That(Metric(report, "PermissionEdges").After, Is.EqualTo(3)); // owner sees both; member sees own station + Assert.That(Metric(report, "PermissionActors").After, Is.EqualTo(2)); + } + [Test] + public async Task Department_action_checks_each_actors_actual_roles_through_the_permission_service() + { + var f = new Fixture(); var report = await f.Service.PreviewAsync(new(7, "owner"), f.Request with { PermissionType = "CreateCall", Action = 2, LockToGroup = false, RoleIds = new[] { 20 } }); + Assert.That(Metric(report, "PermissionActors").Before, Is.EqualTo(4)); + Assert.That(Metric(report, "PermissionActors").After, Is.EqualTo(2)); + Assert.That(Metric(report, "PermissionTargets").After, Is.EqualTo(1)); + } + [Test] + public async Task Missing_or_ambiguous_sources_are_unknown_instead_of_reducing_the_sample() + { + var f = new Fixture(); f.GroupRows[1].Members.Add(new() { DepartmentId = 7, DepartmentGroupId = 11, UserId = "supervisor" }); + var report = await f.Service.PreviewAsync(new(7, "owner"), f.Request); + Assert.That(report.Metrics.Single().State, Is.EqualTo(EvidenceState.Unknown)); Assert.That(report.Metrics.Single().After, Is.Null); + f = new Fixture(); f.Roles.Setup(r => r.GetPersonnelRolesByDepartmentIdAsync(7)).ReturnsAsync((IEnumerable)null); + report = await f.Service.PreviewAsync(new(7, "owner"), f.Request); + Assert.That(report.Metrics.Single().State, Is.EqualTo(EvidenceState.Unknown)); + } + [Test] + public void Revoked_admin_and_cross_tenant_role_proposals_are_rejected() + { + var f = new Fixture(); + Assert.ThrowsAsync(async () => await f.Service.PreviewAsync(new(7, "owner"), f.Request with { Action = 2, RoleIds = new[] { 99 } })); + f.Access.SetupSequence(a => a.CanAccessAsync(It.IsAny(), false, It.IsAny())).ReturnsAsync(true).ReturnsAsync(false); + Assert.ThrowsAsync(async () => await f.Service.PreviewAsync(new(7, "owner"), f.Request)); + } + [Test] + public void Changed_membership_invalidates_preview_even_without_a_journal_revision() + { + var f = new Fixture(); + f.Members.SetupSequence(m => m.GetAllDepartmentMembersUnlimitedAsync(7)).ReturnsAsync(f.People).ReturnsAsync(f.People.Take(3)); + Assert.ThrowsAsync(async () => await f.Service.PreviewAsync(new(7, "owner"), f.Request)); + } + [Test] + public async Task Cyclic_group_hierarchy_produces_unknown_and_does_not_hang() + { + var f = new Fixture(); f.GroupRows[0].ParentDepartmentGroupId = 11; + var report = await f.Service.PreviewAsync(new(7, "owner"), f.Request); + Assert.That(report.Metrics.Single().State, Is.EqualTo(EvidenceState.Unknown)); + } + [TestCase(-1)] [TestCase(4)] [TestCase(90)] + public void Unsupported_actions_are_rejected(int action) + { + var f = new Fixture(); Assert.ThrowsAsync(async () => await f.Service.PreviewAsync(new(7, "owner"), f.Request with { Action = action })); + } + } +} diff --git a/Tests/Resgrid.Tests/AdminAssist/RetentionImpactTests.cs b/Tests/Resgrid.Tests/AdminAssist/RetentionImpactTests.cs new file mode 100644 index 000000000..49672b109 --- /dev/null +++ b/Tests/Resgrid.Tests/AdminAssist/RetentionImpactTests.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Resgrid.AdminAssist; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services.AdminAssist; + +namespace Resgrid.Tests.AdminAssist +{ + [TestFixture] + public class RetentionImpactTests + { + private sealed class Clock : TimeProvider { public override DateTimeOffset GetUtcNow() => new(2026, 9, 24, 12, 0, 0, TimeSpan.Zero); } + private sealed class Fixture + { + public readonly Mock Access = new(); + public readonly Mock Repository = new(); + public readonly Mock Settings = new(); + public readonly Mock Store = new(); + public readonly Mock Authorization = new(); + public readonly Mock Cutover = new(); + public RetentionImpactService Service => new(Access.Object, Repository.Object, new ConfigurationCatalog(), Settings.Object, Store.Object, new Clock(), Authorization.Object, Cutover.Object); + public Fixture() + { + Access.Setup(a => a.CanAccessAsync(It.IsAny(), false, It.IsAny())).ReturnsAsync(true); + Authorization.Setup(a => a.HasPermissionAsync("admin", 7, PermissionTypes.ViewRestrictedRecords)).ReturnsAsync(true); + Authorization.Setup(a => a.CanUserViewRecordAsync("admin", It.IsAny(), 7)).ReturnsAsync(true); + Cutover.Setup(c => c.GetModuleStateAsync(7, true)).ReturnsAsync(new RecordsModuleState { FlagEnabled = true }); + Settings.Setup(s => s.GetAllByDepartmentIdAsync(7)).ReturnsAsync(Array.Empty()); + Rows(Row()); + } + public void Rows(params RetentionImpactHeader[] rows) => Store.Setup(s => s.ReadRetentionHeadersAsync(7, It.IsAny(), It.IsAny())).ReturnsAsync(rows); + public static RetentionImpactHeader Row(string id = "record") => new() { RecordId = id, Kind = (int)RmsRecordKind.Operational, + DefinitionKey = RmsDefinitionKeys.Training, State = (int)RmsRecordState.Finalized, FinalizedOn = new DateTime(2010, 1, 1), ModifiedOn = new DateTime(2010, 1, 1), RowVersion = 1 }; + } + [Test] + public async Task Prospective_policy_keeps_historical_retention_and_never_claims_actual_purge_eligibility() + { + var f = new Fixture(); + var report = await f.Service.PreviewAsync(new(7, "admin"), new("0", 0)); + var expired = report.Metrics.Single(m => m.LabelKey == "Impact.RetentionExpired"); + Assert.That(expired.Before, Is.EqualTo(1)); Assert.That(expired.After, Is.EqualTo(1)); + Assert.That(report.Metrics.Single(m => m.LabelKey == "Impact.RetentionDefault").After, Is.Zero); + Assert.That(report.Metrics.Single(m => m.LabelKey == "Impact.RetentionActualEligibility").State, Is.EqualTo(EvidenceState.Unknown)); + Assert.That(report.LimitKeys, Does.Contain("Impact.RetentionProtection")); + f.Settings.Verify(s => s.GetAllByDepartmentIdAsync(7), Times.Exactly(2)); f.Settings.VerifyNoOtherCalls(); + } + [Test] + public async Task Known_holds_and_uncertain_historical_holds_are_excluded_from_remaining_candidates() + { + var f = new Fixture(); f.Rows(Fixture.Row("held") with { HoldOrPermanentContent = 1 }, Fixture.Row("history") with { HistoricalHoldUncertainty = 1 }, Fixture.Row("clear")); + var report = await f.Service.PreviewAsync(new(7, "admin"), new("0", 1)); + foreach (var metric in new[] { "RetentionHeld", "RetentionHoldUnknown", "RetentionCandidates" }) Assert.That(report.Metrics.Single(m => m.LabelKey == "Impact." + metric).After, Is.EqualTo(1)); + } + [Test] + public async Task Open_amending_recent_restricted_and_unexpired_records_do_not_become_candidates() + { + var f = new Fixture(); f.Rows(Fixture.Row("draft") with { State = (int)RmsRecordState.Draft }, Fixture.Row("amending") with { AmendsRevisionId = "revision" }, + Fixture.Row("recent") with { ModifiedOn = new Clock().GetUtcNow().UtcDateTime }, Fixture.Row("unexpired") with { FinalizedOn = new DateTime(2025, 1, 1) }, + Fixture.Row("restricted") with { DefinitionKey = RmsDefinitionKeys.RestrictedClass.First() }); + var report = await f.Service.PreviewAsync(new(7, "admin"), new("0", 1)); + Assert.That(report.Metrics.Single(m => m.LabelKey == "Impact.RetentionCandidates").After, Is.Zero); + } + [Test] + public async Task Unavailable_metadata_returns_unknown_and_never_reads_record_content() + { + var f = new Fixture(); f.Store.Setup(s => s.ReadRetentionHeadersAsync(7, It.IsAny(), It.IsAny())).ThrowsAsync(new InvalidOperationException()); + var report = await f.Service.PreviewAsync(new(7, "admin"), new("0", null)); + Assert.That(report.Metrics.Single().State, Is.EqualTo(EvidenceState.Unknown)); + } + [Test] + public void Source_drift_restricted_access_or_disabled_module_cannot_return_counts() + { + var f = new Fixture(); f.Store.SetupSequence(s => s.ReadRetentionHeadersAsync(7, It.IsAny(), It.IsAny())).ReturnsAsync(new[] { Fixture.Row() }).ReturnsAsync(new[] { Fixture.Row() with { RowVersion = 2 } }); + Assert.ThrowsAsync(async () => await f.Service.PreviewAsync(new(7, "admin"), new("0", 1))); + f = new Fixture(); f.Authorization.Setup(a => a.CanUserViewRecordAsync("admin", "record", 7)).ReturnsAsync(false); + Assert.ThrowsAsync(async () => await f.Service.PreviewAsync(new(7, "admin"), new("0", 1))); + f = new Fixture(); f.Cutover.Setup(c => c.GetModuleStateAsync(7, true)).ReturnsAsync(new RecordsModuleState { FlagEnabled = false }); + Assert.ThrowsAsync(async () => await f.Service.PreviewAsync(new(7, "admin"), new("0", 1))); f.Store.VerifyNoOtherCalls(); + } + [Test] + public void Retention_boundaries_handle_permanent_overflow_missing_and_leap_day_dates() + { + var now = new DateTime(2025, 2, 28); + Assert.That(RecordsRetentionWindow.HasExpired(new DateTime(2024, 2, 29), 1, now), Is.True); + Assert.That(RecordsRetentionWindow.HasExpired(new DateTime(2024, 2, 29), 1, now.AddTicks(-1)), Is.False); + Assert.That(RecordsRetentionWindow.HasExpired(now, 0, now), Is.False); + Assert.That(RecordsRetentionWindow.HasExpired(null, 1, now), Is.False); + Assert.That(RecordsRetentionWindow.HasExpired(new DateTime(9999, 1, 1), int.MaxValue, now), Is.False); + } + } +} diff --git a/Tests/Resgrid.Tests/AdminAssist/ScopeSafetyTests.cs b/Tests/Resgrid.Tests/AdminAssist/ScopeSafetyTests.cs new file mode 100644 index 000000000..131c142dd --- /dev/null +++ b/Tests/Resgrid.Tests/AdminAssist/ScopeSafetyTests.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Resgrid.AdminAssist; +using Resgrid.Model.AdminAssist; +using Resgrid.Services.AdminAssist; + +namespace Resgrid.Tests.AdminAssist +{ + [TestFixture] + public class ScopeSafetyTests + { + private static readonly ConfigurationCatalog Catalog = new(); + private readonly DateTime _now = DateTime.UtcNow; + private ConfigurationSnapshot Snapshot(params ConfigurationEvidence[] facts) => new(7, "admin", "1", _now, true, facts.ToDictionary(f => f.Id)); + [Test] + public void Deferred_area_cannot_hide_a_verified_critical_failure_or_unknown_check_for_enabled_routing() + { + var rule = new ConfigurationRule(Catalog.Rules.Single(r => r.Id == "text-sources")); + var snapshot = Snapshot(new("EnableTextToCall", EvidenceState.Known, "test", "1", _now, Boolean: true), new("textSourcePresent", EvidenceState.Known, "test", "1", _now, Boolean: false)); + var failure = rule.Evaluate(snapshot, _now, TimeSpan.FromMinutes(1)); + var report = new ConfigurationReport(snapshot, new[] { failure }, new[] { "security" }); + Assert.That(report.Failed, Is.EqualTo(1)); Assert.That(failure.ScopeIndependent, Is.True); + var unknown = rule.Evaluate(Snapshot(new ConfigurationEvidence("EnableTextToCall", EvidenceState.Known, "test", "1", _now, Boolean: true)), _now, TimeSpan.FromMinutes(1)); + Assert.That(new ConfigurationReport(snapshot, new[] { unknown }, new[] { "security" }).HasCriticalUncertainty, Is.True); + } + [Test] + public void Known_unavailable_optional_maintenance_does_not_lower_selected_area_readiness() + { + var snapshot = Snapshot(new ConfigurationEvidence("maintenanceAvailable", EvidenceState.Known, "test", "1", _now, Boolean: false)); + var finding = new ConfigurationRule(Catalog.Rules.Single(r => r.Id == "equipment-holds")).Evaluate(snapshot, _now, TimeSpan.FromMinutes(1)); + Assert.That(finding.Result, Is.EqualTo(RuleResult.NotApplicable)); + var report = new ConfigurationReport(snapshot, new[] { finding }, new[] { "maintenance" }); + Assert.That(report.Required, Is.Zero); Assert.That(report.HasCriticalUncertainty, Is.False); + } + [TestCase(EvidenceState.Unavailable, "AddonRequired.ReadinessPro", EvidenceState.Known)] + [TestCase(EvidenceState.Unknown, "SubscriptionStatusUnavailable", EvidenceState.Unknown)] + [TestCase(EvidenceState.Unavailable, "SourceAccessUnavailable", EvidenceState.Unknown)] + public async Task Availability_failure_cannot_be_misreported_as_no_entitlement(EvidenceState source, string reason, EvidenceState expected) + { + var capability = Catalog.Capabilities.First(c => c.Location.Controller == "WorkOrders"); var access = new Mock(); + access.Setup(a => a.GetCapabilityAsync(It.IsAny(), capability.Id, It.IsAny())).ReturnsAsync(new CapabilityAccess(capability.Id, source, new[] { reason }, false, null, _now)); + var evidence = (await new CapabilityEvidenceSource(access.Object, Catalog).ReadAsync(new(7, "admin"), _now, CancellationToken.None)).Single(); + Assert.That(evidence.State, Is.EqualTo(expected)); Assert.That(evidence.Boolean, Is.EqualTo(expected == EvidenceState.Known ? false : (bool?)null)); + } + } +} diff --git a/Tests/Resgrid.Tests/AdminAssist/SecurityImpactTests.cs b/Tests/Resgrid.Tests/AdminAssist/SecurityImpactTests.cs new file mode 100644 index 000000000..d65bdebb4 --- /dev/null +++ b/Tests/Resgrid.Tests/AdminAssist/SecurityImpactTests.cs @@ -0,0 +1,118 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Resgrid.AdminAssist; +using Resgrid.Model; +using Resgrid.Model.AdminAssist; +using Resgrid.Services.AdminAssist; + +namespace Resgrid.Tests.AdminAssist +{ + [TestFixture, NonParallelizable] + public class SecurityImpactTests + { + private static readonly DateTime Now = new(2026, 9, 24, 12, 0, 0, DateTimeKind.Utc); + private static readonly AdminAssistActor Actor = new(7, "admin"); + private string _gate; + [SetUp] public void SetUp() { _gate = Resgrid.Config.SessionSecurityConfig.DepartmentSessionPolicyEnforcementAfterUtc; Resgrid.Config.SessionSecurityConfig.DepartmentSessionPolicyEnforcementAfterUtc = "2026-09-01T00:00:00Z"; } + [TearDown] public void TearDown() => Resgrid.Config.SessionSecurityConfig.DepartmentSessionPolicyEnforcementAfterUtc = _gate; + private sealed class Clock : TimeProvider { public override DateTimeOffset GetUtcNow() => new(Now); } + private sealed class Fixture + { + public readonly Mock Access = new(); + public readonly Mock Repository = new(); + public readonly Mock Store = new(); + public SecurityImpactService Service => new(Access.Object, Repository.Object, new ConfigurationCatalog(), Store.Object, new Clock()); + public Fixture(int enabledProviders = 0) + { + Access.Setup(a => a.CanAccessAsync(Actor, false, It.IsAny())).ReturnsAsync(true); + Store.Setup(s => s.ReadSecurityImpactAsync(7, Now, It.IsAny(), It.IsAny())).ReturnsAsync(new SecurityImpactEvidence(new[] { + new SecurityMemberEvidence { DepartmentId = 7, MemberId = 1, UserId = "one", TwoFactorEnabled = false, PasswordLastSetOn = Now.AddDays(-31) }, + new SecurityMemberEvidence { DepartmentId = 7, MemberId = 2, UserId = "two", TwoFactorEnabled = true, PasswordLastSetOn = Now.AddDays(-30) }, + new SecurityMemberEvidence { DepartmentId = 7, MemberId = 3, UserId = "three", TwoFactorEnabled = null } }, + new[] { Session("a", Now.AddHours(-2), Now.AddMinutes(-31)), Session("b", Now.AddHours(-2), Now.AddMinutes(-30)), + Session("old", Now.AddMonths(-1), Now.AddMinutes(-40)) }, enabledProviders)); + } + private static SecuritySessionEvidence Session(string id, DateTime created, DateTime active) => new() { DepartmentId = 7, Id = id, UserId = "one", + CreatedOn = created, LastActiveOn = active, ExpiresOn = Now.AddHours(1), AuthenticationGeneration = 1, CurrentGeneration = 1 }; + public Task Preview(string field, bool? boolean = null, decimal? number = null) => Service.PreviewAsync(Actor, new("table.DepartmentSecurityPolicy." + field, "0", boolean, number)); + } + private static ConfigurationImpactMetric Metric(ConfigurationImpactReport report, string suffix) => report.Metrics.Single(m => m.LabelKey == "Impact.Security" + suffix); + [Test] + public async Task Mfa_proposal_reports_enrollment_range_and_does_not_claim_factor_or_recovery_readiness() + { + var f = new Fixture(); var report = await f.Preview("RequireMfa", true); + Assert.That(Metric(report, "MfaEnrollmentMinimum").Before, Is.Zero); Assert.That(Metric(report, "MfaEnrollmentMinimum").After, Is.EqualTo(1)); + Assert.That(Metric(report, "MfaEnrollmentMaximum").After, Is.EqualTo(2)); Assert.That(Metric(report, "MfaCompletionRequired").After, Is.EqualTo(3)); + Assert.That(Metric(report, "RecoveryReadiness").State, Is.EqualTo(EvidenceState.Unknown)); + f.Store.Verify(s => s.ReadSecurityPolicyAsync(7, It.IsAny()), Times.Exactly(2)); + f.Store.Verify(s => s.ReadSecurityImpactAsync(7, Now, It.IsAny(), It.IsAny()), Times.Exactly(2)); f.Store.VerifyNoOtherCalls(); + } + [TestCase(0, 0, 1)][TestCase(1, 3, 0)] + public async Task Sso_matches_the_active_provider_safety_valve_without_claiming_connectivity(int providers, int blocked, int valve) + { + var report = await new Fixture(providers).Preview("RequireSso", true); + Assert.That(Metric(report, "PasswordPathBlocked").After, Is.EqualTo(blocked)); Assert.That(Metric(report, "SsoSafetyValve").After, Is.EqualTo(valve)); + Assert.That(Metric(report, "ProviderAndRecovery").After, Is.Null); + } + [Test] + public async Task Password_age_uses_strict_day_boundary_and_grandfathers_untracked_dates() + { + var report = await new Fixture().Preview("PasswordExpirationDays", number: 30); + Assert.That(Metric(report, "ExpiredPasswordAge").Before, Is.Zero); Assert.That(Metric(report, "ExpiredPasswordAge").After, Is.EqualTo(1)); + Assert.That(Metric(report, "UntrackedPasswordAge").After, Is.EqualTo(1)); + } + [Test] + public async Task Length_preview_never_infers_plaintext_length_from_stored_passwords() + { + var report = await new Fixture().Preview("MinPasswordLength", number: 12); + Assert.That(Metric(report, "MinimumLength").Before, Is.EqualTo(8)); Assert.That(Metric(report, "MinimumLength").After, Is.EqualTo(12)); + Assert.That(Metric(report, "ExistingPasswordCompliance").State, Is.EqualTo(EvidenceState.Unknown)); + } + [Test] + public async Task Idle_counts_include_equal_boundary_but_exclude_pre_rollout_sessions() + { + var report = await new Fixture().Preview("SessionTimeoutMinutes", number: 30); + Assert.That(Metric(report, "ManagedSessions").After, Is.EqualTo(2)); Assert.That(Metric(report, "IdleExpiryCandidates").After, Is.EqualTo(2)); + Assert.That(Metric(report, "ActualReauthentication").State, Is.EqualTo(EvidenceState.Unknown)); + } + [TestCase("")][TestCase("invalid")][TestCase("2027-01-01T00:00:00Z")] + public async Task Inactive_host_policy_gate_does_not_claim_session_enforcement(string gate) + { + Resgrid.Config.SessionSecurityConfig.DepartmentSessionPolicyEnforcementAfterUtc = gate; + var report = await new Fixture().Preview("MaxConcurrentSessions", number: 1); + Assert.That(Metric(report, "AtSessionLimit").After, Is.Zero); Assert.That(Metric(report, "SessionGateActive").After, Is.Zero); + } + [Test] + public async Task Concurrency_limit_estimates_next_insert_gate_without_revoking_existing_sessions() + { + var report = await new Fixture().Preview("MaxConcurrentSessions", number: 2); + Assert.That(Metric(report, "AtSessionLimit").Before, Is.Zero); Assert.That(Metric(report, "AtSessionLimit").After, Is.EqualTo(1)); + Assert.That(report.LimitKeys, Does.Contain("Impact.SecurityMaxConcurrentSessionsScope")); + } + [Test] + public async Task Source_failure_or_foreign_policy_is_unknown_not_an_empty_department() + { + var f = new Fixture(); f.Store.Setup(s => s.ReadSecurityPolicyAsync(7, It.IsAny())).ReturnsAsync(new DepartmentSecurityPolicy { DepartmentId = 8 }); + Assert.That((await f.Preview("RequireSso", true)).Metrics.Single().State, Is.EqualTo(EvidenceState.Unknown)); + f = new Fixture(); f.Store.Setup(s => s.ReadSecurityImpactAsync(7, Now, It.IsAny(), It.IsAny())).ThrowsAsync(new InvalidOperationException()); + Assert.That((await f.Preview("RequireSso", true)).Metrics.Single().State, Is.EqualTo(EvidenceState.Unknown)); + } + [Test] + public void Revocation_and_policy_drift_reject_completed_calculations() + { + var f = new Fixture(); f.Access.SetupSequence(a => a.CanAccessAsync(Actor, false, It.IsAny())).ReturnsAsync(true).ReturnsAsync(false); + Assert.ThrowsAsync(async () => await f.Preview("RequireMfa", true)); + f = new Fixture(); f.Store.SetupSequence(s => s.ReadSecurityPolicyAsync(7, It.IsAny())).ReturnsAsync(new DepartmentSecurityPolicy { DepartmentId = 7 }).ReturnsAsync(new DepartmentSecurityPolicy { DepartmentId = 7, RequireMfa = true }); + Assert.ThrowsAsync(async () => await f.Preview("RequireMfa", true)); + } + [TestCase("MinPasswordLength", 7)][TestCase("MaxConcurrentSessions", -1)][TestCase("SessionTimeoutMinutes", 0.5)][TestCase("PasswordExpirationDays", 36501)][TestCase("AllowedIpRanges", 1)] + public void Unreviewed_fields_and_invalid_values_are_rejected_before_metadata_reads(string field, decimal number) + { + var f = new Fixture(); Assert.ThrowsAsync(async () => await f.Preview(field, number: number)); f.Store.VerifyNoOtherCalls(); + } + } +} diff --git a/Tests/Resgrid.Tests/AdminAssist/SettingsCacheTests.cs b/Tests/Resgrid.Tests/AdminAssist/SettingsCacheTests.cs new file mode 100644 index 000000000..0222c83ad --- /dev/null +++ b/Tests/Resgrid.Tests/AdminAssist/SettingsCacheTests.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.AdminAssist +{ + [TestFixture, NonParallelizable] + public class SettingsCacheTests + { + [Test] + public async Task Shared_save_and_delete_invalidate_the_records_cached_getter() + { + var previous = Resgrid.Config.SystemBehaviorConfig.CacheEnabled; + Resgrid.Config.SystemBehaviorConfig.CacheEnabled = true; + try + { + DepartmentSetting persisted = new() { DepartmentId = 7, SettingType = (int)DepartmentSettingTypes.RecordsReviewDueHours, Setting = "72" }; + var repository = new Mock(); + repository.Setup(r => r.GetDepartmentSettingByIdTypeAsync(7, DepartmentSettingTypes.RecordsReviewDueHours)).ReturnsAsync(() => persisted); + repository.Setup(r => r.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync((DepartmentSetting row, CancellationToken _, bool firstLevelOnly) => persisted = row); + repository.Setup(r => r.DeleteAsync(It.IsAny(), It.IsAny())).ReturnsAsync(() => { persisted = null; return true; }); + var values = new Dictionary(); var cache = new Mock(); + cache.Setup(c => c.RetrieveAsync(It.IsAny(), It.IsAny>>(), It.IsAny())) + .Returns(async (string key, Func> read, TimeSpan _) => values.TryGetValue(key, out var value) ? value : values[key] = await read()); + cache.Setup(c => c.RemoveAsync(It.IsAny())).ReturnsAsync((string key) => values.Remove(key)); + var service = new DepartmentSettingsService(repository.Object, Mock.Of(), Mock.Of(), cache.Object); + Assert.That(await service.GetRecordsReviewDueHoursAsync(7), Is.EqualTo(72)); + await service.SaveOrUpdateSettingAsync(7, "24", DepartmentSettingTypes.RecordsReviewDueHours); + Assert.That(await service.GetRecordsReviewDueHoursAsync(7), Is.EqualTo(24)); + await service.DeleteSettingAsync(7, DepartmentSettingTypes.RecordsReviewDueHours); + Assert.That(await service.GetRecordsReviewDueHoursAsync(7), Is.EqualTo(DepartmentSettingsService.DefaultRecordsReviewDueHours)); + } + finally { Resgrid.Config.SystemBehaviorConfig.CacheEnabled = previous; } + } + } +} diff --git a/Tests/Resgrid.Tests/AdminAssist/SetupReturnTests.cs b/Tests/Resgrid.Tests/AdminAssist/SetupReturnTests.cs new file mode 100644 index 000000000..1fe030e05 --- /dev/null +++ b/Tests/Resgrid.Tests/AdminAssist/SetupReturnTests.cs @@ -0,0 +1,29 @@ +using System; +using Microsoft.AspNetCore.DataProtection; +using NUnit.Framework; +using Resgrid.Model.AdminAssist; +using Resgrid.Web.Helpers; + +namespace Resgrid.Tests.AdminAssist +{ + [TestFixture] + public class SetupReturnTests + { + private readonly IDataProtectionProvider _protection = new EphemeralDataProtectionProvider(); + private readonly AdminAssistActor _actor = new(7, "admin"); + private readonly DateTimeOffset _now = new(2026,9,24,12,0,0,TimeSpan.Zero); + [TestCase("wizard")] [TestCase("report")] + public void Return_context_is_actor_and_department_bound_and_expires(string page) + { + var token = AdminAssistReturnLink.Create(_protection,_actor,page,_now); + Assert.That(AdminAssistReturnLink.Read(_protection,_actor,token,_now.AddMinutes(10)), Is.EqualTo(page)); + Assert.That(AdminAssistReturnLink.Read(_protection,new(8,"admin"),token,_now), Is.Null); + Assert.That(AdminAssistReturnLink.Read(_protection,new(7,"other"),token,_now), Is.Null); + Assert.That(AdminAssistReturnLink.Read(_protection,_actor,token,_now.AddMinutes(30)), Is.Null); + Assert.That(AdminAssistReturnLink.Read(_protection,_actor,"tampered"+token,_now), Is.Null); + } + [TestCase("https://example.com")] [TestCase("//example.com")] [TestCase("../Settings")] + public void Arbitrary_return_destinations_are_rejected(string destination) => + Assert.Throws(() => AdminAssistReturnLink.Create(_protection,_actor,destination,_now)); + } +} diff --git a/Tests/Resgrid.Tests/AdminAssist/SetupWorkspaceTests.cs b/Tests/Resgrid.Tests/AdminAssist/SetupWorkspaceTests.cs new file mode 100644 index 000000000..6fad27eaf --- /dev/null +++ b/Tests/Resgrid.Tests/AdminAssist/SetupWorkspaceTests.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Resgrid.AdminAssist; +using Resgrid.Model.AdminAssist; +using Resgrid.Services.AdminAssist; + +namespace Resgrid.Tests.AdminAssist +{ + [TestFixture] + public class SetupWorkspaceTests + { + private readonly DateTime _now = new(2026,9,24,12,0,0,DateTimeKind.Utc); + private ConfigurationCatalog _catalog; + private Mock _repository; + private Mock _snapshots; + private AdminAssistService _service; + private SetupProgressCommand _saved; + private static readonly AdminAssistActor Actor = new(7,"admin"); + [SetUp] + public void SetUp() + { + _catalog = new(); _repository = new(); _snapshots = new(); _saved = null; + var access = new Mock(); + access.Setup(a => a.CanAccessAsync(Actor,true,It.IsAny())).ReturnsAsync(true); + access.Setup(a => a.GetCapabilitiesAsync(Actor,It.IsAny())).ReturnsAsync(Array.Empty()); + var workspace = new SetupWorkspace(7,0,SetupMode.Fresh,new Dictionary{{"security",SetupAreaChoice.UseNow}},Array.Empty(),Array.Empty(),_catalog.Version,null); + _repository.Setup(r => r.GetWorkspaceAsync(7,"admin",_catalog.Version,It.IsAny())).ReturnsAsync(workspace); + _repository.Setup(r => r.UpdateWorkspaceAsync(Actor,It.IsAny(),It.IsAny())) + .Callback((actor,command,token) => _saved=command).ReturnsAsync(workspace); + _snapshots.Setup(s => s.ReadAsync(Actor,It.IsAny())).ReturnsAsync(new ConfigurationSnapshot(7,"admin","12",_now,true,new Dictionary())); + _service = new(access.Object,_catalog,_snapshots.Object,_repository.Object,new FixedTime(_now)); + } + [Test] + public void Legacy_area_choices_upgrade_without_resetting_department_scope() + { + var state = SetupWorkspaceMetadata.Read("{\"home\":0,\"security\":0,\"people\":1}"); + Assert.That(state.Areas["people"], Is.EqualTo(SetupAreaChoice.LearnLater)); + state.Areas["inventory"] = SetupAreaChoice.NotApplicable; state.AreaReasons["inventory"] = SetupAreaReason.OtherSystem; + state.ReviewEvidence = new("version","12",_now,4,1,1,2); state.RevisitOnUtc = _now.AddDays(30); + var restored = SetupWorkspaceMetadata.Read(state.Serialize()); + Assert.That(restored.Areas, Is.EquivalentTo(state.Areas)); Assert.That(restored.AreaReasons, Is.EquivalentTo(state.AreaReasons)); + Assert.That(restored.ReviewEvidence, Is.EqualTo(state.ReviewEvidence)); Assert.That(restored.RevisitOnUtc, Is.EqualTo(state.RevisitOnUtc)); + } + [TestCase(null)] [TestCase("a private free-text reason")] + public void Not_applicable_requires_an_allowlisted_reason(string reason) + { + Assert.ThrowsAsync(async () => await _service.UpdateSetupAsync(Actor,new(0,"area","inventory","NotApplicable",_catalog.Version,reason))); + Assert.That(_saved, Is.Null); + } + [Test] + public async Task Scope_reason_is_recorded_but_cannot_defer_baseline_security() + { + await _service.UpdateSetupAsync(Actor,new(0,"area","inventory","NotApplicable",_catalog.Version,"OtherSystem")); + Assert.That(_saved.ReasonCode, Is.EqualTo("OtherSystem")); + Assert.ThrowsAsync(async () => await _service.UpdateSetupAsync(Actor,new(0,"area","security","NotApplicable",_catalog.Version,"OtherSystem"))); + } + [Test] + public async Task Review_records_server_evaluated_evidence_and_preserves_unresolved_unknowns() + { + await _service.UpdateSetupAsync(Actor,new(0,"review",CatalogVersion:_catalog.Version,EvidenceRevision:"12") + { ReviewEvidence=new("forged","0",_now,1,1,0,0) }); + Assert.That(_saved.ReviewEvidence.SnapshotRevision, Is.EqualTo("12")); Assert.That(_saved.ReviewEvidence.CatalogVersion, Is.EqualTo(_catalog.Version)); + Assert.That(_saved.ReviewEvidence.Unknown, Is.GreaterThan(0)); Assert.That(_saved.ReviewEvidence.Verified, Is.EqualTo(0)); + } + [Test] + public void A_changed_configuration_must_be_reloaded_before_recording_a_review() + { + Assert.ThrowsAsync(async () => await _service.UpdateSetupAsync(Actor,new(0,"review",CatalogVersion:_catalog.Version,EvidenceRevision:"11"))); + Assert.That(_saved, Is.Null); + } + [Test] + public async Task Revisit_date_is_metadata_only_and_can_be_cleared() + { + await _service.UpdateSetupAsync(Actor,new(0,"revisit",CatalogVersion:_catalog.Version,RevisitOnUtc:_now.AddDays(30))); + Assert.That(_saved.RevisitOnUtc, Is.EqualTo(_now.AddDays(30))); + await _service.UpdateSetupAsync(Actor,new(0,"revisit",CatalogVersion:_catalog.Version)); Assert.That(_saved.RevisitOnUtc, Is.Null); + Assert.ThrowsAsync(async () => await _service.UpdateSetupAsync(Actor,new(0,"revisit",CatalogVersion:_catalog.Version,RevisitOnUtc:_now.AddDays(-1)))); + Assert.ThrowsAsync(async () => await _service.UpdateSetupAsync(Actor,new(0,"revisit",CatalogVersion:_catalog.Version,RevisitOnUtc:_now.AddDays(366)))); + } + [Test] + public void Workspace_revision_cannot_overflow() + { + Assert.ThrowsAsync(async () => await _service.UpdateSetupAsync(Actor,new(long.MaxValue,"mode",Choice:"Fresh",CatalogVersion:_catalog.Version))); + Assert.That(_saved, Is.Null); + } + [Test] + public void Public_serializers_cannot_accept_a_supplied_review_result() + { + var json = "{\"ExpectedRevision\":0,\"Operation\":\"review\",\"ReviewEvidence\":{\"CatalogVersion\":\"forged\",\"Verified\":999}}"; + Assert.That(System.Text.Json.JsonSerializer.Deserialize(json).ReviewEvidence, Is.Null); + Assert.That(Newtonsoft.Json.JsonConvert.DeserializeObject(json).ReviewEvidence, Is.Null); + } + private sealed class FixedTime(DateTime now) : TimeProvider { public override DateTimeOffset GetUtcNow() => now; } + } +} diff --git a/Tests/Resgrid.Tests/AdminAssist/SnapshotTests.cs b/Tests/Resgrid.Tests/AdminAssist/SnapshotTests.cs new file mode 100644 index 000000000..d49fccd95 --- /dev/null +++ b/Tests/Resgrid.Tests/AdminAssist/SnapshotTests.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Resgrid.AdminAssist; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Services; +using Resgrid.Services.AdminAssist; + +namespace Resgrid.Tests.AdminAssist +{ + [TestFixture] + public class SnapshotTests + { + private static readonly AdminAssistActor Actor = new(7, "admin"); + private Mock Authorize() + { + var auth = new Mock(); + auth.Setup(a => a.IsActiveMemberAsync(Actor.UserId, Actor.DepartmentId)).ReturnsAsync(true); + auth.Setup(a => a.IsDepartmentAdminAsync(Actor.UserId, Actor.DepartmentId)).ReturnsAsync(true); + return auth; + } + private Mock Source(Func>> read) + { + var source = new Mock(); source.SetupGet(s => s.SourceId).Returns("test"); + source.SetupGet(s => s.EvidenceIds).Returns(new[] { "unitCount" }); + source.Setup(s => s.ReadAsync(Actor, It.IsAny(), It.IsAny())).Returns(read); + return source; + } + [Test] + public void Demoted_administrator_is_rejected_before_reading_any_evidence() + { + var auth = Authorize(); auth.Setup(a => a.IsDepartmentAdminAsync(Actor.UserId, Actor.DepartmentId)).ReturnsAsync(false); + var source = Source(() => Task.FromResult>(Array.Empty())); + var provider = new ConfigurationSnapshotProvider(new[] { source.Object }, Mock.Of(), auth.Object, new ConfigurationCatalog(), TimeProvider.System); + Assert.ThrowsAsync(() => provider.ReadAsync(Actor)); + source.Verify(s => s.ReadAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + [Test] + public void Mid_read_revocation_discards_the_entire_response() + { + var auth = Authorize(); + auth.SetupSequence(a => a.IsDepartmentAdminAsync(Actor.UserId, Actor.DepartmentId)).ReturnsAsync(true).ReturnsAsync(false); + var provider = new ConfigurationSnapshotProvider(Array.Empty(), Mock.Of(), auth.Object, new ConfigurationCatalog(), TimeProvider.System); + Assert.ThrowsAsync(() => provider.ReadAsync(Actor)); + } + [TestCase(false)] [TestCase(true)] + public async Task Missing_and_redacted_sources_never_become_zero_counts(bool redacted) + { + var source = Source(() => throw (redacted ? new UnauthorizedAccessException("private details") : new InvalidOperationException("private details"))); + var provider = new ConfigurationSnapshotProvider(new[] { source.Object }, Mock.Of(), Authorize().Object, new ConfigurationCatalog(), TimeProvider.System); + var snapshot = await provider.ReadAsync(Actor); + Assert.That(snapshot.Find("unitCount").State, Is.EqualTo(redacted ? EvidenceState.Redacted : EvidenceState.Unknown)); + Assert.That(snapshot.Find("unitCount").Number, Is.Null); + Assert.That(snapshot.Evidence.Values.All(e => e.ReasonCode != "private details"), Is.True); + } + [Test] + public async Task Configuration_mutation_during_source_reads_is_marked_inconsistent() + { + var repository = new Mock(); + repository.SetupSequence(r => r.GetConfigurationRevisionAsync(7, It.IsAny())).ReturnsAsync(3).ReturnsAsync(4); + var provider = new ConfigurationSnapshotProvider(Array.Empty(), repository.Object, Authorize().Object, new ConfigurationCatalog(), TimeProvider.System); + var snapshot = await provider.ReadAsync(Actor); + Assert.That(snapshot.Consistent, Is.False); Assert.That(snapshot.Revision, Is.EqualTo("4")); + } + } +} diff --git a/Tests/Resgrid.Tests/AdminAssist/StaffingEvidenceTests.cs b/Tests/Resgrid.Tests/AdminAssist/StaffingEvidenceTests.cs new file mode 100644 index 000000000..1d07ac7c5 --- /dev/null +++ b/Tests/Resgrid.Tests/AdminAssist/StaffingEvidenceTests.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services.AdminAssist; + +namespace Resgrid.Tests.AdminAssist +{ + [TestFixture] + public class StaffingEvidenceTests + { + private static readonly AdminAssistActor Actor = new(7, "admin"); + private static readonly DateTime Now = new(2026, 11, 1, 8, 30, 0, DateTimeKind.Utc); + private static ShiftDaySchedule Schedule(int id, string start, string end, params ShiftDayRosterEntry[] roster) + { + var shift = new Shift { ShiftId = id, DepartmentId = 7, StartTime = start, EndTime = end }; + return new ShiftDaySchedule { Shift = shift, Day = new ShiftDay { ShiftDayId = id, ShiftId = id, Shift = shift, Day = new DateTime(2026, 10, 31) }, Roster = roster.ToList() }; + } + private static (StaffingEvidenceSource Source, Mock Authorization) Create(List schedules) + { + var shifts = new Mock(); + shifts.Setup(s => s.ReadSchedulesForAdministrationAsync(7, It.IsAny(), It.IsAny(), Now, It.IsAny(), It.IsAny())).ReturnsAsync(schedules); + var departments = new Mock(); departments.Setup(d => d.GetDepartmentByIdAsync(7, true)).ReturnsAsync(new Department { DepartmentId = 7, TimeZone = "America/Los_Angeles" }); + var groups = new Mock(); + groups.Setup(g => g.GetAllGroupsByDepartmentIdAsync(7)).ReturnsAsync(new[] { + new DepartmentGroup { DepartmentId = 7, DepartmentGroupId = 1, Members = new List { new() { DepartmentId = 7, UserId = "a" } } }, + new DepartmentGroup { DepartmentId = 7, DepartmentGroupId = 2, Members = new List() } + }); + var authorization = new Mock(); authorization.Setup(a => a.CanUserViewPersonAsync("admin", It.IsAny(), 7)).ReturnsAsync(true); + var membership = new Mock(); membership.Setup(m => m.IsAssignableMemberAsync(It.IsAny(), 7)).ReturnsAsync(true); + return (new StaffingEvidenceSource(shifts.Object, departments.Object, groups.Object, authorization.Object, membership.Object), authorization); + } + [Test] + public async Task Overnight_DST_roster_counts_unique_people_and_ignores_pending_signups() + { + var first = Schedule(1, "19:00", "07:00", new() { UserId = "a" }, new() { UserId = "b", DepartmentGroupId = 2, ApprovalPending = true }); + var second = Schedule(2, "23:00", "03:00", new ShiftDayRosterEntry() { UserId = "a", DepartmentGroupId = 1 }); + first.Needs[1] = new() { [1] = 2 }; + first.Trades.Add(new ShiftSignupTrade { ShiftSignupTradeId = 10, ApprovalPending = true, UserId = "b" }); + second.Trades.Add(first.Trades[0]); + var (source, _) = Create(new() { first, second }); + var result = (await source.ReadAsync(Actor, Now, CancellationToken.None)).ToDictionary(e => e.Id); + Assert.That(result["groupsWithoutShiftCoverage"].Number, Is.EqualTo(1)); + Assert.That(result["singlePersonShiftGroups"].Number, Is.EqualTo(1)); + Assert.That(result["overlappingShiftPersonnel"].Number, Is.EqualTo(1)); + Assert.That(result["upcomingOpenShiftSlots"].Number, Is.EqualTo(2)); + Assert.That(result["unfilledShiftTrades"].Number, Is.EqualTo(1)); + } + [Test] + public void Hidden_roster_member_prevents_a_department_wide_pass() + { + var (source, authorization) = Create(new()); + authorization.Setup(a => a.CanUserViewPersonAsync("admin", "a", 7)).ReturnsAsync(false); + Assert.ThrowsAsync(() => source.ReadAsync(Actor, Now, CancellationToken.None)); + } + [Test] + public void Missing_schedule_source_is_not_an_empty_roster() + { + var (source, _) = Create(null); + Assert.ThrowsAsync(() => source.ReadAsync(Actor, Now, CancellationToken.None)); + } + } +} diff --git a/Tests/Resgrid.Tests/AdminAssist/StatusAutomationImpactTests.cs b/Tests/Resgrid.Tests/AdminAssist/StatusAutomationImpactTests.cs new file mode 100644 index 000000000..095d5af18 --- /dev/null +++ b/Tests/Resgrid.Tests/AdminAssist/StatusAutomationImpactTests.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services.AdminAssist; + +namespace Resgrid.Tests.AdminAssist +{ + [TestFixture] + public class StatusAutomationImpactTests + { + [TestCase(false)] [TestCase(true)] + public async Task Reset_preview_uses_actual_status_projection_and_restricted_person_is_unknown(bool restricted) + { + var now = DateTime.UtcNow; var actions = new Mock(); var visibility = new Mock(); var membership = new Mock(); + actions.Setup(a => a.ReadLatestForAdministrationAsync(7, true, now, It.IsAny(), It.IsAny())).ReturnsAsync(new List { new() { DepartmentId = 7, UserId = "person", ActionTypeId = (int)ActionTypes.RespondingToScene } }); + actions.Setup(a => a.ReadLatestForAdministrationAsync(7, false, now, It.IsAny(), It.IsAny())).ReturnsAsync(new List()); + visibility.Setup(v => v.CanUserViewPersonAsync("admin", "person", 7)).ReturnsAsync(!restricted); + membership.Setup(m => m.IsAssignableMemberAsync("person", 7)).ReturnsAsync(true); + var source = new StatusAutomationImpactProvider(actions.Object, visibility.Object, membership.Object); + var facts = new Dictionary { ["DisabledAutoAvailable"] = new("DisabledAutoAvailable", EvidenceState.Known, "test", "1", now, Boolean: true) }; + var result = await source.EvaluateAsync(new(7, "admin"), new(7, "admin", "1", now, true, facts), new("setting.DisabledAutoAvailable", "1", false), CancellationToken.None); + Assert.That(result.Metrics[0].State, Is.EqualTo(restricted ? EvidenceState.Redacted : EvidenceState.Known)); + Assert.That(result.Metrics[0].After, Is.EqualTo(restricted ? (decimal?)null : 1)); + if (!restricted) { Assert.That(result.Metrics[1].Before, Is.Zero); Assert.That(result.Metrics[1].After, Is.EqualTo(1)); } + } + } +} diff --git a/Tests/Resgrid.Tests/AdminAssist/TextImportImpactTests.cs b/Tests/Resgrid.Tests/AdminAssist/TextImportImpactTests.cs new file mode 100644 index 000000000..214796155 --- /dev/null +++ b/Tests/Resgrid.Tests/AdminAssist/TextImportImpactTests.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Resgrid.AdminAssist; +using Resgrid.Model; +using Resgrid.Model.AdminAssist; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; +using Resgrid.Services.AdminAssist; + +namespace Resgrid.Tests.AdminAssist +{ + [TestFixture] + public class TextImportImpactTests + { + [Test] + public void Shared_route_decisions_preserve_all_legacy_provider_branches() + { + foreach (var match in new[] { false, true }) + foreach (var calls in new[] { false, true }) + foreach (var commands in new[] { false, true }) + { + var twilio = TextIntakeRouting.Decide(TextIntakePath.TwilioLegacy, match, calls, commands); + Assert.That(twilio.CallBranch, Is.EqualTo(match)); Assert.That(twilio.CommandBranch, Is.EqualTo(!match)); + var signalWire = TextIntakeRouting.Decide(TextIntakePath.SignalWire, match, calls, commands); + Assert.That(signalWire.CallBranch, Is.EqualTo((match || !commands) && calls)); + Assert.That(signalWire.CommandBranch, Is.EqualTo(!match && commands)); + } + } + private sealed class Fixture + { + public readonly Mock Access = new(); + public readonly Mock Repository = new(); + public readonly Mock Settings = new(); + public readonly Mock Numbers = new(); + public TextImportImpactService Service => new(Access.Object, Repository.Object, new ConfigurationCatalog(), Settings.Object, Numbers.Object, TimeProvider.System); + public Fixture() + { + Access.Setup(a => a.CanAccessAsync(It.IsAny(), false, It.IsAny())).ReturnsAsync(true); + Settings.Setup(s => s.GetAllByDepartmentIdAsync(7)).ReturnsAsync(Array.Empty()); + } + } + [Test] + public async Task Disabling_commands_can_route_an_unmatched_sender_to_calls_and_returns_only_a_masked_reference() + { + var f = new Fixture(); + f.Settings.Setup(s => s.GetAllByDepartmentIdAsync(7)).ReturnsAsync(new[] { new DepartmentSetting { DepartmentId = 7, SettingType = (int)DepartmentSettingTypes.EnableTextCommand, Setting = "True" } }); + var report = await f.Service.PreviewAsync(new(7, "admin"), new("0", "SignalWire", "+1 202 555 0142", true, false)); + var calls = report.Impact.Metrics.Single(m => m.LabelKey == "Impact.TextDispatchBranch"); + Assert.That(calls.Before, Is.Zero); Assert.That(calls.After, Is.EqualTo(1)); Assert.That(report.MaskedSource, Is.EqualTo("••••0142")); + Assert.That(report.Impact.Metrics.Single(m => m.LabelKey == "Impact.TextActualAcceptance").State, Is.EqualTo(EvidenceState.Unknown)); + f.Numbers.VerifyNoOtherCalls(); f.Settings.Verify(s => s.GetAllByDepartmentIdAsync(7), Times.Exactly(2)); f.Settings.VerifyNoOtherCalls(); + } + [Test] + public async Task Twilio_legacy_pattern_match_stays_on_call_branch_even_when_both_proposed_switches_are_off() + { + var f = new Fixture(); + f.Settings.Setup(s => s.GetAllByDepartmentIdAsync(7)).ReturnsAsync(new[] { new DepartmentSetting { DepartmentId = 7, SettingType = (int)DepartmentSettingTypes.TextToCallSourceNumbers, Setting = "202555XXXX" } }); + f.Numbers.Setup(n => n.DoesNumberMatchAnyPattern(It.Is>(p => p.SequenceEqual(new[] { "202555XXXX" })), "+12025550142")).Returns(true); + var report = await f.Service.PreviewAsync(new(7, "admin"), new("0", "TwilioLegacy", "+12025550142", false, false)); + var calls = report.Impact.Metrics.Single(m => m.LabelKey == "Impact.TextDispatchBranch"); + Assert.That(calls.Before, Is.EqualTo(1)); Assert.That(calls.After, Is.EqualTo(1)); + f.Numbers.Verify(n => n.DoesNumberMatchAnyPattern(It.IsAny>(), It.IsAny()), Times.Once); f.Numbers.VerifyNoOtherCalls(); + } + [Test] + public async Task Unavailable_or_cross_tenant_settings_produce_unknown_not_an_acceptance_claim() + { + var f = new Fixture(); + f.Settings.Setup(s => s.GetAllByDepartmentIdAsync(7)).ReturnsAsync(new[] { new DepartmentSetting { DepartmentId = 8, SettingType = 0 } }); + var report = await f.Service.PreviewAsync(new(7, "admin"), new("0", "SignalWire", "2025550142", false, false)); + Assert.That(report.Impact.Metrics.Single().State, Is.EqualTo(EvidenceState.Unknown)); f.Numbers.VerifyNoOtherCalls(); + } + [TestCase(null)][TestCase("invalid")] + public async Task Malformed_stored_switch_is_unknown_not_an_absent_default(string value) + { + var f = new Fixture(); f.Settings.Setup(s => s.GetAllByDepartmentIdAsync(7)).ReturnsAsync(new[] { new DepartmentSetting { DepartmentId = 7, SettingType = (int)DepartmentSettingTypes.EnableTextToCall, Setting = value } }); + var report = await f.Service.PreviewAsync(new(7, "admin"), new("0", "SignalWire", "2025550142", true, false)); + Assert.That(report.Impact.Metrics.Single().State, Is.EqualTo(EvidenceState.Unknown)); + } + [Test] + public void Settings_drift_revision_change_or_revoked_access_invalidates_the_scenario() + { + var f = new Fixture(); var request = new TextImportImpactRequest("0", "SignalWire", "2025550142", false, false); + f.Settings.SetupSequence(s => s.GetAllByDepartmentIdAsync(7)).ReturnsAsync(Array.Empty()).ReturnsAsync(new[] { new DepartmentSetting { DepartmentId = 7, SettingType = (int)DepartmentSettingTypes.EnableTextCommand, Setting = "True" } }); + Assert.ThrowsAsync(async () => await f.Service.PreviewAsync(new(7, "admin"), request)); + f = new Fixture(); f.Repository.SetupSequence(r => r.GetConfigurationRevisionAsync(7, It.IsAny())).ReturnsAsync(0).ReturnsAsync(1); + Assert.ThrowsAsync(async () => await f.Service.PreviewAsync(new(7, "admin"), request)); + f = new Fixture(); f.Access.SetupSequence(a => a.CanAccessAsync(It.IsAny(), false, It.IsAny())).ReturnsAsync(true).ReturnsAsync(false); + Assert.ThrowsAsync(async () => await f.Service.PreviewAsync(new(7, "admin"), request)); + } + [TestCase("Twilio", "2025550142")][TestCase("SignalWire", "secret@example.test")][TestCase("SignalWire", "-------")][TestCase("SignalWire", "123")] + public void Invalid_scenarios_are_rejected_before_sources(string path, string source) + { + var f = new Fixture(); Assert.ThrowsAsync(async () => await f.Service.PreviewAsync(new(7, "admin"), new("0", path, source, false, false))); + f.Settings.VerifyNoOtherCalls(); f.Numbers.VerifyNoOtherCalls(); + } + } +} diff --git a/Tests/Resgrid.Tests/Chatbot/ExternalChatbotAuthorizationTests.cs b/Tests/Resgrid.Tests/Chatbot/ExternalChatbotAuthorizationTests.cs index 285ae57ee..bd9744af6 100644 --- a/Tests/Resgrid.Tests/Chatbot/ExternalChatbotAuthorizationTests.cs +++ b/Tests/Resgrid.Tests/Chatbot/ExternalChatbotAuthorizationTests.cs @@ -120,6 +120,26 @@ public async Task UnitsList_FiltersForeignUnitsBeforeApplyingFifteenUnitLimit_Wi authorization.Verify(a => a.CanUserViewUnitAsync(It.IsAny(), It.IsAny()), Times.Never); } + [Test] + public async Task UnitsList_LeavesOutUnitsTheUserMayNotView() + { + var units = new Mock(); + units.Setup(u => u.GetAllLatestStatusForUnitsByDepartmentIdAsync(DepartmentId)).ReturnsAsync(new List + { + State(100, DepartmentId, "MyAreaUnit"), + State(101, DepartmentId, "OtherAreaUnit") + }); + var authorization = MemberAuthorization(); + authorization.Setup(a => a.CanUserViewUnitViaMatrixAsync(101, UserId, DepartmentId)).ReturnsAsync(false); + var states = new Mock(); + states.Setup(s => s.GetCustomUnitStateAsync(It.IsAny())).ReturnsAsync(new CustomStateDetail { ButtonText = "Available" }); + var handler = new UnitsActionHandler(units.Object, states.Object, Mock.Of(), authorization.Object); + + var response = await handler.HandleAsync(Message(), new ChatbotIntent { Type = ChatbotIntentType.ListUnits }, Session()); + + response.Text.Should().Contain("MyAreaUnit").And.NotContain("OtherAreaUnit"); + } + [Test] public async Task UnitsList_NonMember_IsDeniedBeforeAnyUnitIsRead() { @@ -162,6 +182,29 @@ public async Task AvailableUnits_CountAndOverflowIncludeOnlyDepartmentUnits_With authorization.Verify(a => a.CanUserViewUnitAsync(It.IsAny(), It.IsAny()), Times.Never); } + [Test] + public async Task AvailableUnits_LeaveOutAndDoNotCountUnitsTheUserMayNotView() + { + var units = new Mock(); + units.Setup(u => u.GetAllLatestStatusForUnitsByDepartmentIdAsync(DepartmentId)).ReturnsAsync(new List + { + State(100, DepartmentId, "MyAreaUnit"), + State(101, DepartmentId, "OtherAreaUnit") + }); + var authorization = MemberAuthorization(); + authorization.Setup(a => a.CanUserViewUnitViaMatrixAsync(101, UserId, DepartmentId)).ReturnsAsync(false); + var states = new Mock(); + states.Setup(s => s.GetCustomUnitStateAsync(It.IsAny())).ReturnsAsync(new CustomStateDetail { ButtonText = "Available" }); + var reporting = new Mock(); + reporting.Setup(r => r.ClassifyUnitAvailabilityAsync(DepartmentId, It.IsAny(), It.IsAny())) + .ReturnsAsync(AvailabilityClass.Available); + var handler = new UnitsAvailableActionHandler(units.Object, states.Object, reporting.Object, authorization.Object); + + var response = await handler.HandleAsync(Message(), new ChatbotIntent { Type = ChatbotIntentType.UnitsAvailable }, Session()); + + response.Text.Should().Contain("Available Units (1):").And.Contain("MyAreaUnit").And.NotContain("OtherAreaUnit"); + } + [Test] public async Task AvailableUnits_NonMember_IsDeniedBeforeAnyUnitIsRead() { @@ -285,6 +328,8 @@ private static Mock MemberAuthorization() { var authorization = new Mock(); authorization.Setup(a => a.IsUserValidWithinLimitsAsync(UserId, DepartmentId)).ReturnsAsync(true); + // View Units is open unless a test narrows it. + authorization.Setup(a => a.CanUserViewUnitViaMatrixAsync(It.IsAny(), UserId, DepartmentId)).ReturnsAsync(true); return authorization; } diff --git a/Tests/Resgrid.Tests/Providers/ProtectedDataBrokerClientTests.cs b/Tests/Resgrid.Tests/Providers/ProtectedDataBrokerClientTests.cs index fb5b0f320..7fccee7b7 100644 --- a/Tests/Resgrid.Tests/Providers/ProtectedDataBrokerClientTests.cs +++ b/Tests/Resgrid.Tests/Providers/ProtectedDataBrokerClientTests.cs @@ -46,7 +46,7 @@ public async Task Plaintext_http_broker_url_reads_as_unconfigured_and_never_send { DataProtectionConfig.BrokerBaseUrl = "http://broker.test:8080"; var handler = new RefusingHandler(); - using var client = new ProtectedDataBrokerClient(handler); + using var client = new ProtectedDataBrokerClient(handler, Moq.Mock.Of()); client.IsConfigured.Should().BeFalse(); (await client.IsHealthyAsync()).Should().BeFalse(); @@ -63,7 +63,7 @@ public async Task Plaintext_http_broker_url_reads_as_unconfigured_and_never_send public void Https_broker_url_is_configured() { DataProtectionConfig.BrokerBaseUrl = "https://broker.test:8443"; - using var client = new ProtectedDataBrokerClient(new RefusingHandler()); + using var client = new ProtectedDataBrokerClient(new RefusingHandler(), Moq.Mock.Of()); client.IsConfigured.Should().BeTrue(); } @@ -72,7 +72,7 @@ public void Https_broker_url_is_configured() public async Task Empty_broker_url_reads_as_unconfigured() { DataProtectionConfig.BrokerBaseUrl = ""; - using var client = new ProtectedDataBrokerClient(new RefusingHandler()); + using var client = new ProtectedDataBrokerClient(new RefusingHandler(), Moq.Mock.Of()); client.IsConfigured.Should().BeFalse(); (await client.IsHealthyAsync()).Should().BeFalse(); diff --git a/Tests/Resgrid.Tests/Resgrid.Tests.csproj b/Tests/Resgrid.Tests/Resgrid.Tests.csproj index 0f8c57ef8..eabe4a484 100644 --- a/Tests/Resgrid.Tests/Resgrid.Tests.csproj +++ b/Tests/Resgrid.Tests/Resgrid.Tests.csproj @@ -44,6 +44,7 @@ + diff --git a/Tests/Resgrid.Tests/Rms/PermissionsServiceSelectRolesTests.cs b/Tests/Resgrid.Tests/Rms/PermissionsServiceSelectRolesTests.cs index a7bf47bc0..f2e2dc21c 100644 --- a/Tests/Resgrid.Tests/Rms/PermissionsServiceSelectRolesTests.cs +++ b/Tests/Resgrid.Tests/Rms/PermissionsServiceSelectRolesTests.cs @@ -23,7 +23,7 @@ public class PermissionsServiceSelectRolesTests [SetUp] public void SetUp() { - _service = new PermissionsService(new Mock().Object, new Mock().Object); + _service = new PermissionsService(new Mock().Object, new Mock().Object, new Mock().Object); } private static Permission Value4(string data = "9", bool lockToGroup = false) diff --git a/Tests/Resgrid.Tests/Search/UnifiedSearchBusinessOperationsTests.cs b/Tests/Resgrid.Tests/Search/UnifiedSearchBusinessOperationsTests.cs index 172b702eb..92302303d 100644 --- a/Tests/Resgrid.Tests/Search/UnifiedSearchBusinessOperationsTests.cs +++ b/Tests/Resgrid.Tests/Search/UnifiedSearchBusinessOperationsTests.cs @@ -27,7 +27,7 @@ private void BuildWithBusinessOperations() { _deployments = new Mock(MockBehavior.Strict); _certifications = new Mock(); - var permissions = new Resgrid.Services.PermissionsService(_permissions.Object, Mock.Of()); + var permissions = new Resgrid.Services.PermissionsService(_permissions.Object, Mock.Of(), Mock.Of()); _service = new UnifiedSearchService(_global.Object, _actions.Object, _flags.Object, _auth.Object, _states.Object, _recordsSearch.Object, _recordsAuth.Object, _records.Object, _cutover.Object, _departments.Object, permissions, _groups.Object, _roles.Object, _calls.Object, _units.Object, _messages.Object, _documents.Object, _notes.Object, diff --git a/Tests/Resgrid.Tests/Search/UnifiedSearchServiceTests.cs b/Tests/Resgrid.Tests/Search/UnifiedSearchServiceTests.cs index edde33dbb..03b004e3e 100644 --- a/Tests/Resgrid.Tests/Search/UnifiedSearchServiceTests.cs +++ b/Tests/Resgrid.Tests/Search/UnifiedSearchServiceTests.cs @@ -98,7 +98,7 @@ public void SetUp() _projections = new Mock(); _projections.Setup(p => p.GetByIdsAsync(7, It.IsAny>())).ReturnsAsync(() => _rows); _permissions = new Mock(); - var permissions = new Resgrid.Services.PermissionsService(_permissions.Object, Mock.Of()); + var permissions = new Resgrid.Services.PermissionsService(_permissions.Object, Mock.Of(), Mock.Of()); _service = new UnifiedSearchService(_global.Object, _actions.Object, _flags.Object, _auth.Object, _states.Object, _recordsSearch.Object, _recordsAuth.Object, _records.Object, _cutover.Object, _departments.Object, permissions, _groups.Object, diff --git a/Tests/Resgrid.Tests/Services/AdpAccessDatabaseTests.cs b/Tests/Resgrid.Tests/Services/AdpAccessDatabaseTests.cs new file mode 100644 index 000000000..d77ac4725 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/AdpAccessDatabaseTests.cs @@ -0,0 +1,102 @@ +using System; +using System.Data.Common; +using System.Linq; +using System.Threading.Tasks; +using Dapper; +using FluentAssertions; +using FluentMigrator; +using FluentMigrator.Runner; +using FluentMigrator.Runner.Initialization; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Npgsql; +using NUnit.Framework; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Providers.Migrations.Migrations; +using Resgrid.Providers.MigrationsPg.Migrations; +using Resgrid.Repositories.DataRepository; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Servers.SqlServer; + +namespace Resgrid.Tests.Services +{ + [TestFixture(DatabaseTypes.SqlServer), TestFixture(DatabaseTypes.Postgres), NonParallelizable] + public class AdpAccessDatabaseTests(DatabaseTypes type) + { + private DatabaseTypes _previous; + private string _master, _connection, _database; + private ServiceProvider _runner; + private DbConnection Connect(string connection) => type == DatabaseTypes.Postgres ? new NpgsqlConnection(connection) : new SqlConnection(connection); + private SqlConfiguration Configuration() => type == DatabaseTypes.Postgres ? new PostgreSqlConfiguration() : new SqlServerConfiguration(); + private IConnectionProvider Connections() + { + var connections = new Mock(); + connections.Setup(c => c.Create()).Returns(() => Connect(_connection)); + return connections.Object; + } + + [OneTimeSetUp] + public async Task Create_isolated_database() + { + _master = Environment.GetEnvironmentVariable(type == DatabaseTypes.Postgres ? "RESGRID_ADP_POSTGRES_TEST_CONNECTION" : "RESGRID_ADP_SQLSERVER_TEST_CONNECTION"); + if (string.IsNullOrWhiteSpace(_master)) Assert.Ignore("Set an ADP test connection to run real database concurrency checks."); + _previous = DataConfig.DatabaseType; + DataConfig.DatabaseType = type; + _database = "adp_verification_" + Guid.NewGuid().ToString("N"); + await using var master = Connect(_master); + await master.ExecuteAsync("CREATE DATABASE " + _database); + _connection = type == DatabaseTypes.Postgres + ? new NpgsqlConnectionStringBuilder(_master) { Database = _database }.ConnectionString + : new SqlConnectionStringBuilder(_master) { InitialCatalog = _database }.ConnectionString; + var source = new Mock(); + source.Setup(s => s.GetMigrations()).Returns(new IMigration[] { type == DatabaseTypes.Postgres ? new M0236_AddAdpAuditPg() : new M0236_AddAdpAudit() }); + _runner = new ServiceCollection().AddFluentMigratorCore().ConfigureRunner(r => { + if (type == DatabaseTypes.Postgres) r.AddPostgres(); else r.AddSqlServer(); + r.WithGlobalConnectionString(_connection); + }).AddSingleton(source.Object).BuildServiceProvider(); + _runner.GetRequiredService().MigrateUp(); + } + + [Test] + public async Task Concurrent_appends_form_one_chain_that_survives_database_roundtrip() + { + var audit = new AdpAuditRepository(Connections(), Configuration()); + await Task.WhenAll(Enumerable.Range(0, 16).Select(_ => audit.AppendAsync(new AdpAuditEvent { + DepartmentId = 7, Layer = "broker", Operation = "decrypt", Outcome = "requested" }))); + var rows = await audit.ReadAsync(7); + rows.Count.Should().Be(16); + AdpAuditChain.Verify(rows, 16, rows.Last().Hash).Should().BeTrue(); + (await audit.ReadAsync(8)).Should().BeEmpty(); + } + + [Test] + public async Task Compare_and_swap_allows_only_one_consuming_host() + { + var store = new AdpAccessStore(Connections(), Configuration()); + var key = Guid.NewGuid().ToString("N"); + (await store.SaveAsync(key, "unused", 0)).Should().BeTrue(); + var winners = await Task.WhenAll(Enumerable.Range(0, 16).Select(_ => store.SaveAsync(key, "consumed", 1))); + winners.Count(w => w).Should().Be(1); + var stored = await store.GetAsync(key); + stored.Version.Should().Be(2); + stored.Json.Should().Be("consumed"); + } + + [OneTimeTearDown] + public async Task Remove_only_this_fixture_database() + { + if (_database == null) return; + _runner?.Dispose(); + DataConfig.DatabaseType = _previous; + if (!_database.StartsWith("adp_verification_", StringComparison.Ordinal) || !Guid.TryParseExact(_database.Substring("adp_verification_".Length), "N", out _)) + throw new InvalidOperationException("Unexpected test database name."); + if (type == DatabaseTypes.Postgres) NpgsqlConnection.ClearAllPools(); else SqlConnection.ClearAllPools(); + await using var master = Connect(_master); + await master.ExecuteAsync(type == DatabaseTypes.Postgres ? "DROP DATABASE " + _database + " WITH (FORCE)" + : "ALTER DATABASE " + _database + " SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE " + _database); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/AdpReleaseTests.cs b/Tests/Resgrid.Tests/Services/AdpReleaseTests.cs new file mode 100644 index 000000000..89dbc7e1f --- /dev/null +++ b/Tests/Resgrid.Tests/Services/AdpReleaseTests.cs @@ -0,0 +1,286 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using Newtonsoft.Json; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class AdpReleaseTests + { + private MemoryAccessStore _store; + private Mock _audit; + private Mock _protection; + private Mock _authorization; + private Mock _grants; + private Mock _broker; + private Mock _profiles; + private Mock _calls; + private AdpReleaseService _service; + private AdpReleaseReceiptService _receipts; + private DepartmentDataProtectionPolicy _policy; + private DepartmentProtectedDataEgressPolicy _egress; + private const string Phone = "+12015550123"; + private const string Pin = "739152"; + + [SetUp] + public void Setup() + { + _store = new MemoryAccessStore(); + _audit = new Mock(); + _policy = new DepartmentDataProtectionPolicy { DepartmentId = 7, PolicyEpoch = 3, State = (int)DepartmentDataProtectionState.Enabled }; + _egress = new DepartmentProtectedDataEgressPolicy { DepartmentId = 7, SmsMode = 1, VoiceMode = 1, + AcknowledgementVersion = "v1", AcknowledgedOn = DateTime.UtcNow, PinChallengeExpiryMinutes = 5, PinMaxAttempts = 3, PinLockoutMinutes = 15 }; + _protection = new Mock(); + _protection.Setup(p => p.IsProtectionEnforcedAsync(7)).ReturnsAsync(true); + _protection.Setup(p => p.GetPolicyByDepartmentIdAsync(7, true)).ReturnsAsync(_policy); + _protection.Setup(p => p.GetEgressPolicyByDepartmentIdAsync(7, true)).ReturnsAsync(_egress); + _grants = new Mock(); + var grant = new ProtectedDataGrant { UserId = "member", MfaAtUtc = DateTime.UtcNow }; + _grants.Setup(g => g.ValidateGrant("grant", 7, 3, ProtectedDataGrantScopes.Read, out grant, null)) + .Returns(ProtectedDataGrantValidationOutcome.Valid); + _authorization = new Mock(); + _authorization.Setup(a => a.CanUserViewCallAsync("member", 42)).ReturnsAsync(true); + _profiles = new Mock(); + _profiles.Setup(p => p.GetProfileByUserIdAsync("member", false)).ReturnsAsync( + new UserProfile { UserId = "member", MobileNumber = Phone, MobileNumberVerified = true }); + _calls = new Mock(); + _calls.Setup(c => c.GetCallByIdAsync(42, true)).ReturnsAsync(new Call { CallId = 42, DepartmentId = 7, + Name = "Dispatch name", Address = "Dispatch address", NatureOfCall = "Dispatch nature" }); + var departments = new Mock(); + departments.Setup(d => d.GetDepartmentMemberAsync("member", 7, true)).ReturnsAsync(new DepartmentMember()); + var permissions = new Mock(); + permissions.Setup(p => p.IsUserAllowed(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>())).Returns(true); + _broker = new Mock(); + _receipts = new AdpReleaseReceiptService(_store, _audit.Object); + _service = new AdpReleaseService(_store, _audit.Object, _protection.Object, _grants.Object, + _receipts, _broker.Object, _calls.Object, _authorization.Object, _profiles.Object, departments.Object, + permissions.Object, Mock.Of(), Mock.Of(), + new Resgrid.Providers.NumberProvider.PhoneNumberProcesserProvider()); + } + + private async Task Challenge() + { + (await _service.EnrollPinAsync(7, "member", "grant", Pin)).Should().BeTrue(); + var id = await _service.CreateChallengeAsync(7, 42, "member", Phone, ProtectedDataEgressChannel.Sms); + id.Should().NotBeNull(); + return id; + } + + [Test] + public async Task Release_is_one_use_and_never_stores_the_pin_or_content() + { + var id = await Challenge(); + (await _service.ReleaseAsync(id, Phone, Pin, ProtectedDataEgressChannel.Sms)).Should().Contain("Dispatch nature"); + (await _service.ReleaseAsync(id, Phone, Pin, ProtectedDataEgressChannel.Sms)).Should().BeNull(); + string.Join("", _store.Snapshot()).Should().NotContain(Pin).And.NotContain("Dispatch nature"); + } + + [Test] + public async Task Lockout_survives_new_challenges_and_correct_pin() + { + var id = await Challenge(); + for (var i = 0; i < 3; i++) + (await _service.ReleaseAsync(id, Phone, "000000", ProtectedDataEgressChannel.Sms)).Should().BeNull(); + (await _service.ReleaseAsync(id, Phone, Pin, ProtectedDataEgressChannel.Sms)).Should().BeNull(); + (await _service.CreateChallengeAsync(7, 42, "member", Phone, ProtectedDataEgressChannel.Sms)).Should().BeNull(); + } + + [TestCase(false)] + [TestCase(true)] + public async Task Encrypted_release_uses_bound_receipt_and_rechecks_authorization_after_broker(bool revoked) + { + _calls.Setup(c => c.GetCallByIdAsync(42, true)).ReturnsAsync(new Call { CallId = 42, DepartmentId = 7, + Name = "rgdp:1:1:encrypted", Address = "Address", NatureOfCall = "Nature" }); + var id = await Challenge(); + _broker.Setup(b => b.DecryptAsync(7, It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny())) + .Returns(async (int dept, string token, string requestId, IReadOnlyList fields, CancellationToken ct) => + { + fields.Should().ContainSingle(f => f.FieldId == "calls.name" && f.RowKey == "42" && f.Value == "rgdp:1:1:encrypted"); + (await _receipts.ConsumeAsync(token, dept, 3, fields, ct)).Should().Be("member"); + if (revoked) _authorization.Setup(a => a.CanUserViewCallAsync("member", 42)).ReturnsAsync(false); + return new ProtectedDataBrokerResult { Success = true, Items = new List + { new() { FieldId = "calls.name", RowKey = "42", Value = "Protected dispatch" } } }; + }); + var text = await _service.ReleaseAsync(id, Phone, Pin, ProtectedDataEgressChannel.Sms); + if (revoked) text.Should().BeNull(); + else text.Should().Be("Protected dispatch. Address. Nature"); + _broker.Verify(b => b.DecryptAsync(7, It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny()), Times.Once); + } + + [Test] + public async Task Wrong_phone_channel_epoch_and_changed_authorization_refuse_release() + { + var id = await Challenge(); + (await _service.ReleaseAsync(id, "+15555550999", Pin, ProtectedDataEgressChannel.Sms)).Should().BeNull(); + (await _service.ReleaseAsync(id, Phone, Pin, ProtectedDataEgressChannel.Voice)).Should().BeNull(); + _policy.PolicyEpoch++; + (await _service.ReleaseAsync(id, Phone, Pin, ProtectedDataEgressChannel.Sms)).Should().BeNull(); + _policy.PolicyEpoch--; + _authorization.Setup(a => a.CanUserViewCallAsync("member", 42)).ReturnsAsync(false); + (await _service.ReleaseAsync(id, Phone, Pin, ProtectedDataEgressChannel.Sms)).Should().BeNull(); + } + + [Test] + public async Task Reset_pin_revokes_pending_challenges() + { + var id = await Challenge(); + (await _service.EnrollPinAsync(7, "member", "grant", "825173")).Should().BeTrue(); + (await _service.ReleaseAsync(id, Phone, "825173", ProtectedDataEgressChannel.Sms)).Should().BeNull(); + } + + [Test] + public async Task Unverified_phone_and_disabled_egress_refuse_release() + { + var id = await Challenge(); + _egress.SmsMode = 0; + (await _service.ReleaseAsync(id, Phone, Pin, ProtectedDataEgressChannel.Sms)).Should().BeNull(); + _egress.SmsMode = 1; + _profiles.Setup(p => p.GetProfileByUserIdAsync("member", false)).ReturnsAsync(new UserProfile { MobileNumber = Phone, MobileNumberVerified = false }); + (await _service.ReleaseAsync(id, Phone, Pin, ProtectedDataEgressChannel.Sms)).Should().BeNull(); + } + + [TestCase("(201) 555-0123", "+12015550123")] + [TestCase("0044 7400 555012", "+447400555012")] + public async Task Stored_phone_formats_match_the_canonical_provider_callback(string stored, string callback) + { + _profiles.Setup(p => p.GetProfileByUserIdAsync("member", false)).ReturnsAsync(new UserProfile + { UserId = "member", MobileNumber = stored, MobileNumberVerified = true }); + (await _service.EnrollPinAsync(7, "member", "grant", Pin)).Should().BeTrue(); + var id = await _service.CreateChallengeAsync(7, 42, "member", stored, ProtectedDataEgressChannel.Sms); + id.Should().NotBeNull(); + (await _service.ReleaseAsync(id, callback, Pin, ProtectedDataEgressChannel.Sms)).Should().Contain("Dispatch nature"); + } + + [Test] + public async Task Concurrent_release_has_exactly_one_winner() + { + var id = await Challenge(); + var results = await Task.WhenAll(Enumerable.Range(0, 4).Select(_ => Task.Run(() => _service.ReleaseAsync(id, Phone, Pin, ProtectedDataEgressChannel.Sms)))); + results.Count(r => r != null).Should().Be(1); + } + + [Test] + public async Task Receipt_is_bound_to_tenant_epoch_exact_fields_and_is_atomic() + { + var fields = new[] { new ProtectedFieldOperationItem { FieldId = "calls.name", RowKey = "42", Value = "envelope" } }; + var token = await _receipts.IssueAsync(7, 3, "member", "sms-pin", fields); + (await _receipts.ConsumeAsync(token, 8, 3, fields)).Should().BeNull(); + (await _receipts.ConsumeAsync(token, 7, 4, fields)).Should().BeNull(); + fields[0].RowKey = "43"; + (await _receipts.ConsumeAsync(token, 7, 3, fields)).Should().BeNull(); + fields[0].RowKey = "42"; + var results = await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => Task.Run(() => _receipts.ConsumeAsync(token, 7, 3, fields)))); + results.Count(r => r == "member").Should().Be(1); + } + + [Test] + public async Task Staff_receipt_requires_consent_and_revoke_reenable_does_not_restore_it() + { + var fields = new[] { new ProtectedFieldOperationItem { FieldId = "calls.name", RowKey = "42", Value = "envelope" } }; + Func issue = () => _receipts.IssueAsync(7, 3, "staff", "staff-support", fields); + await issue.Should().ThrowAsync(); + await _store.SaveAsync(AdpSupportConsent.Key(7), JsonConvert.SerializeObject(new AdpSupportConsent { Enabled = true }), 0); + var token = await _receipts.IssueAsync(7, 3, "staff", "staff-support", fields); + await _store.SaveAsync(AdpSupportConsent.Key(7), JsonConvert.SerializeObject(new AdpSupportConsent { Enabled = false }), 1); + await _store.SaveAsync(AdpSupportConsent.Key(7), JsonConvert.SerializeObject(new AdpSupportConsent { Enabled = true }), 2); + (await _receipts.ConsumeAsync(token, 7, 3, fields)).Should().BeNull(); + } + + [Test] + public async Task Audit_failure_prevents_issuance() + { + _audit.Setup(a => a.AppendAsync(It.IsAny(), It.IsAny())).ThrowsAsync(new InvalidOperationException()); + Func issue = () => _service.EnrollPinAsync(7, "member", "grant", Pin); + await issue.Should().ThrowAsync(); + _store.Snapshot().Should().BeEmpty(); + } + + [Test] + public async Task Kms_audit_failure_zeros_the_unwrapped_key_before_refusing_it() + { + var key = Enumerable.Repeat((byte)42, 32).ToArray(); + var inner = new Mock(); + inner.Setup(p => p.UnwrapDataKeyAsync(7, "wrapped", It.IsAny())).ReturnsAsync(key); + _audit.Setup(a => a.AppendAsync(It.Is(e => e.Outcome == "completed"), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Audit storage unavailable")); + var audited = new Resgrid.Providers.ProtectedData.AuditedKeyWrappingProvider(inner.Object, _audit.Object); + Func unwrap = () => audited.UnwrapDataKeyAsync(7, "wrapped"); + await unwrap.Should().ThrowAsync(); + key.Should().OnlyContain(b => b == 0); + } + + [Test] + public async Task Expired_challenge_is_refused() + { + var id = await Challenge(); + var state = await _store.GetAsync("challenge:" + id); + var json = Newtonsoft.Json.Linq.JObject.Parse(state.Json); + json["ExpiresUtc"] = DateTime.UtcNow.AddMinutes(-1); + await _store.SaveAsync(state.StateId, json.ToString(), state.Version); + (await _service.ReleaseAsync(id, Phone, Pin, ProtectedDataEgressChannel.Sms)).Should().BeNull(); + } + + [TestCase(true, "member", 0)] + [TestCase(false, "someone-else", 0)] + [TestCase(false, "member", -10)] + [TestCase(false, "member", 10)] + public async Task Pin_enrollment_requires_same_user_and_recent_real_mfa(bool exempt, string userId, int offset) + { + var grant = new ProtectedDataGrant { UserId = userId, StepUpExempt = exempt, MfaAtUtc = DateTime.UtcNow.AddMinutes(offset) }; + _grants.Setup(g => g.ValidateGrant("grant", 7, 3, ProtectedDataGrantScopes.Read, out grant, null)).Returns(ProtectedDataGrantValidationOutcome.Valid); + (await _service.EnrollPinAsync(7, "member", "grant", Pin)).Should().BeFalse(); + _store.Snapshot().Should().BeEmpty(); + } + + [Test] + public void Chain_detects_mutation_reordering_cross_tenant_and_truncation_against_checkpoint() + { + var first = new AdpAuditEvent { DepartmentId = 7, Layer = "identity", Operation = "grant", Outcome = "verified" }; + var second = new AdpAuditEvent { DepartmentId = 7, Layer = "broker", Operation = "decrypt", Outcome = "completed" }; + AdpAuditChain.Link(first, null); + AdpAuditChain.Link(second, first); + var checkpoint = second.Hash; + AdpAuditChain.Verify(new[] { first, second }, 2, checkpoint).Should().BeTrue(); + AdpAuditChain.Verify(new[] { first }, 2, checkpoint).Should().BeFalse(); + AdpAuditChain.Verify(new[] { second, first }, 2, checkpoint).Should().BeFalse(); + second.Outcome = "denied"; + AdpAuditChain.Verify(new[] { first, second }, 2, checkpoint).Should().BeFalse(); + second.DepartmentId = 8; + AdpAuditChain.Link(second, first); + AdpAuditChain.Verify(new[] { first, second }, 2, second.Hash).Should().BeFalse(); + } + + private sealed class MemoryAccessStore : IAdpAccessStore + { + private readonly Dictionary _rows = new(); + public Task GetAsync(string id, CancellationToken cancellationToken = default) + { + lock (_rows) return Task.FromResult(_rows.TryGetValue(id, out var row) + ? new AdpAccessState { StateId = id, Version = row.Version, Json = row.Json } : null); + } + public Task SaveAsync(string id, string json, long expectedVersion, CancellationToken cancellationToken = default) + { + lock (_rows) + { + if ((_rows.TryGetValue(id, out var row) ? row.Version : 0) != expectedVersion) return Task.FromResult(false); + _rows[id] = new AdpAccessState { StateId = id, Version = expectedVersion + 1, Json = json }; + return Task.FromResult(true); + } + } + public string[] Snapshot() { lock (_rows) return _rows.Values.Select(r => r.Json).ToArray(); } + } + } +} diff --git a/Tests/Resgrid.Tests/Services/AuthorizationServicePersonGroupLockTests.cs b/Tests/Resgrid.Tests/Services/AuthorizationServicePersonGroupLockTests.cs new file mode 100644 index 000000000..0f6ea58d4 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/AuthorizationServicePersonGroupLockTests.cs @@ -0,0 +1,338 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; + +namespace Resgrid.Tests.Services +{ + namespace AuthorizationServiceTests + { + /// + /// The direct visibility checks for a person (ViewGroupUsers), a person's location (CanSeePersonnelLocations), + /// a unit (ViewGroupUnits) and a unit's location (CanSeeUnitLocations), under every permission action that can + /// be locked to group. + /// They give the same answers as the visibility matrices: locked means the target's own group, except that + /// admins of a group above it (an area supervisor) count for the locked "department and group admins" rule. + /// Department admins always pass; someone in no group shares a group with nobody. + /// + [TestFixture] + public class when_authorizing_a_view_locked_to_group : with_the_authorization_service + { + private const int DepartmentId = 1; + private const int ServiceArea = 5; + private const int GroupA = 10; // beneath the service area + private const int GroupB = 20; // a separate top-level group + private const int SelectedRoleId = 9; + + private const int UnitA = 100; + private const int UnitB = 200; + private const int UnitNoStation = 300; + private const int UnitOtherDepartment = 400; + + private const string DepartmentAdmin = "dept-admin"; + private const string AreaSupervisor = "area-supervisor"; + private const string AreaMember = "area-member"; + private const string GroupAdminA = "group-admin-a"; + private const string MemberA = "member-a"; + private const string RoleHolderA = "role-holder-a"; + private const string RoleHolderNoGroup = "role-holder-none"; + private const string NoGroupMember = "no-group"; + private const string TargetA = "target-a"; + private const string TargetB = "target-b"; + private const string TargetNoGroup = "target-none"; + + private Permission _permission; + + [SetUp] + public void Setup() + { + var department = CreateDepartmentWithAdmins(DepartmentId, "owner", DepartmentAdmin); + _departmentsServiceMock.Setup(m => m.GetDepartmentByIdAsync(DepartmentId, It.IsAny())).ReturnsAsync(department); + _departmentsServiceMock.Setup(m => m.GetDepartmentByUserIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(department); + + var serviceArea = Group(ServiceArea, null, + new DepartmentGroupMember { UserId = AreaSupervisor, IsAdmin = true }, + new DepartmentGroupMember { UserId = AreaMember, IsAdmin = false }); + var groupA = Group(GroupA, ServiceArea, + new DepartmentGroupMember { UserId = GroupAdminA, IsAdmin = true }, + new DepartmentGroupMember { UserId = MemberA, IsAdmin = false }, + new DepartmentGroupMember { UserId = RoleHolderA, IsAdmin = false }, + new DepartmentGroupMember { UserId = TargetA, IsAdmin = false }); + var groupB = Group(GroupB, null, new DepartmentGroupMember { UserId = TargetB, IsAdmin = false }); + + _departmentGroupsServiceMock.Setup(m => m.GetGroupForUserAsync(It.IsAny(), DepartmentId)).ReturnsAsync((DepartmentGroup)null); + foreach (var userId in new[] { AreaSupervisor, AreaMember }) + _departmentGroupsServiceMock.Setup(m => m.GetGroupForUserAsync(userId, DepartmentId)).ReturnsAsync(serviceArea); + foreach (var userId in new[] { GroupAdminA, MemberA, RoleHolderA, TargetA }) + _departmentGroupsServiceMock.Setup(m => m.GetGroupForUserAsync(userId, DepartmentId)).ReturnsAsync(groupA); + _departmentGroupsServiceMock.Setup(m => m.GetGroupForUserAsync(TargetB, DepartmentId)).ReturnsAsync(groupB); + + // What the matrices use too: a group's admins plus the admins of every group above it. + _departmentGroupsServiceMock.Setup(m => m.GetAllAdminsForGroupAndAncestorsAsync(ServiceArea)) + .ReturnsAsync(new List { new DepartmentGroupMember { UserId = AreaSupervisor, IsAdmin = true } }); + _departmentGroupsServiceMock.Setup(m => m.GetAllAdminsForGroupAndAncestorsAsync(GroupA)) + .ReturnsAsync(new List + { + new DepartmentGroupMember { UserId = GroupAdminA, IsAdmin = true }, + new DepartmentGroupMember { UserId = AreaSupervisor, IsAdmin = true } + }); + _departmentGroupsServiceMock.Setup(m => m.GetAllAdminsForGroupAndAncestorsAsync(GroupB)) + .ReturnsAsync(new List()); + + var selectedRole = new List { new PersonnelRole { PersonnelRoleId = SelectedRoleId, Name = "Supervisor" } }; + _personnelRolesServiceMock.Setup(m => m.GetRolesForUserAsync(It.IsAny(), DepartmentId)).ReturnsAsync(new List()); + _personnelRolesServiceMock.Setup(m => m.GetRolesForUserAsync(RoleHolderA, DepartmentId)).ReturnsAsync(selectedRole); + _personnelRolesServiceMock.Setup(m => m.GetRolesForUserAsync(RoleHolderNoGroup, DepartmentId)).ReturnsAsync(selectedRole); + + _unitsServiceMock.Setup(m => m.GetUnitByIdAsync(UnitA)).ReturnsAsync(new Unit { UnitId = UnitA, DepartmentId = DepartmentId, StationGroupId = GroupA }); + _unitsServiceMock.Setup(m => m.GetUnitByIdAsync(UnitB)).ReturnsAsync(new Unit { UnitId = UnitB, DepartmentId = DepartmentId, StationGroupId = GroupB }); + _unitsServiceMock.Setup(m => m.GetUnitByIdAsync(UnitNoStation)).ReturnsAsync(new Unit { UnitId = UnitNoStation, DepartmentId = DepartmentId, StationGroupId = null }); + _unitsServiceMock.Setup(m => m.GetUnitByIdAsync(UnitOtherDepartment)).ReturnsAsync(new Unit { UnitId = UnitOtherDepartment, DepartmentId = DepartmentId + 1, StationGroupId = GroupA }); + + _permissionsServiceMock.Setup(m => m.GetPermissionByDepartmentTypeAsync(DepartmentId, It.IsAny())) + .ReturnsAsync(() => _permission); + } + + private static DepartmentGroup Group(int id, int? parentId, params DepartmentGroupMember[] members) + { + return new DepartmentGroup { DepartmentGroupId = id, DepartmentId = DepartmentId, ParentDepartmentGroupId = parentId, Members = new List(members) }; + } + + private void Permit(PermissionTypes type, PermissionActions action, bool lockToGroup, string roleIds = null) + { + _permission = new Permission { DepartmentId = DepartmentId, PermissionType = (int)type, Action = (int)action, LockToGroup = lockToGroup, Data = roleIds }; + } + + private Task CanView(PermissionTypes type, string viewer, string target) + { + return type == PermissionTypes.CanSeePersonnelLocations + ? _authorizationService.CanUserViewPersonLocationAsync(viewer, target, DepartmentId) + : _authorizationService.CanUserViewPersonAsync(viewer, target, DepartmentId); + } + + private Task CanSeeUnit(PermissionTypes type, string viewer, int unitId) + { + return type == PermissionTypes.CanSeeUnitLocations + ? _authorizationService.CanUserViewUnitLocationAsync(viewer, unitId, DepartmentId) + : _authorizationService.CanUserViewUnitAsync(viewer, unitId); + } + + #region People: everyone, locked to group + + [TestCase(PermissionTypes.ViewGroupUsers)] + [TestCase(PermissionTypes.CanSeePersonnelLocations)] + public async Task everyone_locked_sees_their_own_group(PermissionTypes type) + { + Permit(type, PermissionActions.Everyone, true); + + (await CanView(type, MemberA, TargetA)).Should().BeTrue(); + } + + [TestCase(PermissionTypes.ViewGroupUsers)] + [TestCase(PermissionTypes.CanSeePersonnelLocations)] + public async Task everyone_locked_does_not_see_another_group(PermissionTypes type) + { + Permit(type, PermissionActions.Everyone, true); + + (await CanView(type, MemberA, TargetB)).Should().BeFalse(); + } + + [TestCase(PermissionTypes.ViewGroupUsers)] + [TestCase(PermissionTypes.CanSeePersonnelLocations)] + public async Task everyone_locked_does_not_reach_down_into_groups_beneath(PermissionTypes type) + { + // Only the admin rule reaches beneath an area; plain members stay in their own group, as in the matrices. + Permit(type, PermissionActions.Everyone, true); + + (await CanView(type, AreaMember, TargetA)).Should().BeFalse(); + } + + [TestCase(PermissionTypes.ViewGroupUsers)] + [TestCase(PermissionTypes.CanSeePersonnelLocations)] + public async Task everyone_locked_shares_no_group_with_someone_in_none(PermissionTypes type) + { + Permit(type, PermissionActions.Everyone, true); + + (await CanView(type, MemberA, TargetNoGroup)).Should().BeFalse(); + (await CanView(type, NoGroupMember, TargetA)).Should().BeFalse(); + } + + [TestCase(PermissionTypes.ViewGroupUsers)] + [TestCase(PermissionTypes.CanSeePersonnelLocations)] + public async Task everyone_locked_still_lets_department_admins_see_every_group(PermissionTypes type) + { + Permit(type, PermissionActions.Everyone, true); + + (await CanView(type, DepartmentAdmin, TargetB)).Should().BeTrue(); + } + + [TestCase(PermissionTypes.ViewGroupUsers)] + [TestCase(PermissionTypes.CanSeePersonnelLocations)] + public async Task everyone_unlocked_sees_every_group(PermissionTypes type) + { + Permit(type, PermissionActions.Everyone, false); + + (await CanView(type, MemberA, TargetB)).Should().BeTrue(); + } + + #endregion + + #region People: department and group admins, locked to group + + [TestCase(PermissionTypes.ViewGroupUsers)] + [TestCase(PermissionTypes.CanSeePersonnelLocations)] + public async Task group_admins_locked_see_their_own_group_but_not_another(PermissionTypes type) + { + Permit(type, PermissionActions.DepartmentAndGroupAdmins, true); + + (await CanView(type, GroupAdminA, TargetA)).Should().BeTrue(); + (await CanView(type, GroupAdminA, TargetB)).Should().BeFalse(); + } + + [TestCase(PermissionTypes.ViewGroupUsers)] + [TestCase(PermissionTypes.CanSeePersonnelLocations)] + public async Task area_supervisors_locked_see_the_groups_beneath_their_area(PermissionTypes type) + { + Permit(type, PermissionActions.DepartmentAndGroupAdmins, true); + + (await CanView(type, AreaSupervisor, TargetA)).Should().BeTrue(); + (await CanView(type, AreaSupervisor, TargetB)).Should().BeFalse(); + } + + [TestCase(PermissionTypes.ViewGroupUsers)] + [TestCase(PermissionTypes.CanSeePersonnelLocations)] + public async Task group_admins_locked_do_not_see_up_into_the_area_above(PermissionTypes type) + { + Permit(type, PermissionActions.DepartmentAndGroupAdmins, true); + + (await CanView(type, GroupAdminA, AreaMember)).Should().BeFalse(); + } + + [TestCase(PermissionTypes.ViewGroupUsers)] + [TestCase(PermissionTypes.CanSeePersonnelLocations)] + public async Task group_admins_locked_excludes_plain_members_of_the_same_group(PermissionTypes type) + { + Permit(type, PermissionActions.DepartmentAndGroupAdmins, true); + + (await CanView(type, MemberA, TargetA)).Should().BeFalse(); + } + + [TestCase(PermissionTypes.ViewGroupUsers)] + [TestCase(PermissionTypes.CanSeePersonnelLocations)] + public async Task group_admins_locked_still_lets_department_admins_see_every_group(PermissionTypes type) + { + Permit(type, PermissionActions.DepartmentAndGroupAdmins, true); + + (await CanView(type, DepartmentAdmin, TargetB)).Should().BeTrue(); + } + + #endregion + + #region People: department admins and select roles, locked to group + + [TestCase(PermissionTypes.ViewGroupUsers)] + [TestCase(PermissionTypes.CanSeePersonnelLocations)] + public async Task select_roles_locked_see_their_own_group_only(PermissionTypes type) + { + Permit(type, PermissionActions.DepartmentAdminsAndSelectRoles, true, SelectedRoleId.ToString()); + + (await CanView(type, RoleHolderA, TargetA)).Should().BeTrue(); + (await CanView(type, RoleHolderA, TargetB)).Should().BeFalse(); + } + + [TestCase(PermissionTypes.ViewGroupUsers)] + [TestCase(PermissionTypes.CanSeePersonnelLocations)] + public async Task select_roles_locked_shares_no_group_with_someone_in_none(PermissionTypes type) + { + Permit(type, PermissionActions.DepartmentAdminsAndSelectRoles, true, SelectedRoleId.ToString()); + + (await CanView(type, RoleHolderNoGroup, TargetA)).Should().BeFalse(); + (await CanView(type, RoleHolderA, TargetNoGroup)).Should().BeFalse(); + } + + [TestCase(PermissionTypes.ViewGroupUsers)] + [TestCase(PermissionTypes.CanSeePersonnelLocations)] + public async Task select_roles_locked_requires_the_role(PermissionTypes type) + { + Permit(type, PermissionActions.DepartmentAdminsAndSelectRoles, true, SelectedRoleId.ToString()); + + (await CanView(type, MemberA, TargetA)).Should().BeFalse(); + } + + [TestCase(PermissionTypes.ViewGroupUsers)] + [TestCase(PermissionTypes.CanSeePersonnelLocations)] + public async Task select_roles_locked_still_lets_department_admins_see_every_group(PermissionTypes type) + { + Permit(type, PermissionActions.DepartmentAdminsAndSelectRoles, true, SelectedRoleId.ToString()); + + (await CanView(type, DepartmentAdmin, TargetB)).Should().BeTrue(); + } + + #endregion + + #region Units and unit locations + + [TestCase(PermissionTypes.ViewGroupUnits)] + [TestCase(PermissionTypes.CanSeeUnitLocations)] + public async Task units_everyone_locked_is_the_units_own_station(PermissionTypes type) + { + Permit(type, PermissionActions.Everyone, true); + + (await CanSeeUnit(type, MemberA, UnitA)).Should().BeTrue(); + (await CanSeeUnit(type, MemberA, UnitB)).Should().BeFalse(); + (await CanSeeUnit(type, MemberA, UnitNoStation)).Should().BeFalse(); + (await CanSeeUnit(type, DepartmentAdmin, UnitB)).Should().BeTrue("department admins always pass, as in the matrices"); + } + + [TestCase(PermissionTypes.ViewGroupUnits)] + [TestCase(PermissionTypes.CanSeeUnitLocations)] + public async Task units_group_admins_locked_include_the_area_supervisor_above_the_station(PermissionTypes type) + { + Permit(type, PermissionActions.DepartmentAndGroupAdmins, true); + + (await CanSeeUnit(type, GroupAdminA, UnitA)).Should().BeTrue(); + (await CanSeeUnit(type, GroupAdminA, UnitB)).Should().BeFalse(); + (await CanSeeUnit(type, AreaSupervisor, UnitA)).Should().BeTrue(); + (await CanSeeUnit(type, AreaSupervisor, UnitB)).Should().BeFalse(); + (await CanSeeUnit(type, AreaSupervisor, UnitNoStation)).Should().BeFalse(); + (await CanSeeUnit(type, MemberA, UnitA)).Should().BeFalse("plain members aren't admins"); + (await CanSeeUnit(type, DepartmentAdmin, UnitB)).Should().BeTrue(); + } + + [TestCase(PermissionTypes.ViewGroupUnits)] + [TestCase(PermissionTypes.CanSeeUnitLocations)] + public async Task units_select_roles_locked_is_the_units_own_station(PermissionTypes type) + { + Permit(type, PermissionActions.DepartmentAdminsAndSelectRoles, true, SelectedRoleId.ToString()); + + (await CanSeeUnit(type, RoleHolderA, UnitA)).Should().BeTrue(); + (await CanSeeUnit(type, RoleHolderA, UnitB)).Should().BeFalse(); + (await CanSeeUnit(type, RoleHolderA, UnitNoStation)).Should().BeFalse(); + (await CanSeeUnit(type, RoleHolderNoGroup, UnitA)).Should().BeFalse(); + (await CanSeeUnit(type, MemberA, UnitA)).Should().BeFalse("the role is still required"); + } + + [TestCase(PermissionTypes.ViewGroupUnits)] + [TestCase(PermissionTypes.CanSeeUnitLocations)] + public async Task units_are_open_to_the_department_when_no_restriction_is_configured(PermissionTypes type) + { + _permission = null; + + (await CanSeeUnit(type, MemberA, UnitB)).Should().BeTrue(); + (await CanSeeUnit(type, NoGroupMember, UnitNoStation)).Should().BeTrue(); + } + + [Test] + public async Task unit_view_never_crosses_departments() + { + _permission = null; + + (await CanSeeUnit(PermissionTypes.ViewGroupUnits, DepartmentAdmin, UnitOtherDepartment)).Should().BeFalse(); + } + + #endregion + } + } +} diff --git a/Tests/Resgrid.Tests/Services/BrokerOperationServiceTests.cs b/Tests/Resgrid.Tests/Services/BrokerOperationServiceTests.cs index 059ffc396..6a2876478 100644 --- a/Tests/Resgrid.Tests/Services/BrokerOperationServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/BrokerOperationServiceTests.cs @@ -89,7 +89,7 @@ public async Task SetUp() _container = builder.Build(); _service = new BrokerOperationService(_container, _grantService, _cryptoService, - _keyWrappingProvider, new MemoryCache(new MemoryCacheOptions())); + _keyWrappingProvider, new MemoryCache(new MemoryCacheOptions()), Mock.Of()); } private string IssueGrantToken(params string[] scopes) diff --git a/Tests/Resgrid.Tests/Services/CommunicationServiceTests.cs b/Tests/Resgrid.Tests/Services/CommunicationServiceTests.cs index 4ab4f5815..ae2fb4c5f 100644 --- a/Tests/Resgrid.Tests/Services/CommunicationServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/CommunicationServiceTests.cs @@ -75,7 +75,7 @@ public void SetUpCommunicationService() _communicationService = new CommunicationService(_smsServiceMock.Object, _emailServiceMock.Object, _pushServiceMock.Object, _geoLocationProviderMock.Object, _outboundVoiceProviderMock.Object, _userProfileServiceMock.Object, _departmentSettingsServiceMock.Object, _subscriptionsServiceMock.Object, _userStateServiceMock.Object, _chatbotOutboundServiceMock.Object, - _departmentsServiceMock.Object, _protectedProjectionServiceMock.Object); + _departmentsServiceMock.Object, _protectedProjectionServiceMock.Object, new System.Lazy(() => Mock.Of())); } } diff --git a/Tests/Resgrid.Tests/Services/PermissionsServiceAllowedUsersTests.cs b/Tests/Resgrid.Tests/Services/PermissionsServiceAllowedUsersTests.cs new file mode 100644 index 000000000..cd8f38ee2 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/PermissionsServiceAllowedUsersTests.cs @@ -0,0 +1,198 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + /// + /// GetAllowedUsersAsync (the user map's personnel-location filter) under every permission action that can + /// be locked to group. Locked means the caller's own group, except that a group admin also reaches the groups + /// beneath theirs (the same reach the visibility matrices give); a caller in no group shares one with nobody. + /// + [TestFixture] + public class PermissionsServiceAllowedUsersTests + { + private const int DepartmentId = 1; + private const int GroupA = 10; + private const int GroupA1 = 11; // beneath group A + private const int GroupB = 20; + private const int SelectedRoleId = 9; + + private static readonly List SelectedRole = new List { new PersonnelRole { PersonnelRoleId = SelectedRoleId } }; + private static readonly List NoRoles = new List(); + + private PermissionsService _service; + + [SetUp] + public void SetUp() + { + var users = new Mock(); + users.Setup(x => x.GetUserGroupAndRolesByDepartmentIdAsync(DepartmentId, true, false, false)).ReturnsAsync(new List + { + new UserGroupRole { UserId = "a1", DepartmentGroupId = GroupA }, + new UserGroupRole { UserId = "a2", DepartmentGroupId = GroupA }, + new UserGroupRole { UserId = "a3", DepartmentGroupId = GroupA1 }, + new UserGroupRole { UserId = "b1", DepartmentGroupId = GroupB }, + new UserGroupRole { UserId = "n1", DepartmentGroupId = null } + }); + + var groups = new Mock(); + groups.Setup(x => x.GetAllGroupsForDepartmentUnlimitedAsync(DepartmentId)).ReturnsAsync(new List + { + new DepartmentGroup { DepartmentGroupId = GroupA, DepartmentId = DepartmentId }, + new DepartmentGroup { DepartmentGroupId = GroupA1, DepartmentId = DepartmentId, ParentDepartmentGroupId = GroupA }, + new DepartmentGroup { DepartmentGroupId = GroupB, DepartmentId = DepartmentId } + }); + + _service = new PermissionsService(new Mock().Object, users.Object, groups.Object); + } + + private static Permission Permission(PermissionActions action, bool lockToGroup, string roleIds = null) + { + return new Permission + { + DepartmentId = DepartmentId, + PermissionType = (int)PermissionTypes.CanSeePersonnelLocations, + Action = (int)action, + LockToGroup = lockToGroup, + Data = roleIds + }; + } + + private Task> Allowed(Permission permission, int? callerGroupId, bool isDepartmentAdmin = false, bool isGroupAdmin = false, List roles = null) + { + return _service.GetAllowedUsersAsync(permission, DepartmentId, callerGroupId, isDepartmentAdmin, isGroupAdmin, roles ?? NoRoles); + } + + #region Department and group admins + + [Test] + public async Task group_admins_locked_get_their_own_group_and_the_groups_beneath_it() + { + // This branch was unreachable (its condition duplicated the department-admin one): group admins got nobody. + (await Allowed(Permission(PermissionActions.DepartmentAndGroupAdmins, true), GroupA, isGroupAdmin: true)) + .Should().BeEquivalentTo("a1", "a2", "a3"); + } + + [Test] + public async Task group_admins_locked_do_not_reach_up_into_the_group_above() + { + (await Allowed(Permission(PermissionActions.DepartmentAndGroupAdmins, true), GroupA1, isGroupAdmin: true)) + .Should().BeEquivalentTo("a3"); + } + + [Test] + public async Task group_admins_unlocked_get_everyone() + { + (await Allowed(Permission(PermissionActions.DepartmentAndGroupAdmins, false), GroupA, isGroupAdmin: true)) + .Should().BeEquivalentTo("a1", "a2", "a3", "b1", "n1"); + } + + [Test] + public async Task group_admins_locked_still_give_department_admins_everyone() + { + (await Allowed(Permission(PermissionActions.DepartmentAndGroupAdmins, true), GroupB, isDepartmentAdmin: true)) + .Should().BeEquivalentTo("a1", "a2", "a3", "b1", "n1"); + } + + [Test] + public async Task group_admins_permission_gives_plain_members_nobody() + { + (await Allowed(Permission(PermissionActions.DepartmentAndGroupAdmins, true), GroupA)).Should().BeEmpty(); + } + + #endregion + + #region Everyone + + [Test] + public async Task everyone_locked_gets_their_own_group() + { + // The lock used to be ignored here: everyone saw every group. + (await Allowed(Permission(PermissionActions.Everyone, true), GroupB)).Should().BeEquivalentTo("b1"); + } + + [Test] + public async Task everyone_locked_does_not_reach_the_groups_beneath() + { + // Only the group-admin rule reaches down, as in the visibility matrices. + (await Allowed(Permission(PermissionActions.Everyone, true), GroupA)).Should().BeEquivalentTo("a1", "a2"); + } + + [Test] + public async Task everyone_locked_in_no_group_gets_nobody() + { + (await Allowed(Permission(PermissionActions.Everyone, true), null)).Should().BeEmpty(); + } + + [Test] + public async Task everyone_locked_still_gives_department_admins_everyone() + { + (await Allowed(Permission(PermissionActions.Everyone, true), GroupA, isDepartmentAdmin: true)) + .Should().BeEquivalentTo("a1", "a2", "a3", "b1", "n1"); + } + + [Test] + public async Task everyone_unlocked_gets_everyone() + { + (await Allowed(Permission(PermissionActions.Everyone, false), GroupA)).Should().BeEquivalentTo("a1", "a2", "a3", "b1", "n1"); + } + + #endregion + + #region Department admins and select roles + + [Test] + public async Task select_roles_locked_get_their_own_group() + { + (await Allowed(Permission(PermissionActions.DepartmentAdminsAndSelectRoles, true, SelectedRoleId.ToString()), GroupB, roles: SelectedRole)) + .Should().BeEquivalentTo("b1"); + } + + [Test] + public async Task select_roles_locked_in_no_group_gets_nobody() + { + // Used to match every other ungrouped member. + (await Allowed(Permission(PermissionActions.DepartmentAdminsAndSelectRoles, true, SelectedRoleId.ToString()), null, roles: SelectedRole)) + .Should().BeEmpty(); + } + + [Test] + public async Task select_roles_without_the_role_gets_nobody() + { + (await Allowed(Permission(PermissionActions.DepartmentAdminsAndSelectRoles, true, SelectedRoleId.ToString()), GroupA)).Should().BeEmpty(); + } + + [Test] + public async Task select_roles_with_no_roles_configured_gets_nobody_instead_of_failing() + { + (await Allowed(Permission(PermissionActions.DepartmentAdminsAndSelectRoles, false, null), GroupA, roles: SelectedRole)).Should().BeEmpty(); + } + + #endregion + + #region Department, group admins and select roles + + [Test] + public async Task group_admins_and_select_roles_locked_in_no_group_gets_nobody() + { + (await Allowed(Permission(PermissionActions.DepartmentAndGroupAdminsAndSelectRoles, true, SelectedRoleId.ToString()), null, roles: SelectedRole)) + .Should().BeEmpty(); + } + + [Test] + public async Task group_admins_and_select_roles_locked_get_their_own_group() + { + (await Allowed(Permission(PermissionActions.DepartmentAndGroupAdminsAndSelectRoles, true, SelectedRoleId.ToString()), GroupA, isGroupAdmin: true)) + .Should().BeEquivalentTo("a1", "a2"); + } + + #endregion + } +} diff --git a/Tests/Resgrid.Tests/Services/ProtectedWorkflows/ProtectedWorkflowEhrTests.cs b/Tests/Resgrid.Tests/Services/ProtectedWorkflows/ProtectedWorkflowEhrTests.cs index 59fd95ea8..f14a5e33a 100644 --- a/Tests/Resgrid.Tests/Services/ProtectedWorkflows/ProtectedWorkflowEhrTests.cs +++ b/Tests/Resgrid.Tests/Services/ProtectedWorkflows/ProtectedWorkflowEhrTests.cs @@ -451,6 +451,48 @@ public async Task only_transport_failures_5xx_and_429_are_retried(int status, st _h.Notifications.Should().NotBeEmpty("a final failure alerts the administrators at once"); } + [Test] + public async Task a_failure_that_cannot_be_retried_stops_the_run_even_when_another_step_could_be() + { + var second = ProtectedWorkflowHarness.Clone(_h.Steps[0]); + second.WorkflowStepId = "step-2"; + second.StepOrder = 2; + _h.Steps.Add(second); + _h.ActivateRelease(); + _h.Capturing.Respond = ctx => new WorkflowActionResult + { + Success = false, + HttpStatus = ctx.WorkflowStepId == "step-1" ? 400 : 503, + ErrorDetail = "detail", + ProtectedOutcome = ProtectedWorkflowDisclosureOutcomes.FailedHttp + }; + + var run = await _h.RunAsync(); + + _h.Capturing.Calls.Should().HaveCount(2); + run.Status.Should().Be((int)WorkflowRunStatus.Failed, "a retry would send the rejected step again"); + run.ErrorMessage.Should().Be($"Not retried: {ProtectedWorkflowErrorCodes.HttpFailed}"); + _h.Notifications.Should().NotBeEmpty(); + } + + [TestCase(1, WorkflowRunStatus.Retrying)] + [TestCase(3, WorkflowRunStatus.Failed)] + public async Task an_unreachable_release_store_retries_without_sending_then_alerts(int attempt, WorkflowRunStatus expected) + { + _h.ActivateRelease(); + _h.FailReleaseLookups = true; + + var run = await _h.RunAsync(attempt: attempt); + + run.Status.Should().Be((int)expected); + run.ErrorMessage.Should().Be("protected_gate_unavailable"); + _h.Capturing.Calls.Should().BeEmpty(); + if (expected == WorkflowRunStatus.Failed) + _h.Notifications.Should().NotBeEmpty("the last attempt alerts the administrators"); + else + _h.Notifications.Should().BeEmpty(); + } + // ── Fingerprint ───────────────────────────────────────────────────────────────────────────── private static readonly (string Name, Func Change)[] ExtraChanges = diff --git a/Tests/Resgrid.Tests/Services/ProtectedWorkflows/ProtectedWorkflowHarness.cs b/Tests/Resgrid.Tests/Services/ProtectedWorkflows/ProtectedWorkflowHarness.cs index 8042f26b6..e879903d1 100644 --- a/Tests/Resgrid.Tests/Services/ProtectedWorkflows/ProtectedWorkflowHarness.cs +++ b/Tests/Resgrid.Tests/Services/ProtectedWorkflows/ProtectedWorkflowHarness.cs @@ -65,6 +65,9 @@ internal sealed class ProtectedWorkflowHarness public int SubjectIdentifierWriteConflicts { get; set; } public bool FailDisclosureAppends { get; set; } + /// The release store is unreachable: the run gate cannot tell whether the workflow is protected. + public bool FailReleaseLookups { get; set; } + /// Runs inside a conditional release write, before the version check: a concurrent writer racing it. public Action BeforeReleaseUpdate { get; set; } public DepartmentDataProtectionPolicy Policy { get; } @@ -437,7 +440,9 @@ private IWorkflowProtectedReleaseRepository ReleaseRepository() { var mock = new Mock(); mock.Setup(r => r.GetLatestByWorkflowIdAsync(It.IsAny())) - .ReturnsAsync((string id) => Clone(Releases.Where(r => r.WorkflowId == id).OrderByDescending(r => r.CreatedOn).FirstOrDefault())); + .ReturnsAsync((string id) => FailReleaseLookups + ? throw new InvalidOperationException("release store unavailable") + : Clone(Releases.Where(r => r.WorkflowId == id).OrderByDescending(r => r.CreatedOn).FirstOrDefault())); mock.Setup(r => r.GetAllByWorkflowIdAsync(It.IsAny())) .ReturnsAsync((string id) => Releases.Where(r => r.WorkflowId == id).Select(Clone).ToList()); mock.Setup(r => r.GetAllByDepartmentIdAsync(It.IsAny())) diff --git a/Tests/Resgrid.Tests/Services/ProtectedWorkflows/WorkflowTemplateFunctionsTests.cs b/Tests/Resgrid.Tests/Services/ProtectedWorkflows/WorkflowTemplateFunctionsTests.cs index ea3e95621..56dbe3b70 100644 --- a/Tests/Resgrid.Tests/Services/ProtectedWorkflows/WorkflowTemplateFunctionsTests.cs +++ b/Tests/Resgrid.Tests/Services/ProtectedWorkflows/WorkflowTemplateFunctionsTests.cs @@ -122,6 +122,11 @@ public void a_protected_value_without_an_escape_helper_is_flagged(string templat [TestCase("application/json", "{\"a\":1}", true, null)] [TestCase("application/json", "{\"a\":1", false, ProtectedPayloadValidator.RuleJsonParse)] [TestCase("application/json", "{\"a\":1} {\"b\":2}", false, ProtectedPayloadValidator.RuleJsonParse)] + [TestCase("application/json", "{\"a\":1 /* note */}", false, ProtectedPayloadValidator.RuleJsonParse)] + [TestCase("application/json", "{\"a\":1} // note", false, ProtectedPayloadValidator.RuleJsonParse)] + [TestCase("application/json", "/* note */ {\"a\":1}", false, ProtectedPayloadValidator.RuleJsonParse)] + [TestCase("application/fhir+json", "{\"resourceType\":\"Bundle\", // note\n\"type\":\"transaction\"}", false, ProtectedPayloadValidator.RuleJsonParse)] + [TestCase("application/json", "", false, ProtectedPayloadValidator.RuleJsonParse)] [TestCase("application/fhir+json", "{\"resourceType\":\"Bundle\"}", true, null)] [TestCase("application/fhir+json", "{\"type\":\"transaction\"}", false, ProtectedPayloadValidator.RuleFhirResourceType)] [TestCase("application/fhir+json", "[]", false, ProtectedPayloadValidator.RuleFhirResourceType)] @@ -171,6 +176,17 @@ public void only_the_allowed_content_types_are_accepted(string contentType, bool ProtectedStepOptions.IsAllowedContentType(contentType).Should().Be(allowed); } + [TestCase("PID-3.1.2", true)] + [TestCase("MSA-2", true)] + [TestCase("PID-٣", false)] + [TestCase("PID-3.١", false)] + public void hl7_field_captures_take_ascii_field_numbers_only(string expression, bool valid) + { + ProtectedStepOptions.Read("{\"responseCapture\":[{\"source\":\"hl7_field\",\"expression\":\"" + expression + "\",\"key\":\"ehr_id\"}]}", out var errors); + + errors.Contains(ProtectedStepOptions.CaptureInvalid).Should().Be(!valid); + } + [Test] public void step_options_are_read_and_validated_from_the_action_config() { diff --git a/Tests/Resgrid.Tests/Services/ShiftManagementScopeAuthorizationTests.cs b/Tests/Resgrid.Tests/Services/ShiftManagementScopeAuthorizationTests.cs index bf79fcb9a..a84dd3ffa 100644 --- a/Tests/Resgrid.Tests/Services/ShiftManagementScopeAuthorizationTests.cs +++ b/Tests/Resgrid.Tests/Services/ShiftManagementScopeAuthorizationTests.cs @@ -76,6 +76,16 @@ public async Task Department_admin_supervises_every_group() scope.AllGroups.Should().BeTrue(); } + [Test] + public async Task Managing_user_without_a_member_record_supervises_every_group() + { + _departmentsService.Setup(x => x.GetDepartmentMemberAsync("owner", DepartmentId, It.IsAny())).ReturnsAsync((DepartmentMember)null); + + var scope = await _service.GetShiftManagementScopeAsync("owner", DepartmentId); + + scope.AllGroups.Should().BeTrue(); + } + [Test] public async Task Group_admin_supervises_their_group_and_its_child_teams_only() { diff --git a/Tests/Resgrid.Tests/Services/ShiftRosterBuilderTests.cs b/Tests/Resgrid.Tests/Services/ShiftRosterBuilderTests.cs index ea490001a..01cbea358 100644 --- a/Tests/Resgrid.Tests/Services/ShiftRosterBuilderTests.cs +++ b/Tests/Resgrid.Tests/Services/ShiftRosterBuilderTests.cs @@ -127,6 +127,30 @@ public void Build_shows_a_standing_roster_persons_own_day_slot_once_as_assigned( roster[0].ShiftSignupId.Should().Be(5); } + [Test] + public void Build_keeps_a_standing_roster_person_in_their_group_when_they_sign_up_for_another() + { + var shift = MakeShift(new ShiftPerson { UserId = "alice", GroupId = CrisisTeam }); + + var roster = ShiftRosterBuilder.Build(shift, Day1, new[] { Signup(5, "ALICE", Day1, PeerTeam) }, null); + + roster.Should().ContainSingle(x => x.UserId == "alice" && x.DepartmentGroupId == CrisisTeam && x.Source == ShiftRosterSources.Assigned && x.IsOnDuty()); + roster.Should().ContainSingle(x => x.DepartmentGroupId == PeerTeam && x.ShiftSignupId == 5); + } + + [Test] + public void Build_keeps_a_standing_roster_person_on_duty_while_a_signup_waits_for_approval() + { + var shift = MakeShift(new ShiftPerson { UserId = "alice", GroupId = CrisisTeam }); + var pending = Signup(5, "alice", Day1, PeerTeam); + pending.ApprovalPending = true; + + var roster = ShiftRosterBuilder.Build(shift, Day1, new[] { pending }, null); + + roster.Should().ContainSingle(x => x.DepartmentGroupId == CrisisTeam && x.IsOnDuty()); + roster.Should().ContainSingle(x => x.DepartmentGroupId == PeerTeam && x.ApprovalPending); + } + [Test] public void Build_gives_the_slot_to_the_taker_of_a_completed_give_away_trade() { diff --git a/Tests/Resgrid.Tests/Services/ShiftsServiceSchedulingTests.cs b/Tests/Resgrid.Tests/Services/ShiftsServiceSchedulingTests.cs index 39e9c4260..e6879a5e5 100644 --- a/Tests/Resgrid.Tests/Services/ShiftsServiceSchedulingTests.cs +++ b/Tests/Resgrid.Tests/Services/ShiftsServiceSchedulingTests.cs @@ -343,6 +343,21 @@ public async Task Finishing_a_trade_only_accepts_someone_who_offered() (await _service.FinishTradeAsync(50, "mallory", "bob", null)).Error.Should().Be(ShiftActionErrors.NotAllowed); } + [Test] + public async Task Answering_a_trade_matches_the_participant_whatever_the_user_id_case() + { + _signups.Add(new ShiftSignup { ShiftSignupId = 5, ShiftId = ShiftId, UserId = "alice", ShiftDay = _day.Day }); + var bob = new ShiftSignupTradeUser { ShiftSignupTradeUserId = 3, ShiftSignupTradeId = 50, UserId = "bob" }; + SetupTrade(new ShiftSignupTrade { ShiftSignupTradeId = 50, SourceShiftSignupId = 5 }, bob); + + var result = await _service.RespondToTradeAsync(50, "BOB", false, "busy", null); + + result.Success.Should().BeTrue(); + bob.Declined.Should().BeTrue(); + _shiftSignupTradeUserRepository.Verify(x => x.SaveOrUpdateAsync(bob, It.IsAny(), true), Times.Once); + _eventAggregator.Verify(x => x.SendMessage(It.IsAny()), Times.Once); + } + [Test] public async Task Approving_a_pending_trade_makes_it_take_effect() { diff --git a/Tests/Resgrid.Tests/Services/SmsServiceNumberFormatTests.cs b/Tests/Resgrid.Tests/Services/SmsServiceNumberFormatTests.cs index 4e18ce967..344d9e3f5 100644 --- a/Tests/Resgrid.Tests/Services/SmsServiceNumberFormatTests.cs +++ b/Tests/Resgrid.Tests/Services/SmsServiceNumberFormatTests.cs @@ -49,6 +49,27 @@ public void SetUp() new PhoneNumberProcesserProvider()); } + [Test] + public async Task Pin_challenge_uses_dispatch_preferences_and_a_reply_capable_direct_number() + { + var profile = Profile("+12705550101", MobileCarriers.None); + profile.SendSms = true; + profile.SendNotificationSms = false; + profile.MobileNumberVerified = true; + _textMessageProvider.Setup(p => p.SendTextMessage(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), false, false, 0)).ReturnsAsync(true); + (await _service.SendProtectedDispatchChallengeAsync(profile, 7, "+15555550100", "OPEN challenge" )).Should().BeTrue(); + _textMessageProvider.Verify(p => p.SendTextMessage("+12705550101", It.Is(s => s.Contains("OPEN challenge")), + "+15555550100", MobileCarriers.None, 7, false, false, 0), Times.Once); + profile.SendSms = false; + (await _service.SendProtectedDispatchChallengeAsync(profile, 7, "+15555550100", "OPEN challenge")).Should().BeFalse(); + profile.SendSms = true; + profile.MobileNumberVerified = false; + (await _service.SendProtectedDispatchChallengeAsync(profile, 7, "+15555550100", "OPEN challenge")).Should().BeFalse(); + _textMessageProvider.Verify(p => p.SendTextMessage(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + private static UserProfile Profile(string mobileNumber, MobileCarriers carrier) => new UserProfile { UserId = "user-1", diff --git a/Tests/Resgrid.Tests/Web/ForwardedHeadersSetupTests.cs b/Tests/Resgrid.Tests/Web/ForwardedHeadersSetupTests.cs new file mode 100644 index 000000000..905e5dbd1 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/ForwardedHeadersSetupTests.cs @@ -0,0 +1,108 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.Extensions.Logging.Abstractions; +using NUnit.Framework; +using Resgrid.Web.Helpers; + +namespace Resgrid.Tests.Web +{ + /// + /// Runs the real ForwardedHeadersMiddleware with the shared configuration: only a peer inside the ingress proxy + /// network may replace the caller's address with X-Forwarded-For. The IPv4-mapped cases are the ones Kestrel produces + /// for IPv4 peers on a dual-stack socket, and the ones the previous "::ffff:network / IPv4 prefix" setup got wrong by + /// trusting every IPv4 client. + /// + [TestFixture] + public sealed class ForwardedHeadersSetupTests + { + private const string ForwardedClient = "198.51.100.9"; + + [TestCase("10.42.3.4")] + [TestCase("::ffff:10.42.3.4")] + public async Task Proxy_InTheIngressNetwork_ShouldSetTheCallersAddress(string peer) + { + var remoteAddress = await ResolveRemoteAddress("10.42.0.0", 16, peer); + + Assert.That(remoteAddress, Is.EqualTo(IPAddress.Parse(ForwardedClient))); + } + + [TestCase("203.0.113.5")] + [TestCase("::ffff:203.0.113.5")] + [TestCase("10.43.0.1")] + [TestCase("::ffff:10.43.0.1")] + public async Task Client_OutsideTheIngressNetwork_ShouldNotBeAbleToSpoofItsAddress(string peer) + { + var remoteAddress = await ResolveRemoteAddress("10.42.0.0", 16, peer); + + Assert.That(remoteAddress, Is.EqualTo(IPAddress.Parse(peer))); + } + + /// + /// The same boundary checked on the KnownNetworks entries directly. .NET 9's middleware (production) matches a + /// mapped peer against these Microsoft.AspNetCore.HttpOverrides.IPNetwork entries, which count ::ffff:203.0.113.5 as + /// inside ::ffff:10.42.0.0/16. .NET 10's middleware matches System.Net.IPNetwork, which does not, so on a .NET 10 + /// test runtime the middleware tests above cannot catch that mistake and this test must. + /// + [TestCase("10.42.3.4", true)] + [TestCase("::ffff:10.42.3.4", true)] + [TestCase("203.0.113.5", false)] + [TestCase("::ffff:203.0.113.5", false)] + public void KnownNetworks_ShouldContainOnlyTheIngressProxyNetwork(string peer, bool trusted) + { + var options = new ForwardedHeadersOptions(); + + ForwardedHeadersSetup.Configure(options, "10.42.0.0", 16); + + Assert.That(options.KnownNetworks.Any(x => x.Contains(IPAddress.Parse(peer))), Is.EqualTo(trusted)); + } + + [TestCase("::ffff:10.42.0.0", 16)] + [TestCase("::ffff:10.42.0.0", 112)] + public async Task Network_ConfiguredInMappedForm_ShouldTrustOnlyThatNetwork(string network, int cidr) + { + Assert.That(await ResolveRemoteAddress(network, cidr, "::ffff:10.42.3.4"), Is.EqualTo(IPAddress.Parse(ForwardedClient))); + Assert.That(await ResolveRemoteAddress(network, cidr, "::ffff:203.0.113.5"), Is.EqualTo(IPAddress.Parse("::ffff:203.0.113.5"))); + } + + [TestCase("", 16)] + [TestCase("not-an-address", 16)] + [TestCase("10.42.0.0", 33)] + [TestCase("10.42.0.0", -1)] + public async Task Network_ThatDoesNotParse_ShouldTrustNoProxy(string network, int cidr) + { + var remoteAddress = await ResolveRemoteAddress(network, cidr, "10.42.3.4"); + + Assert.That(remoteAddress, Is.EqualTo(IPAddress.Parse("10.42.3.4"))); + } + + [Test] + public void Configure_ShouldHonourForwardedForAndProto() + { + var options = new ForwardedHeadersOptions(); + + ForwardedHeadersSetup.Configure(options, "10.42.0.0", 16); + + Assert.That(options.ForwardedHeaders, Is.EqualTo(ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto)); + } + + private static async Task ResolveRemoteAddress(string proxyNetwork, int proxyNetworkCidr, string peer) + { + var options = new ForwardedHeadersOptions(); + ForwardedHeadersSetup.Configure(options, proxyNetwork, proxyNetworkCidr); + + var context = new DefaultHttpContext(); + context.Connection.RemoteIpAddress = IPAddress.Parse(peer); + context.Request.Headers["X-Forwarded-For"] = ForwardedClient; + + var middleware = new ForwardedHeadersMiddleware(_ => Task.CompletedTask, NullLoggerFactory.Instance, + Microsoft.Extensions.Options.Options.Create(options)); + await middleware.Invoke(context); + + return context.Connection.RemoteIpAddress; + } + } +} diff --git a/Tests/Resgrid.Tests/Web/Mcp/McpRateLimitTests.cs b/Tests/Resgrid.Tests/Web/Mcp/McpRateLimitTests.cs new file mode 100644 index 000000000..7633343c7 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/Mcp/McpRateLimitTests.cs @@ -0,0 +1,115 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Resgrid.Config; +using Resgrid.Web.Mcp.Infrastructure; +using Resgrid.Web.Mcp.ModelContextProtocol; + +namespace Resgrid.Tests.Web.Mcp +{ + /// + /// Tool calls are limited per signed-in session (access token), and calls made without a token (authenticate, + /// refresh_access_token) per client address, more tightly. + /// + [TestFixture] + public sealed class McpRateLimitTests + { + [Test] + public async Task RateLimiter_ShouldRejectRequestsOverTheLimit() + { + using var limiter = new RateLimiter(NullLogger.Instance); + + for (var i = 0; i < 3; i++) + Assert.That(await limiter.IsAllowedAsync("client-a", "tools/call", 3), Is.True); + + Assert.That(await limiter.IsAllowedAsync("client-a", "tools/call", 3), Is.False); + Assert.That(await limiter.IsAllowedAsync("client-b", "tools/call", 3), Is.True, "Each client has its own window"); + } + + [Test] + public async Task ToolCalls_ShouldBeLimitedPerAccessToken() + { + using var limiter = new RateLimiter(NullLogger.Instance); + var handlerCalls = 0; + var server = CreateServer(limiter, () => handlerCalls++); + + for (var i = 0; i < McpConfig.ToolCallsPerMinute; i++) + Assert.That((await CallTool(server, "access-a", "203.0.113.5")).Value("isError"), Is.False); + + var limited = await CallTool(server, "access-a", "203.0.113.5"); + + Assert.That(limited.Value("isError"), Is.True); + Assert.That(ToolResult(limited).Value("errorCode"), Is.EqualTo(McpToolErrorException.RateLimited)); + Assert.That(handlerCalls, Is.EqualTo(McpConfig.ToolCallsPerMinute), "A rate limited call must not reach the tool"); + + var otherSession = await CallTool(server, "access-b", "203.0.113.5"); + + Assert.That(otherSession.Value("isError"), Is.False, "Sessions sharing an address must not share a limit"); + } + + [Test] + public async Task CallsWithoutAToken_ShouldBeLimitedPerClientAddress() + { + using var limiter = new RateLimiter(NullLogger.Instance); + var server = CreateServer(limiter, () => { }); + + for (var i = 0; i < McpConfig.UnauthenticatedCallsPerMinute; i++) + Assert.That((await CallTool(server, null, "203.0.113.5")).Value("isError"), Is.False); + + var limited = await CallTool(server, null, "203.0.113.5"); + var otherAddress = await CallTool(server, null, "198.51.100.7"); + + Assert.That(ToolResult(limited).Value("errorCode"), Is.EqualTo(McpToolErrorException.RateLimited)); + Assert.That(otherAddress.Value("isError"), Is.False); + } + + [Test] + public async Task RateLimiter_ShouldNeverReceiveTheRawAccessToken() + { + var clientIds = new List(); + var limiter = new Mock(); + limiter.Setup(x => x.IsAllowedAsync(Capture.In(clientIds), It.IsAny(), It.IsAny())).ReturnsAsync(true); + var server = CreateServer(limiter.Object, () => { }); + + await CallTool(server, "secret-access-token", "203.0.113.5"); + + Assert.That(clientIds.Single(), Does.StartWith("token:").And.Not.Contain("secret-access-token")); + } + + private static McpServer CreateServer(IRateLimiter limiter, System.Action onCall) + { + var server = new McpServer("test", "1.0.0", rateLimiter: limiter); + server.AddTool("test_tool", "test tool", new Dictionary(), _ => + { + onCall(); + return Task.FromResult(new { success = true }); + }); + return server; + } + + private static async Task CallTool(McpServer server, string accessToken, string clientAddress) + { + var arguments = new JObject(); + if (accessToken != null) + arguments["accessToken"] = accessToken; + + var request = new JObject + { + ["jsonrpc"] = "2.0", + ["id"] = 1, + ["method"] = "tools/call", + ["params"] = new JObject { ["name"] = "test_tool", ["arguments"] = arguments } + }; + + var response = await server.HandleRequestAsync(request.ToString(), clientAddress, CancellationToken.None); + return (JObject)JObject.Parse(response)["result"]; + } + + private static JObject ToolResult(JObject result) => JObject.Parse(result["content"][0].Value("text")); + } +} diff --git a/Tests/Resgrid.Tests/Web/Mcp/McpServerToolResultTests.cs b/Tests/Resgrid.Tests/Web/Mcp/McpServerToolResultTests.cs index b178242cd..4b97cad58 100644 --- a/Tests/Resgrid.Tests/Web/Mcp/McpServerToolResultTests.cs +++ b/Tests/Resgrid.Tests/Web/Mcp/McpServerToolResultTests.cs @@ -1,3 +1,5 @@ +using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json; @@ -8,8 +10,8 @@ namespace Resgrid.Tests.Web.Mcp { /// - /// Tool handlers return the Newtonsoft JObject payloads that ApiClient deserializes from the v4 API. The server - /// must write them out as JSON, not as the nested empty arrays System.Text.Json produces for a JToken. + /// How the server wraps a tool's return value in the MCP tools/call result: the payload as JSON text, and isError + /// set when the tool reports a failure. /// [TestFixture] public sealed class McpServerToolResultTests @@ -17,24 +19,58 @@ public sealed class McpServerToolResultTests [Test] public async Task ToolsCall_ShouldSerializeJObjectPayloadAsJson() { - // Arrange + // Arrange: tool handlers return the Newtonsoft JObject payloads that ApiClient deserializes from the v4 API, + // which must come out as JSON, not the nested empty arrays System.Text.Json produces for a JToken. var apiResponse = JsonConvert.DeserializeObject(@"{""Data"":[{""CallId"":""42"",""Name"":""Structure fire""}],""Status"":""success""}"); - var server = new McpServer("test", "1.0.0"); - server.AddTool("get_active_calls", "test tool", new System.Collections.Generic.Dictionary(), - _ => Task.FromResult(new { success = true, data = apiResponse })); // Act - var responseJson = await server.HandleRequestAsync( - @"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/call"",""params"":{""name"":""get_active_calls"",""arguments"":{}}}", - CancellationToken.None); + var result = await CallTool(_ => Task.FromResult(new { success = true, data = apiResponse })); // Assert - var text = JObject.Parse(responseJson)["result"]["content"][0].Value("text"); - var toolResult = JObject.Parse(text); + var toolResult = JObject.Parse(result["content"][0].Value("text")); Assert.That(toolResult.Value("success"), Is.True); Assert.That(toolResult["data"]["Status"].Value(), Is.EqualTo("success")); Assert.That(toolResult["data"]["Data"][0]["Name"].Value(), Is.EqualTo("Structure fire")); + Assert.That(result.Value("isError"), Is.False); + } + + [Test] + public async Task ToolsCall_ShouldSetIsErrorWhenTheToolReportsFailure() + { + var result = await CallTool(_ => Task.FromResult(new { success = false, error = "Valid call ID is required" })); + + Assert.That(result.Value("isError"), Is.True); + Assert.That(JObject.Parse(result["content"][0].Value("text")).Value("error"), Is.EqualTo("Valid call ID is required")); + } + + [Test] + public async Task ToolsCall_ShouldSetIsErrorForAnMcpToolError() + { + var result = await CallTool(_ => throw new McpToolErrorException(McpToolErrorException.AccessTokenExpired, "Refresh the token")); + + Assert.That(result.Value("isError"), Is.True); + Assert.That(JObject.Parse(result["content"][0].Value("text")).Value("errorCode"), Is.EqualTo(McpToolErrorException.AccessTokenExpired)); + } + + [Test] + public async Task ToolsCall_ShouldNotSetIsErrorWhenTheResultHasNoSuccessFlag() + { + var result = await CallTool(_ => Task.FromResult(new JArray(new JObject { ["success"] = false }))); + + Assert.That(result.Value("isError"), Is.False); + } + + private static async Task CallTool(Func> handler) + { + var server = new McpServer("test", "1.0.0"); + server.AddTool("test_tool", "test tool", new Dictionary(), handler); + + var responseJson = await server.HandleRequestAsync( + @"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/call"",""params"":{""name"":""test_tool"",""arguments"":{}}}", + CancellationToken.None); + + return (JObject)JObject.Parse(responseJson)["result"]; } } } diff --git a/Tests/Resgrid.Tests/Web/Mcp/McpToolErrorTests.cs b/Tests/Resgrid.Tests/Web/Mcp/McpToolErrorTests.cs new file mode 100644 index 000000000..7e163a970 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/Mcp/McpToolErrorTests.cs @@ -0,0 +1,178 @@ +using System; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Resgrid.Web.Mcp; +using Resgrid.Web.Mcp.ModelContextProtocol; +using Resgrid.Web.Mcp.Tools; + +namespace Resgrid.Tests.Web.Mcp +{ + /// + /// A tool called with an expired access token must say so, with a code the client can act on, rather than the + /// generic "try again later" that would have it retry the same dead token. + /// + [TestFixture] + public sealed class McpToolErrorTests + { + // OpenIddict validation's challenge for an expired token, and SessionValidationMiddleware's for a revoked session. + private const string ExpiredTokenChallenge = + @"Bearer error=""invalid_token"", error_description=""The specified token is no longer valid."", error_uri=""https://documentation.openiddict.com/errors/ID2019"""; + private const string RevokedSessionChallenge = @"Bearer error=""invalid_token"""; + + [TestCase(ExpiredTokenChallenge)] + [TestCase(RevokedSessionChallenge)] + public void ApiClient_ShouldReportARejectedTokenAsExpired(string challenge) + { + var apiClient = CreateApiClient(_ => Unauthorized(challenge)); + + var error = Assert.ThrowsAsync(() => apiClient.GetAsync(V4Routes.Get.ActiveCalls, "access-1")); + + Assert.That(error.ErrorCode, Is.EqualTo(McpToolErrorException.AccessTokenExpired)); + Assert.That(error.Message, Does.Contain("refresh_access_token")); + } + + [Test] + public void ApiClient_ShouldReportARejectedTokenOnDeleteAsExpired() + { + var apiClient = CreateApiClient(_ => Unauthorized(ExpiredTokenChallenge)); + + var error = Assert.ThrowsAsync(() => apiClient.DeleteAsync($"{V4Routes.Delete.Message}?messageId=1", "access-1")); + + Assert.That(error.ErrorCode, Is.EqualTo(McpToolErrorException.AccessTokenExpired)); + } + + [Test] + public void ApiClient_ShouldReportAnActionLevelUnauthorizedAsForbidden() + { + // v4 actions return a bare Unauthorized() (no challenge) when the user may not touch the record. + var apiClient = CreateApiClient(_ => Unauthorized(null)); + + var error = Assert.ThrowsAsync(() => apiClient.GetAsync(V4Routes.Get.ActiveCalls, "access-1")); + + Assert.That(error.ErrorCode, Is.EqualTo(McpToolErrorException.Forbidden)); + } + + [Test] + public void ApiClient_ShouldReportAPolicyRefusalAsForbidden() + { + var apiClient = CreateApiClient(_ => new HttpResponseMessage(HttpStatusCode.Forbidden)); + + var error = Assert.ThrowsAsync(() => apiClient.GetAsync(V4Routes.Get.ActiveCalls, "access-1")); + + Assert.That(error.ErrorCode, Is.EqualTo(McpToolErrorException.Forbidden)); + } + + [Test] + public void ApiClient_ShouldLeaveOtherFailuresAsHttpErrors() + { + var apiClient = CreateApiClient(_ => new HttpResponseMessage(HttpStatusCode.InternalServerError)); + + Assert.ThrowsAsync(() => apiClient.GetAsync(V4Routes.Get.ActiveCalls, "access-1")); + } + + [Test] + public async Task Tool_ShouldReturnAccessTokenExpiredWhenTheApiRejectsTheToken() + { + var server = new McpServer("test", "1.0.0"); + new CallsToolProvider(CreateApiClient(_ => Unauthorized(ExpiredTokenChallenge)), NullLogger.Instance) + .RegisterTools(server); + + var result = await CallTool(server, "get_active_calls", new JObject { ["accessToken"] = "access-1" }); + + Assert.That(result.Value("success"), Is.False); + Assert.That(result.Value("errorCode"), Is.EqualTo(McpToolErrorException.AccessTokenExpired)); + Assert.That(result.Value("error"), Does.Contain("refresh_access_token")); + } + + [Test] + public async Task Tool_ShouldReturnForbiddenWhenTheUserIsNotPermitted() + { + var server = new McpServer("test", "1.0.0"); + new PersonnelToolProvider(CreateApiClient(_ => Unauthorized(null)), NullLogger.Instance) + .RegisterTools(server); + + var result = await CallTool(server, "set_personnel_status", + new JObject { ["accessToken"] = "access-1", ["userId"] = "someone-else", ["statusType"] = 2 }); + + Assert.That(result.Value("success"), Is.False); + Assert.That(result.Value("errorCode"), Is.EqualTo(McpToolErrorException.Forbidden)); + } + + [Test] + public void ToolCatchAlls_ShouldLetMcpToolErrorsReachTheServer() + { + var root = RepositoryRoot(); + if (root == null) + Assert.Ignore("Resgrid.sln not found above the test directory; the MCP source is not available to scan."); + + var toolsDirectory = Path.Combine(root, "Web", "Resgrid.Web.Mcp", "Tools"); + var offenders = Directory.EnumerateFiles(toolsDirectory, "*.cs") + .SelectMany(file => File.ReadLines(file) + .Select((line, index) => (line, index)) + .Where(x => x.line.Contains("catch (Exception") && !x.line.Contains("when (ex is not McpToolErrorException)")) + .Select(x => $"{Path.GetRelativePath(root, file)}:{x.index + 1}: {x.line.Trim()}")) + .ToList(); + + Assert.That(offenders, Is.Empty, + "A tool's catch-all must not swallow McpToolErrorException: add 'when (ex is not McpToolErrorException)'"); + } + + private static async Task CallTool(McpServer server, string toolName, JObject arguments) + { + var request = new JObject + { + ["jsonrpc"] = "2.0", + ["id"] = 1, + ["method"] = "tools/call", + ["params"] = new JObject { ["name"] = toolName, ["arguments"] = arguments } + }; + + var response = JObject.Parse(await server.HandleRequestAsync(request.ToString(), CancellationToken.None)); + Assert.That(response["error"]?.Type ?? JTokenType.Null, Is.EqualTo(JTokenType.Null), "Tool errors must come back as a result, not a JSON-RPC error"); + Assert.That(response["result"].Value("isError"), Is.True, "A failed tool call must be flagged with isError"); + + return JObject.Parse(response["result"]["content"][0].Value("text")); + } + + private static HttpResponseMessage Unauthorized(string challenge) + { + var response = new HttpResponseMessage(HttpStatusCode.Unauthorized); + if (challenge != null) + response.Headers.TryAddWithoutValidation("WWW-Authenticate", challenge); + return response; + } + + private static ApiClient CreateApiClient(Func respond) + { + var factory = new Mock(); + factory.Setup(x => x.CreateClient("ResgridApi")) + .Returns(() => new HttpClient(new StubHandler(respond)) { BaseAddress = new Uri("https://api.example.test/") }); + + return new ApiClient(factory.Object, NullLogger.Instance); + } + + private static string RepositoryRoot() + { + var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); + while (directory != null && !File.Exists(Path.Combine(directory.FullName, "Resgrid.sln"))) + directory = directory.Parent; + return directory?.FullName; + } + + private sealed class StubHandler : HttpMessageHandler + { + private readonly Func _respond; + public StubHandler(Func respond) { _respond = respond; } + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => Task.FromResult(_respond(request)); + } + } +} diff --git a/Tests/Resgrid.Tests/Web/Mcp/SensitiveDataRedactorTests.cs b/Tests/Resgrid.Tests/Web/Mcp/SensitiveDataRedactorTests.cs index 6a8b12da6..baeea300b 100644 --- a/Tests/Resgrid.Tests/Web/Mcp/SensitiveDataRedactorTests.cs +++ b/Tests/Resgrid.Tests/Web/Mcp/SensitiveDataRedactorTests.cs @@ -202,6 +202,30 @@ public void RedactSensitiveFields_ShouldHandleCaseInsensitiveFieldNames() Assert.That(redacted, Does.Not.Contain("token123"), "Should not contain token value"); Assert.That(redacted, Does.Not.Contain("key456"), "Should not contain API key value"); } + + [Test] + public void RedactSensitiveFields_ShouldRedactTokensInsideToolResultText() + { + // Arrange: tool results are serialized JSON inside the JSON-RPC response's content[].text string + var toolResult = @"{""success"":true,""accessToken"":""access-abc"",""refreshToken"":""refresh-def"",""expiresIn"":86400}"; + var response = new System.Text.Json.Nodes.JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 1, + ["result"] = new System.Text.Json.Nodes.JsonObject + { + ["content"] = new System.Text.Json.Nodes.JsonArray(new System.Text.Json.Nodes.JsonObject { ["type"] = "text", ["text"] = toolResult }) + } + }.ToJsonString(); + + // Act + var redacted = SensitiveDataRedactor.RedactSensitiveFields(response); + + // Assert + Assert.That(redacted, Does.Not.Contain("access-abc"), "Should not contain the access token from the tool result"); + Assert.That(redacted, Does.Not.Contain("refresh-def"), "Should not contain the refresh token from the tool result"); + Assert.That(redacted, Does.Contain("86400"), "Should preserve non-sensitive tool result fields"); + } } } diff --git a/Tests/Resgrid.Tests/Web/Mcp/TokenRefreshTests.cs b/Tests/Resgrid.Tests/Web/Mcp/TokenRefreshTests.cs new file mode 100644 index 000000000..25fd416ed --- /dev/null +++ b/Tests/Resgrid.Tests/Web/Mcp/TokenRefreshTests.cs @@ -0,0 +1,224 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Resgrid.Web.Mcp; +using Resgrid.Web.Mcp.Infrastructure; +using Resgrid.Web.Mcp.ModelContextProtocol; +using Resgrid.Web.Mcp.Tools; + +namespace Resgrid.Tests.Web.Mcp +{ + /// + /// The MCP client signs in once, then keeps its session alive by exchanging single-use refresh tokens through the + /// refresh_access_token tool. + /// + [TestFixture] + public sealed class TokenRefreshTests + { + private const string TokenPairJson = + @"{""access_token"":""access-2"",""token_type"":""Bearer"",""expires_in"":86400,""refresh_token"":""refresh-2""}"; + + [Test] + public async Task Authenticate_ShouldRequestOfflineAccessAndReturnTheRefreshToken() + { + string body = null; + var apiClient = CreateApiClient(request => + { + body = request.Content.ReadAsStringAsync().Result; + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TokenPairJson) }; + }); + + var result = await apiClient.AuthenticateAsync("jane", "secret"); + + Assert.That(FormValue(body, "grant_type"), Is.EqualTo("password")); + Assert.That(FormValue(body, "scope").Split(' '), Does.Contain("offline_access")); + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.RefreshToken, Is.EqualTo("refresh-2")); + } + + [Test] + public async Task RefreshToken_ShouldPostTheRefreshGrantToTheTokenEndpoint() + { + HttpRequestMessage sent = null; + string body = null; + var apiClient = CreateApiClient(request => + { + sent = request; + body = request.Content.ReadAsStringAsync().Result; + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TokenPairJson) }; + }); + + var result = await apiClient.RefreshTokenAsync("refresh-1"); + + Assert.That(sent.Method, Is.EqualTo(HttpMethod.Post)); + Assert.That(sent.RequestUri.AbsolutePath, Is.EqualTo(V4Routes.Post.Token)); + Assert.That(FormValue(body, "grant_type"), Is.EqualTo("refresh_token")); + Assert.That(FormValue(body, "refresh_token"), Is.EqualTo("refresh-1")); + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.AccessToken, Is.EqualTo("access-2")); + Assert.That(result.RefreshToken, Is.EqualTo("refresh-2")); + Assert.That(result.ExpiresIn, Is.EqualTo(86400)); + } + + [Test] + public async Task RefreshToken_ShouldReportTheServersReasonWhenTheGrantIsRefused() + { + var apiClient = CreateApiClient(_ => new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent(@"{""error"":""invalid_grant"",""error_description"":""The refresh token is no longer valid.""}") + }); + + var result = await apiClient.RefreshTokenAsync("refresh-1"); + + Assert.That(result.IsSuccess, Is.False); + Assert.That(result.ErrorMessage, Is.EqualTo("The refresh token is no longer valid.")); + } + + [Test] + public async Task Refresh_ShouldShareOneExchangeBetweenCallersPresentingTheSameToken() + { + var exchange = new TaskCompletionSource(); + var apiClient = new Mock(); + apiClient.Setup(x => x.RefreshTokenAsync("refresh-1", It.IsAny())).Returns(exchange.Task); + var service = new TokenRefreshService(apiClient.Object, NullLogger.Instance); + + var first = service.RefreshAsync("refresh-1"); + var second = service.RefreshAsync("refresh-1"); + exchange.SetResult(new AuthenticationResult { IsSuccess = true, AccessToken = "access-2", RefreshToken = "refresh-2" }); + + Assert.That((await first).RefreshToken, Is.EqualTo("refresh-2")); + Assert.That((await second).RefreshToken, Is.EqualTo("refresh-2")); + apiClient.Verify(x => x.RefreshTokenAsync("refresh-1", It.IsAny()), Times.Once); + } + + [Test] + public async Task Refresh_ShouldNotReuseACompletedExchange() + { + var apiClient = new Mock(); + apiClient.Setup(x => x.RefreshTokenAsync("refresh-1", It.IsAny())) + .ReturnsAsync(new AuthenticationResult { IsSuccess = true, AccessToken = "access-2", RefreshToken = "refresh-2" }); + var service = new TokenRefreshService(apiClient.Object, NullLogger.Instance); + + await service.RefreshAsync("refresh-1"); + await service.RefreshAsync("refresh-1"); + + // A redeemed token goes back to the API, which decides whether it is still inside the reuse window. + apiClient.Verify(x => x.RefreshTokenAsync("refresh-1", It.IsAny()), Times.Exactly(2)); + } + + [Test] + public async Task Refresh_ShouldKeepTheSharedExchangeRunningWhenOneCallerCancels() + { + var exchange = new TaskCompletionSource(); + var apiClient = new Mock(); + apiClient.Setup(x => x.RefreshTokenAsync("refresh-1", It.IsAny())).Returns(exchange.Task); + var service = new TokenRefreshService(apiClient.Object, NullLogger.Instance); + using var cancellation = new CancellationTokenSource(); + + var abandoned = service.RefreshAsync("refresh-1", cancellation.Token); + var waiting = service.RefreshAsync("refresh-1"); + cancellation.Cancel(); + exchange.SetResult(new AuthenticationResult { IsSuccess = true, RefreshToken = "refresh-2" }); + + Assert.That(async () => await abandoned, Throws.InstanceOf()); + Assert.That((await waiting).RefreshToken, Is.EqualTo("refresh-2")); + } + + [Test] + public async Task Refresh_ShouldRejectABlankTokenWithoutCallingTheApi() + { + var apiClient = new Mock(); + var service = new TokenRefreshService(apiClient.Object, NullLogger.Instance); + + var result = await service.RefreshAsync(" "); + + Assert.That(result.IsSuccess, Is.False); + apiClient.Verify(x => x.RefreshTokenAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task RefreshAccessTokenTool_ShouldReturnTheNewPair() + { + var refresh = new Mock(); + refresh.Setup(x => x.RefreshAsync("refresh-1", It.IsAny())) + .ReturnsAsync(new AuthenticationResult { IsSuccess = true, AccessToken = "access-2", TokenType = "Bearer", ExpiresIn = 86400, RefreshToken = "refresh-2" }); + + var result = await CallRefreshTool(refresh.Object, "refresh-1"); + + Assert.That(result.Value("success"), Is.True); + Assert.That(result.Value("accessToken"), Is.EqualTo("access-2")); + Assert.That(result.Value("refreshToken"), Is.EqualTo("refresh-2")); + Assert.That(result.Value("expiresIn"), Is.EqualTo(86400)); + } + + [Test] + public async Task RefreshAccessTokenTool_ShouldSendTheCallerBackToAuthenticateWhenRefreshFails() + { + var refresh = new Mock(); + refresh.Setup(x => x.RefreshAsync("refresh-1", It.IsAny())) + .ReturnsAsync(new AuthenticationResult { IsSuccess = false, ErrorMessage = "The refresh token is no longer valid." }); + + var result = await CallRefreshTool(refresh.Object, "refresh-1"); + + Assert.That(result.Value("success"), Is.False); + Assert.That(result.Value("error"), Is.EqualTo("The refresh token is no longer valid. Call authenticate to sign in again.")); + } + + private static async Task CallRefreshTool(ITokenRefreshService refreshService, string refreshToken) + { + var server = new McpServer("test", "1.0.0"); + new AuthenticationToolProvider(Mock.Of(), refreshService, NullLogger.Instance) + .RegisterTools(server); + + var request = new JObject + { + ["jsonrpc"] = "2.0", + ["id"] = 1, + ["method"] = "tools/call", + ["params"] = new JObject + { + ["name"] = "refresh_access_token", + ["arguments"] = new JObject { ["refreshToken"] = refreshToken } + } + }; + + var response = await server.HandleRequestAsync(request.ToString(), CancellationToken.None); + return JObject.Parse(JObject.Parse(response)["result"]["content"][0].Value("text")); + } + + private static ApiClient CreateApiClient(Func respond) + { + var httpClient = new HttpClient(new StubHandler(respond)) { BaseAddress = new Uri("https://api.example.test/") }; + var factory = new Mock(); + factory.Setup(x => x.CreateClient("ResgridApi")).Returns(httpClient); + + return new ApiClient(factory.Object, NullLogger.Instance); + } + + private static string FormValue(string formBody, string key) + { + foreach (var pair in formBody.Split('&')) + { + var parts = pair.Split('=', 2); + if (WebUtility.UrlDecode(parts[0]) == key) + return WebUtility.UrlDecode(parts[1]); + } + + return null; + } + + private sealed class StubHandler : HttpMessageHandler + { + private readonly Func _respond; + public StubHandler(Func respond) { _respond = respond; } + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => Task.FromResult(_respond(request)); + } + } +} diff --git a/Tests/Resgrid.Tests/Web/Services/CallsControllerTests.cs b/Tests/Resgrid.Tests/Web/Services/CallsControllerTests.cs index 6e112e244..abeac0d90 100644 --- a/Tests/Resgrid.Tests/Web/Services/CallsControllerTests.cs +++ b/Tests/Resgrid.Tests/Web/Services/CallsControllerTests.cs @@ -33,6 +33,9 @@ public class CallsControllerTests private Mock _protocolsService; private Mock _dataProtectionService; private Mock _protectedCallReadService; + private Mock _protectedWriteService; + private Mock _dispatchScope; + private Mock _mappingService; private CallsController _controller; private Activity _activity; @@ -59,6 +62,16 @@ public void SetUp() .Returns((d, call, g, u, ct) => Task.FromResult(new ProtectedReadResult { Call = call })); + // Unprotected department: every write goes through unchanged. + _protectedWriteService = new Mock(); + _protectedWriteService + .Setup(x => x.PrepareCallWriteAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ProtectedWriteResult { Success = true }); + + _dispatchScope = PassThroughDispatchScope(); + _mappingService = new Mock(); + var httpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity(new[] @@ -87,7 +100,7 @@ public void SetUp() Mock.Of(), Mock.Of(), Mock.Of(), - Mock.Of(), + _mappingService.Object, Mock.Of(), Mock.Of(), Mock.Of(), @@ -96,9 +109,9 @@ public void SetUp() Mock.Of(), _dataProtectionService.Object, _protectedCallReadService.Object, - Mock.Of(), + _protectedWriteService.Object, Mock.Of(), - PassThroughDispatchScope()) + _dispatchScope.Object) { ControllerContext = new ControllerContext { HttpContext = httpContext } }; @@ -126,14 +139,35 @@ public async Task GetCall_ReturnsBadRequest_WhenCallIdIsInvalid(string callId) } /// Group-scoped dispatch off: every call list comes back unchanged. - private static IDispatchScopeService PassThroughDispatchScope() + private static Mock PassThroughDispatchScope() { var scope = new Mock(); scope.Setup(x => x.FilterCallsForUserAsync(It.IsAny(), It.IsAny(), It.IsAny>())) .ReturnsAsync((int departmentId, string userId, List calls) => calls); scope.Setup(x => x.CanUserAccessCallAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(true); - return scope.Object; + return scope; + } + + [Test] + public async Task GetCalls_LeavesOut_CallsOutsideTheCallersDispatchScope() + { + var departmentCalls = new List + { + new Call { CallId = 1, DepartmentId = DepartmentId, Name = "Out of area", LoggedOn = DateTime.UtcNow } + }; + _callsService.Setup(x => x.GetAllCallsByDepartmentDateRangeAsync(DepartmentId, It.IsAny(), It.IsAny())) + .ReturnsAsync(departmentCalls); + _dispatchScope.Setup(x => x.FilterCallsForUserAsync(DepartmentId, UserId, departmentCalls)) + .ReturnsAsync(new List()); + _mappingService.Setup(x => x.GetPOIsForDepartmentAsync(DepartmentId)).ReturnsAsync(new List()); + + var response = await _controller.GetCalls(DateTime.UtcNow.AddDays(-1), DateTime.UtcNow); + + var result = (response.Result as OkObjectResult)?.Value as ActiveCallsResult; + result.Should().NotBeNull(); + result.Data.Should().BeEmpty(); + _dispatchScope.Verify(x => x.FilterCallsForUserAsync(DepartmentId, UserId, departmentCalls), Times.Once); } [Test] @@ -287,5 +321,83 @@ public async Task EditCall_ReturnsBadRequest_WhenIdIsInvalid(string id) service => service.CanUserEditCallAsync(It.IsAny(), It.IsAny()), Times.Never); } + + #region Delete and close each check their own permission + + private const int ManagedCallId = 77; + + /// An undispatched call in the caller's department (a dispatched call can't be deleted at all). + private void SetupManagedCall() + { + var call = new Call { CallId = ManagedCallId, DepartmentId = DepartmentId, Name = "Welfare check", HasBeenDispatched = false }; + + _callsService.Setup(x => x.GetCallByIdAsync(ManagedCallId, It.IsAny())).ReturnsAsync(call); + _callsService.Setup(x => x.PopulateCallData(call, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(call); + _callsService.Setup(x => x.SaveCallAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Call saved, CancellationToken ct) => saved); + } + + private void SetupPermissions(bool canClose, bool canDelete) + { + _authorizationService.Setup(x => x.CanUserCloseCallAsync(UserId, ManagedCallId, DepartmentId)).ReturnsAsync(canClose); + _authorizationService.Setup(x => x.CanUserDeleteCallAsync(UserId, ManagedCallId, DepartmentId)).ReturnsAsync(canDelete); + } + + [Test] + public async Task DeleteCall_IsRefused_WhenTheCallerMayCloseButNotDelete() + { + SetupManagedCall(); + SetupPermissions(canClose: true, canDelete: false); + + var response = await _controller.DeleteCall(ManagedCallId.ToString()); + + response.Result.Should().BeOfType(); + _callsService.Verify(x => x.SaveCallAsync(It.IsAny(), It.IsAny()), Times.Never); + _authorizationService.Verify(x => x.CanUserCloseCallAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task DeleteCall_Succeeds_WhenTheCallerMayDeleteButNotClose() + { + SetupManagedCall(); + SetupPermissions(canClose: false, canDelete: true); + + var response = await _controller.DeleteCall(ManagedCallId.ToString()); + + response.Result.Should().BeOfType(); + _callsService.Verify(x => x.SaveCallAsync(It.Is(c => c.CallId == ManagedCallId && c.IsDeleted), It.IsAny()), Times.Once); + _authorizationService.Verify(x => x.CanUserCloseCallAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task CloseCall_IsRefused_WhenTheCallerMayDeleteButNotClose() + { + SetupManagedCall(); + SetupPermissions(canClose: false, canDelete: true); + + var response = await _controller.CloseCall(new CloseCallInput { Id = ManagedCallId.ToString(), Type = (int)CallStates.Closed }, CancellationToken.None); + + response.Result.Should().BeOfType(); + _callsService.Verify(x => x.SaveCallAsync(It.IsAny(), It.IsAny()), Times.Never); + _authorizationService.Verify(x => x.CanUserDeleteCallAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task CloseCall_Succeeds_WhenTheCallerMayCloseButNotDelete() + { + SetupManagedCall(); + SetupPermissions(canClose: true, canDelete: false); + + var response = await _controller.CloseCall(new CloseCallInput { Id = ManagedCallId.ToString(), Type = (int)CallStates.Closed, Notes = "Resolved on scene" }, CancellationToken.None); + + response.Result.Should().BeOfType(); + _callsService.Verify(x => x.SaveCallAsync(It.Is(c => c.CallId == ManagedCallId && c.State == (int)CallStates.Closed && c.ClosedByUserId == UserId), + It.IsAny()), Times.Once); + _authorizationService.Verify(x => x.CanUserDeleteCallAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + #endregion } } diff --git a/Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs b/Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs index 56c10fb48..209f409cd 100644 --- a/Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs +++ b/Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs @@ -46,10 +46,13 @@ public class TwilioControllerVoiceVerificationTests : TestBase private Mock _twilioVoiceResponseServiceMock; private Mock _featureToggleServiceMock; private Mock _dispatchScopeServiceMock; + private Mock _authorizationServiceMock; + private Mock _adpReleaseMock; protected override void Before_all_tests() { _departmentSettingsServiceMock = new Mock(); + _adpReleaseMock = new Mock(); _numbersServiceMock = new Mock(); _limitsServiceMock = new Mock(); _callsServiceMock = new Mock(); @@ -70,6 +73,10 @@ protected override void Before_all_tests() _encryptionServiceMock = new Mock(); _featureToggleServiceMock = new Mock(); // Group-scoped dispatch off unless a test narrows it: every call is in scope. + // Every unit viewable unless a test narrows it. + _authorizationServiceMock = new Mock(); + _authorizationServiceMock.Setup(x => x.CanUserViewUnitViaMatrixAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(true); + _dispatchScopeServiceMock = new Mock(); _dispatchScopeServiceMock.Setup(x => x.CanUserAccessCallAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(true); _dispatchScopeServiceMock.Setup(x => x.FilterCallsForUserAsync(It.IsAny(), It.IsAny(), It.IsAny>())) @@ -156,7 +163,8 @@ private TestableTwilioController BuildController() _twilioVoiceResponseServiceMock.Object, _featureToggleServiceMock.Object, Mock.Of(), - _dispatchScopeServiceMock.Object); + _dispatchScopeServiceMock.Object, + _authorizationServiceMock.Object, _adpReleaseMock.Object); } private static string InvokeBuildDispatchPrompt(Type controllerType, Call call, string address) @@ -481,6 +489,36 @@ public async System.Threading.Tasks.Task should_not_redirect_the_listing_when_tt content.Should().Contain(" x.GetDepartmentByUserIdAsync("user3", false)).ReturnsAsync(department); + _userProfileServiceMock.Setup(x => x.GetProfileByUserIdAsync("user3", false)).ReturnsAsync(profile); + _unitsServiceMock.Setup(x => x.GetUnitsForDepartmentUnlimitedAsync(7)).ReturnsAsync(new List + { + new Unit { UnitId = 71, DepartmentId = 7, Name = "PMRT A1" }, + new Unit { UnitId = 72, DepartmentId = 7, Name = "PMRT C1" } + }); + _unitsServiceMock.Setup(x => x.GetAllLatestStatusForUnitsByDepartmentIdAsync(7)).ReturnsAsync(new List()); + _customStateServiceMock.Setup(x => x.GetCustomUnitStateAsync(It.IsAny())).ReturnsAsync(new CustomStateDetail { ButtonText = "Available" }); + _authorizationServiceMock.Setup(x => x.CanUserViewUnitViaMatrixAsync(72, "user3", 7)).ReturnsAsync(false); + + await BuildController().InboundVoiceAction("user3", new VoiceRequest { Digits = "3" }); + + _twilioVoiceResponseServiceMock.Verify( + x => x.AppendPromptAsync(It.IsAny(), "PMRT A1, Status Available.", It.IsAny(), It.IsAny()), + Times.AtLeastOnce); + _twilioVoiceResponseServiceMock.Verify( + x => x.AppendPromptAsync(It.IsAny(), It.Is(p => p.Contains("PMRT C1")), It.IsAny(), It.IsAny()), + Times.Never); + _twilioVoiceResponseServiceMock.Verify( + x => x.AppendPromptAsync(It.IsAny(), It.Is(p => p.Contains("PMRT C1")), It.IsAny(), It.IsAny()), + Times.Never); + } + [Test] public async System.Threading.Tasks.Task should_read_the_listing_without_redirecting_when_audio_is_ready() { @@ -596,6 +634,30 @@ public void voice_prompt_catalog_should_use_sentence_punctuation_for_tts_playbac TwilioVoicePromptCatalog.StatusMarked("Available").Should().Be("You have been marked as Available. Goodbye."); } + [TestCase("OPEN ABCDEF 123456")] + [TestCase("OPEN\tABCDEF\n123456")] + public async System.Threading.Tasks.Task Pin_commands_over_get_are_refused_before_archiving_or_text_commands(string body) + { + var controller = BuildController(); + controller.Request.Method = "GET"; + var result = await controller.IncomingMessage(new TwilioMessage { From = "+15555550123", To = "+15555550456", Body = body }); + result.Should().BeOfType(); + _queueServiceMock.Invocations.Should().BeEmpty(); + _textCommandServiceMock.Invocations.Should().BeEmpty(); + _adpReleaseMock.Invocations.Should().BeEmpty(); + ((ContentResult)result).Content.Should().NotContain("123456"); + } + + [Test] + public async System.Threading.Tasks.Task Protected_voice_release_uses_inline_speech_without_the_audio_cache() + { + _adpReleaseMock.Setup(a => a.ReleaseAsync("challenge", "+15555550123", "123456", ProtectedDataEgressChannel.Voice, It.IsAny())) + .ReturnsAsync("Protected dispatch address."); + var result = await BuildController().AdpVoicePin("challenge", new VoiceRequest { To = "+15555550123", Digits = "123456" }); + ((ContentResult)result).Content.Should().Contain("Protected dispatch address.").And.NotContain(""); + _twilioVoiceResponseServiceMock.Invocations.Should().BeEmpty(); + } + private sealed class TestableTwilioController : TwilioController { public TestableTwilioController( @@ -621,7 +683,8 @@ public TestableTwilioController( ITwilioVoiceResponseService twilioVoiceResponseService, IFeatureToggleService featureToggleService, ITextDepartmentSwitchService textDepartmentSwitchService, - IDispatchScopeService dispatchScopeService) + IDispatchScopeService dispatchScopeService, + IAuthorizationService authorizationService, IAdpReleaseService adpRelease) : base( departmentSettingsService, numbersService, @@ -646,8 +709,19 @@ public TestableTwilioController( featureToggleService, textDepartmentSwitchService, Mock.Of(), - dispatchScopeService) + dispatchScopeService, + authorizationService, adpRelease, Projection()) + { + ControllerContext = new ControllerContext { HttpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext() }; + } + + private static IProtectedProjectionService Projection() { + var projection = new Mock(); + projection.Setup(p => p.BuildNotificationSafeCallAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns((d, c, channel, culture) => System.Threading.Tasks.Task.FromResult(c)); + return projection.Object; } public List ReportedDecryptionFailures { get; } = new List(); diff --git a/Tests/Resgrid.Tests/Web/Services/UnitLocationControllerTests.cs b/Tests/Resgrid.Tests/Web/Services/UnitLocationControllerTests.cs index 7207f8310..a38e83c3f 100644 --- a/Tests/Resgrid.Tests/Web/Services/UnitLocationControllerTests.cs +++ b/Tests/Resgrid.Tests/Web/Services/UnitLocationControllerTests.cs @@ -23,6 +23,7 @@ public class UnitLocationControllerTests private Mock _unitsService; private Mock _unitLocationEventProvider; + private Mock _authorizationService; private UnitLocationController _controller; [SetUp] @@ -30,6 +31,10 @@ public void SetUp() { _unitsService = new Mock(); _unitLocationEventProvider = new Mock(); + _authorizationService = new Mock(); + _authorizationService + .Setup(x => x.CanUserViewUnitLocationViaMatrixAsync(UnitId, "unit-location-user", DepartmentId)) + .ReturnsAsync(true); _unitsService .Setup(service => service.GetUnitByIdAsync(UnitId)) @@ -45,7 +50,7 @@ public void SetUp() }; ClaimsAuthorizationHelper._httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext }; - _controller = new UnitLocationController(_unitsService.Object, _unitLocationEventProvider.Object) + _controller = new UnitLocationController(_unitsService.Object, _unitLocationEventProvider.Object, _authorizationService.Object) { ControllerContext = new ControllerContext { HttpContext = httpContext } }; @@ -74,5 +79,31 @@ public async Task SetUnitLocation_ReturnsServiceUnavailable_WhenConfirmedPublish response.Result.Should().BeOfType() .Which.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable); } + + [Test] + public async Task GetLatestUnitLocation_IsRefused_WhenTheCallerMayNotSeeTheUnitsLocation() + { + _authorizationService + .Setup(x => x.CanUserViewUnitLocationViaMatrixAsync(UnitId, "unit-location-user", DepartmentId)) + .ReturnsAsync(false); + + var response = await _controller.GetLatestUnitLocation(UnitId.ToString()); + + response.Result.Should().BeOfType(); + _unitsService.Verify(x => x.GetLatestUnitLocationAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task GetLatestUnitLocation_ReturnsTheLocation_WhenTheCallerMaySeeIt() + { + _unitsService + .Setup(x => x.GetLatestUnitLocationAsync(UnitId, It.IsAny())) + .ReturnsAsync(new UnitsLocation { UnitId = UnitId, Latitude = 47.6062m, Longitude = -122.3321m, Timestamp = System.DateTime.UtcNow }); + + var response = await _controller.GetLatestUnitLocation(UnitId.ToString()); + + response.Value.Data.Should().NotBeNull(); + response.Value.Data.Latitude.Should().NotBeNullOrEmpty(); + } } } diff --git a/Tests/Resgrid.Tests/Web/Services/UnitStatusVisibilityTests.cs b/Tests/Resgrid.Tests/Web/Services/UnitStatusVisibilityTests.cs new file mode 100644 index 000000000..d1a066935 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/Services/UnitStatusVisibilityTests.cs @@ -0,0 +1,151 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Tests.Helpers; +using Resgrid.Web.Services.Controllers.v4; +using Resgrid.Web.Services.Helpers; +using Resgrid.Web.Services.Models.v4.Units; +using Resgrid.Web.ServicesCore.Helpers; + +namespace Resgrid.Tests.Web.Services +{ + /// + /// The v4 unit status endpoints apply the same two rules as the unit lists and the map: a unit shows up only when + /// the caller passes View Units for it, and its coordinates only when they pass See Unit Locations. A unit the + /// caller may see but not locate is still returned, with its coordinates withheld. + /// + [TestFixture] + [NonParallelizable] + public class UnitStatusVisibilityTests + { + private const int DepartmentId = 10; + private const int UnitId = 42; + private const int OtherAreaUnitId = 43; + private const string UserId = "status-viewer"; + + private Mock _unitsService; + private Mock _authorizationService; + private UnitStatusController _controller; + + [SetUp] + public void SetUp() + { + _unitsService = new Mock(); + _unitsService.Setup(x => x.GetUnitByIdAsync(UnitId)).ReturnsAsync(new Unit { UnitId = UnitId, DepartmentId = DepartmentId, Name = "PMRT A1" }); + _unitsService.Setup(x => x.GetLastUnitStateByUnitIdAsync(UnitId)).ReturnsAsync((UnitState)null); + _unitsService.Setup(x => x.GetLatestUnitLocationAsync(UnitId, It.IsAny())) + .ReturnsAsync(new UnitsLocation { UnitId = UnitId, Latitude = 34.05m, Longitude = -118.25m, Timestamp = DateTime.UtcNow }); + + var calls = new Mock(); + calls.Setup(x => x.GetActiveCallsByDepartmentAsync(DepartmentId)).ReturnsAsync(new List()); + var groups = new Mock(); + groups.Setup(x => x.GetAllGroupsForDepartmentAsync(DepartmentId)).ReturnsAsync(new List()); + var mapping = new Mock(); + mapping.Setup(x => x.GetPOIsForDepartmentAsync(DepartmentId)).ReturnsAsync(new List()); + + _unitsService.Setup(x => x.GetUnitsForDepartmentAsync(DepartmentId)).ReturnsAsync(new List + { + new Unit { UnitId = UnitId, DepartmentId = DepartmentId, Name = "PMRT A1" }, + new Unit { UnitId = OtherAreaUnitId, DepartmentId = DepartmentId, Name = "PMRT C1" } + }); + _unitsService.Setup(x => x.GetAllLatestStatusForUnitsByDepartmentIdAsync(DepartmentId)).ReturnsAsync(new List()); + + // Viewable and locatable unless a test says otherwise. + _authorizationService = new Mock(); + _authorizationService.Setup(x => x.CanUserViewUnitViaMatrixAsync(It.IsAny(), UserId, DepartmentId)).ReturnsAsync(true); + _authorizationService.Setup(x => x.CanUserViewUnitLocationViaMatrixAsync(It.IsAny(), UserId, DepartmentId)).ReturnsAsync(true); + + var httpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.PrimarySid, UserId), + new Claim(ClaimTypes.PrimaryGroupSid, DepartmentId.ToString()) + }, "test")) + }; + ClaimsAuthorizationHelper._httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext }; + + _controller = new UnitStatusController(calls.Object, _unitsService.Object, groups.Object, Mock.Of(), + Mock.Of(), mapping.Object, Mock.Of(), Mock.Of(), + DispatchScopeMocks.Off(), _authorizationService.Object) + { + ControllerContext = new ControllerContext { HttpContext = httpContext } + }; + } + + [TearDown] + public void TearDown() => ClaimsAuthorizationHelper._httpContextAccessor = null; + + [Test] + public async Task unit_status_withholds_coordinates_the_caller_may_not_see_but_still_returns_the_unit() + { + _authorizationService.Setup(x => x.CanUserViewUnitLocationViaMatrixAsync(UnitId, UserId, DepartmentId)).ReturnsAsync(false); + + var response = await _controller.GetUnitStatus(UnitId.ToString()); + + var data = response.Value.Data; + data.Should().NotBeNull(); + data.Name.Should().Be("PMRT A1"); + data.Latitude.Should().BeNull(); + data.Longitude.Should().BeNull(); + } + + [Test] + public async Task unit_status_includes_coordinates_the_caller_may_see() + { + _authorizationService.Setup(x => x.CanUserViewUnitLocationViaMatrixAsync(UnitId, UserId, DepartmentId)).ReturnsAsync(true); + + var response = await _controller.GetUnitStatus(UnitId.ToString()); + + response.Value.Data.Latitude.Should().Be(34.05m); + response.Value.Data.Longitude.Should().Be(-118.25m); + } + + [Test] + public async Task unit_status_list_leaves_out_units_the_caller_may_not_view() + { + _authorizationService.Setup(x => x.CanUserViewUnitViaMatrixAsync(OtherAreaUnitId, UserId, DepartmentId)).ReturnsAsync(false); + + var response = await _controller.GetAllUnitStatuses(); + + var result = (response.Result as OkObjectResult)?.Value as Resgrid.Web.Services.Models.v4.UnitStatus.UnitStautsesResult ?? response.Value; + result.Data.Select(d => d.UnitId).Should().Equal(UnitId.ToString()); + } + + [Test] + public async Task a_single_unit_status_the_caller_may_not_view_reads_as_not_found() + { + _authorizationService.Setup(x => x.CanUserViewUnitViaMatrixAsync(UnitId, UserId, DepartmentId)).ReturnsAsync(false); + + var response = await _controller.GetUnitStatus(UnitId.ToString()); + + var result = (response.Result as OkObjectResult)?.Value as Resgrid.Web.Services.Models.v4.UnitStatus.UnitStatusResult ?? response.Value; + result.Data.Should().BeNull(); + result.Status.Should().Be(ResponseHelper.NotFound); + _unitsService.Verify(x => x.GetLatestUnitLocationAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public void withholding_clears_both_coordinates_on_every_unit_shape() + { + var unit = new UnitResultData { Latitude = "34.05", Longitude = "-118.25" }; + UnitLocationVisibility.Withhold(unit); + unit.Latitude.Should().BeNull(); + unit.Longitude.Should().BeNull(); + + var status = new Resgrid.Web.Services.Models.v4.UnitStatus.UnitStatusResultData { Latitude = 34.05m, Longitude = -118.25m }; + UnitLocationVisibility.Withhold(status); + status.Latitude.Should().BeNull(); + status.Longitude.Should().BeNull(); + } + } +} diff --git a/Tests/Resgrid.Tests/Web/Services/UserDefinedFieldsControllerTests.cs b/Tests/Resgrid.Tests/Web/Services/UserDefinedFieldsControllerTests.cs new file mode 100644 index 000000000..d42f3b6c1 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/Services/UserDefinedFieldsControllerTests.cs @@ -0,0 +1,119 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; +using Resgrid.Web.Services.Controllers.v4; +using Resgrid.Web.Services.Models.v4.UserDefinedFields; +using Resgrid.Web.ServicesCore.Helpers; + +namespace Resgrid.Tests.Web.Services +{ + /// + /// Saving a custom field definition from a client that does not send Sensitivity keeps each field's Protected Workflows + /// tag, so a Part 2 field is never silently re-opened for release. + /// + [TestFixture] + [NonParallelizable] + public class UserDefinedFieldsControllerTests + { + private const int DepartmentId = 12; + private const string UserId = "udf-admin"; + private const string DispositionFieldId = "field-disposition"; + + private Mock _udfService; + private List _savedFields; + private UserDefinedFieldsController _controller; + + [SetUp] + public void SetUp() + { + _udfService = new Mock(); + _udfService.Setup(x => x.GetActiveDefinitionAsync(DepartmentId, (int)UdfEntityType.Call)) + .ReturnsAsync(new UdfDefinition { UdfDefinitionId = "def-1", DepartmentId = DepartmentId, EntityType = (int)UdfEntityType.Call, Version = 1 }); + _udfService.Setup(x => x.GetFieldsForActiveDefinitionAsync(DepartmentId, (int)UdfEntityType.Call)) + .ReturnsAsync(new List + { + new UdfField { UdfFieldId = DispositionFieldId, Name = "disposition", Label = "Disposition", Sensitivity = (int)UdfFieldSensitivity.Part2 } + }); + _udfService.Setup(x => x.SaveDefinitionAsync(DepartmentId, (int)UdfEntityType.Call, It.IsAny>(), UserId, It.IsAny())) + .Callback, string, CancellationToken>((d, e, fields, u, c) => _savedFields = fields) + .ReturnsAsync(new UdfDefinition { UdfDefinitionId = "def-2", DepartmentId = DepartmentId, EntityType = (int)UdfEntityType.Call, Version = 2 }); + + var httpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.PrimarySid, UserId), + new Claim(ClaimTypes.PrimaryGroupSid, DepartmentId.ToString()) + }, "test")) + }; + httpContext.Connection.RemoteIpAddress = IPAddress.Loopback; + ClaimsAuthorizationHelper._httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext }; + + _controller = new UserDefinedFieldsController(_udfService.Object, Mock.Of(), Mock.Of(), + Mock.Of()) + { + ControllerContext = new ControllerContext { HttpContext = httpContext } + }; + } + + [TearDown] + public void TearDown() + { + ClaimsAuthorizationHelper._httpContextAccessor = null; + } + + [Test] + public async Task Renaming_a_field_without_sending_sensitivity_keeps_its_tag() + { + await SaveAsync(new UdfFieldInput { UdfFieldId = DispositionFieldId, Name = "outcome", Label = "Outcome" }); + + _savedFields.Single().Sensitivity.Should().Be((int)UdfFieldSensitivity.Part2); + } + + [Test] + public async Task A_field_sent_without_its_id_keeps_its_tag_by_name() + { + await SaveAsync(new UdfFieldInput { Name = " Disposition ", Label = "Disposition" }); + + _savedFields.Single().Sensitivity.Should().Be((int)UdfFieldSensitivity.Part2); + } + + [Test] + public async Task A_sensitivity_the_client_sends_is_kept_as_sent() + { + await SaveAsync(new UdfFieldInput { UdfFieldId = DispositionFieldId, Name = "disposition", Label = "Disposition", Sensitivity = (int)UdfFieldSensitivity.None }); + + _savedFields.Single().Sensitivity.Should().Be((int)UdfFieldSensitivity.None); + } + + [Test] + public async Task A_new_field_starts_untagged() + { + await SaveAsync(new UdfFieldInput { Name = "shift_notes", Label = "Shift notes" }); + + _savedFields.Single().Sensitivity.Should().Be((int)UdfFieldSensitivity.None); + } + + private async Task SaveAsync(UdfFieldInput field) + { + var response = await _controller.SaveDefinition(new SaveUdfDefinitionInput + { + EntityType = (int)UdfEntityType.Call, + Fields = new List { field } + }, CancellationToken.None); + + response.Result.Should().BeOfType(); + } + } +} diff --git a/Tests/Resgrid.Tests/Web/Tts/TtsRequestIdentityTests.cs b/Tests/Resgrid.Tests/Web/Tts/TtsRequestIdentityTests.cs index a7d9774f1..9d546ee62 100644 --- a/Tests/Resgrid.Tests/Web/Tts/TtsRequestIdentityTests.cs +++ b/Tests/Resgrid.Tests/Web/Tts/TtsRequestIdentityTests.cs @@ -54,7 +54,9 @@ public void configure_forwarded_headers_should_register_configured_ingress_proxy .ToList(); knownNetworks.Should().Contain(x => x.Prefix.Equals(IPAddress.Parse("10.42.0.0")) && x.PrefixLength == 16); - knownNetworks.Should().Contain(x => x.Prefix.Equals(IPAddress.Parse("::ffff:10.42.0.0")) && x.PrefixLength == 16); + // The mapped form needs 96 + 16: "::ffff:10.42.0.0/16" is ::/16, which trusts every IPv4 client as a proxy. + knownNetworks.Should().Contain(x => x.Prefix.Equals(IPAddress.Parse("::ffff:10.42.0.0")) && x.PrefixLength == 112); + knownNetworks.Should().NotContain(x => x.Prefix.Equals(IPAddress.Parse("::ffff:10.42.0.0")) && x.PrefixLength == 16); } [Test] diff --git a/Web/Resgrid.Web.Broker/Services/BrokerOperationService.cs b/Web/Resgrid.Web.Broker/Services/BrokerOperationService.cs index 181902f96..0ed7403cf 100644 --- a/Web/Resgrid.Web.Broker/Services/BrokerOperationService.cs +++ b/Web/Resgrid.Web.Broker/Services/BrokerOperationService.cs @@ -34,16 +34,18 @@ public class BrokerOperationService private readonly IProtectedFieldCryptoService _cryptoService; private readonly IKeyWrappingProvider _keyWrappingProvider; private readonly IMemoryCache _replayCache; + private readonly IAdpAuditRepository _audit; public BrokerOperationService(ILifetimeScope rootScope, IProtectedDataGrantService grantService, IProtectedFieldCryptoService cryptoService, IKeyWrappingProvider keyWrappingProvider, - IMemoryCache replayCache) + IMemoryCache replayCache, IAdpAuditRepository audit) { _rootScope = rootScope; _grantService = grantService; _cryptoService = cryptoService; _keyWrappingProvider = keyWrappingProvider; _replayCache = replayCache; + _audit = audit; } public Task DecryptAsync(BrokerFieldOperationRequest request, CancellationToken cancellationToken) => @@ -78,6 +80,24 @@ public static bool IsAllowedWorkloadPurpose(string purpose) private async Task ProcessAsync(BrokerFieldOperationRequest request, bool decrypt, CancellationToken cancellationToken, string workloadPurpose = null) + { + if (request == null || request.DepartmentId <= 0) return Fail("invalid_request"); + var operation = workloadPurpose != null ? "workload-decrypt" : decrypt ? "decrypt" : "encrypt"; + try + { + await _audit.AppendAsync(new AdpAuditEvent { DepartmentId = request.DepartmentId, Layer = "broker", + Operation = operation, Outcome = "requested", CorrelationId = request.RequestId }, cancellationToken); + var result = await ProcessCoreAsync(request, decrypt, cancellationToken, workloadPurpose); + await _audit.AppendAsync(new AdpAuditEvent { DepartmentId = request.DepartmentId, Layer = "broker", + Operation = operation, Outcome = result.Success ? "completed" : "denied", CorrelationId = request.RequestId }, cancellationToken); + return result; + } + catch (OperationCanceledException) { throw; } + catch (Exception) { return Fail("audit_unavailable"); } + } + + private async Task ProcessCoreAsync(BrokerFieldOperationRequest request, bool decrypt, + CancellationToken cancellationToken, string workloadPurpose) { if (request == null || request.DepartmentId <= 0 || string.IsNullOrWhiteSpace(request.RequestId) || request.Items == null || request.Items.Count == 0) @@ -117,7 +137,14 @@ private async Task ProcessAsync(BrokerFieldOperationR // grant that IS presented is still fully validated, so a stolen/stale token cannot be // laundered through the encrypt path either. ProtectedDataGrant grant = null; - if (decrypt && workloadPurpose == null || !string.IsNullOrWhiteSpace(request.GrantToken)) + if (decrypt && workloadPurpose == null && request.GrantToken?.StartsWith("adpr.", StringComparison.Ordinal) == true) + { + var receipts = scope.Resolve(); + if (policy == null || policy.State != (int)DepartmentDataProtectionState.Enabled && policy.State != (int)DepartmentDataProtectionState.Rotating || + await receipts.ConsumeAsync(request.GrantToken, request.DepartmentId, currentEpoch, request.Items, cancellationToken) == null) + return Fail("grant_invalid"); + } + else if (decrypt && workloadPurpose == null || !string.IsNullOrWhiteSpace(request.GrantToken)) { var requiredScope = decrypt ? ProtectedDataGrantScopes.Read : ProtectedDataGrantScopes.Write; var outcome = _grantService.ValidateGrant(request.GrantToken, request.DepartmentId, currentEpoch, @@ -126,6 +153,10 @@ private async Task ProcessAsync(BrokerFieldOperationR return Fail(MapGrantOutcome(outcome)); } + await _audit.AppendAsync(new AdpAuditEvent { DepartmentId = request.DepartmentId, Layer = "broker", + Operation = decrypt ? "decrypt-authorized" : "encrypt-authorized", Outcome = "authorized", + ActorId = grant?.UserId, ResourceId = grant?.GrantId, CorrelationId = request.RequestId, + PolicyEpoch = currentEpoch }, cancellationToken); var result = new ProtectedDataBrokerResult { Success = true }; var unwrappedKeys = new Dictionary(); try diff --git a/Web/Resgrid.Web.Common/Helpers/ForwardedHeadersSetup.cs b/Web/Resgrid.Web.Common/Helpers/ForwardedHeadersSetup.cs new file mode 100644 index 000000000..50452f5fe --- /dev/null +++ b/Web/Resgrid.Web.Common/Helpers/ForwardedHeadersSetup.cs @@ -0,0 +1,57 @@ +using System.Net; +using System.Net.Sockets; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.HttpOverrides; +using ProxyNetwork = Microsoft.AspNetCore.HttpOverrides.IPNetwork; + +namespace Resgrid.Web.Helpers +{ + /// + /// Decides which proxies may set the caller's address through X-Forwarded-For. Shared by every ASP.NET host so the + /// trust boundary cannot drift between them: whatever ForwardedHeadersMiddleware accepts becomes + /// HttpContext.Connection.RemoteIpAddress, which audit logs, session tracking and per-IP limits all record. + /// + public static class ForwardedHeadersSetup + { + private const int Ipv4MappedPrefixBits = 96; + + /// + /// Trusts X-Forwarded-For and X-Forwarded-Proto only from the configured proxy network. An IPv4 network is also + /// added in IPv4-mapped IPv6 form, because Kestrel on a dual-stack socket reports IPv4 peers as ::ffff:a.b.c.d. + /// The mapped form's prefix length is offset by the 96 bits of ::ffff:0:0/96: keeping the IPv4 length there would + /// describe ::/n, which contains every IPv4-mapped address and so trusts any IPv4 client as a proxy. + /// A network or prefix that does not parse trusts nothing beyond the framework's loopback defaults. + /// + public static void Configure(ForwardedHeadersOptions options, string proxyNetwork, int proxyNetworkCidr) + { + options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; + + if (string.IsNullOrWhiteSpace(proxyNetwork) || !IPAddress.TryParse(proxyNetwork, out var network)) + return; + + if (network.IsIPv4MappedToIPv6) + { + // Configured in mapped form (::ffff:a.b.c.d): accept the IPv4 prefix length or the full IPv6 one. + network = network.MapToIPv4(); + if (proxyNetworkCidr > 32) + proxyNetworkCidr -= Ipv4MappedPrefixBits; + } + + if (network.AddressFamily == AddressFamily.InterNetwork) + { + if (proxyNetworkCidr < 0 || proxyNetworkCidr > 32) + return; + + options.KnownNetworks.Add(new ProxyNetwork(network, proxyNetworkCidr)); + options.KnownNetworks.Add(new ProxyNetwork(network.MapToIPv6(), Ipv4MappedPrefixBits + proxyNetworkCidr)); + } + else if (network.AddressFamily == AddressFamily.InterNetworkV6) + { + if (proxyNetworkCidr < 0 || proxyNetworkCidr > 128) + return; + + options.KnownNetworks.Add(new ProxyNetwork(network, proxyNetworkCidr)); + } + } + } +} diff --git a/Web/Resgrid.Web.Mcp/ApiClient.cs b/Web/Resgrid.Web.Mcp/ApiClient.cs index 8cf6d5233..d3130fedf 100644 --- a/Web/Resgrid.Web.Mcp/ApiClient.cs +++ b/Web/Resgrid.Web.Mcp/ApiClient.cs @@ -1,11 +1,14 @@ using System; using System.Collections.Generic; +using System.Linq; +using System.Net; using System.Net.Http; using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Newtonsoft.Json; +using Resgrid.Web.Mcp.ModelContextProtocol; namespace Resgrid.Web.Mcp { @@ -23,28 +26,46 @@ public ApiClient(IHttpClientFactory httpClientFactory, ILogger logger _logger = logger; } - public async Task AuthenticateAsync( + public Task AuthenticateAsync( string username, string password, CancellationToken cancellationToken = default) + { + return RequestTokenAsync(new[] + { + new KeyValuePair("grant_type", "password"), + new KeyValuePair("username", username), + new KeyValuePair("password", password), + // offline_access is what makes the API issue a refresh token alongside the access token. + new KeyValuePair("scope", "openid profile email offline_access") + }, "Authentication", cancellationToken); + } + + public Task RefreshTokenAsync( + string refreshToken, + CancellationToken cancellationToken = default) + { + return RequestTokenAsync(new[] + { + new KeyValuePair("grant_type", "refresh_token"), + new KeyValuePair("refresh_token", refreshToken) + }, "Token refresh", cancellationToken); + } + + private async Task RequestTokenAsync( + IEnumerable> form, + string operation, + CancellationToken cancellationToken) { try { var client = _httpClientFactory.CreateClient("ResgridApi"); - var formContent = new FormUrlEncodedContent(new[] - { - new KeyValuePair("grant_type", "password"), - new KeyValuePair("username", username), - new KeyValuePair("password", password), - new KeyValuePair("scope", "openid profile email") - }); - - var response = await client.PostAsync(V4Routes.Post.Token, formContent, cancellationToken); + var response = await client.PostAsync(V4Routes.Post.Token, new FormUrlEncodedContent(form), cancellationToken); + var content = await response.Content.ReadAsStringAsync(cancellationToken); if (response.IsSuccessStatusCode) { - var content = await response.Content.ReadAsStringAsync(cancellationToken); var tokenResponse = JsonConvert.DeserializeObject(content); if (tokenResponse is null) @@ -62,32 +83,45 @@ public async Task AuthenticateAsync( IsSuccess = true, AccessToken = tokenResponse.AccessToken, TokenType = tokenResponse.TokenType, - ExpiresIn = tokenResponse.ExpiresIn + ExpiresIn = tokenResponse.ExpiresIn, + RefreshToken = tokenResponse.RefreshToken }; } - else - { - var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); - _logger.LogWarning("Authentication failed: {StatusCode} - {Error}", response.StatusCode, errorContent); - return new AuthenticationResult - { - IsSuccess = false, - ErrorMessage = $"Authentication failed: {response.StatusCode}" - }; - } + _logger.LogWarning("{Operation} failed: {StatusCode} - {Error}", operation, response.StatusCode, content); + + // The token endpoint explains a refused grant in error_description (for example "The refresh token is no + // longer valid."), which tells the caller whether to retry or sign in again. + return new AuthenticationResult + { + IsSuccess = false, + ErrorMessage = TryReadErrorDescription(content) ?? $"{operation} failed: {response.StatusCode}" + }; } catch (Exception ex) { - _logger.LogError(ex, "Error during authentication"); + _logger.LogError(ex, "Error during {Operation}", operation); return new AuthenticationResult { IsSuccess = false, - ErrorMessage = "An error occurred during authentication" + ErrorMessage = $"An error occurred during {operation.ToLowerInvariant()}" }; } } + private static string TryReadErrorDescription(string content) + { + try + { + var error = JsonConvert.DeserializeObject(content); + return string.IsNullOrWhiteSpace(error?.ErrorDescription) ? null : error.ErrorDescription; + } + catch (JsonException) + { + return null; + } + } + public async Task GetAsync( string endpoint, string accessToken, @@ -98,6 +132,7 @@ public async Task GetAsync( try { var response = await client.GetAsync(endpoint, cancellationToken); + ThrowIfNotAuthorized(response, endpoint); response.EnsureSuccessStatusCode(); var content = await response.Content.ReadAsStringAsync(cancellationToken); @@ -111,7 +146,7 @@ public async Task GetAsync( return result; } - catch (Exception ex) + catch (Exception ex) when (ex is not McpToolErrorException) { _logger.LogError(ex, "Error making GET request to {Endpoint}", endpoint); throw; @@ -132,6 +167,7 @@ public async Task PostAsync( var content = new StringContent(json, Encoding.UTF8, "application/json"); var response = await client.PostAsync(endpoint, content, cancellationToken); + ThrowIfNotAuthorized(response, endpoint); response.EnsureSuccessStatusCode(); var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); @@ -145,7 +181,7 @@ public async Task PostAsync( return result; } - catch (Exception ex) + catch (Exception ex) when (ex is not McpToolErrorException) { _logger.LogError(ex, "Error making POST request to {Endpoint}", endpoint); throw; @@ -166,6 +202,7 @@ public async Task PutAsync( var content = new StringContent(json, Encoding.UTF8, "application/json"); var response = await client.PutAsync(endpoint, content, cancellationToken); + ThrowIfNotAuthorized(response, endpoint); response.EnsureSuccessStatusCode(); var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); @@ -179,7 +216,7 @@ public async Task PutAsync( return result; } - catch (Exception ex) + catch (Exception ex) when (ex is not McpToolErrorException) { _logger.LogError(ex, "Error making PUT request to {Endpoint}", endpoint); throw; @@ -196,15 +233,47 @@ public async Task DeleteAsync( try { var response = await client.DeleteAsync(endpoint, cancellationToken); + ThrowIfNotAuthorized(response, endpoint); return response.IsSuccessStatusCode; } - catch (Exception ex) + catch (Exception ex) when (ex is not McpToolErrorException) { _logger.LogError(ex, "Error making DELETE request to {Endpoint}", endpoint); throw; } } + /// + /// Turns an authorization failure into an error the MCP client can act on. The API marks a rejected access token + /// (expired, or its session revoked) with a Bearer invalid_token challenge. Any other 401, which v4 actions return + /// when the user may not touch a record, and any 403 mean the user is signed in but not permitted: a new token + /// will not help. + /// + private void ThrowIfNotAuthorized(HttpResponseMessage response, string endpoint) + { + if (response.StatusCode == HttpStatusCode.Unauthorized && IsInvalidTokenChallenge(response)) + { + _logger.LogInformation("API rejected the access token for {Endpoint}", endpoint); + throw new McpToolErrorException(McpToolErrorException.AccessTokenExpired, + "The access token has expired or is no longer valid. Call refresh_access_token with your refresh token, " + + "then call this tool again with the new access token. If the refresh fails, call authenticate."); + } + + if (response.StatusCode == HttpStatusCode.Unauthorized || response.StatusCode == HttpStatusCode.Forbidden) + { + _logger.LogInformation("API refused {Endpoint} with {StatusCode}", endpoint, response.StatusCode); + throw new McpToolErrorException(McpToolErrorException.Forbidden, + "The signed-in user is not permitted to do this. A new access token will not change that."); + } + } + + private static bool IsInvalidTokenChallenge(HttpResponseMessage response) + { + return response.Headers.TryGetValues("WWW-Authenticate", out var challenges) + && challenges.Any(x => x.StartsWith("Bearer", StringComparison.OrdinalIgnoreCase) + && x.Contains("error=\"invalid_token\"", StringComparison.OrdinalIgnoreCase)); + } + private HttpClient CreateAuthenticatedClient(string accessToken) { var client = _httpClientFactory.CreateClient("ResgridApi"); @@ -223,6 +292,18 @@ private sealed class TokenResponse [JsonProperty("expires_in")] public int ExpiresIn { get; set; } + + [JsonProperty("refresh_token")] + public string RefreshToken { get; set; } + } + + private sealed class TokenErrorResponse + { + [JsonProperty("error")] + public string Error { get; set; } + + [JsonProperty("error_description")] + public string ErrorDescription { get; set; } } } } diff --git a/Web/Resgrid.Web.Mcp/Controllers/McpController.cs b/Web/Resgrid.Web.Mcp/Controllers/McpController.cs index a8094e88c..565adaa82 100644 --- a/Web/Resgrid.Web.Mcp/Controllers/McpController.cs +++ b/Web/Resgrid.Web.Mcp/Controllers/McpController.cs @@ -83,8 +83,10 @@ public async Task HandleRequest(CancellationToken cancellationTok var redactedRequest = SensitiveDataRedactor.RedactSensitiveFields(requestBody); _logger.LogDebug("Received MCP request: {Request}", redactedRequest); - // Process the request through the MCP handler - var response = await _mcpHandler.HandleRequestAsync(requestBody, cancellationToken); + // Process the request through the MCP handler. RemoteIpAddress is the client's own address once + // UseForwardedHeaders has applied a trusted proxy's X-Forwarded-For. + var response = await _mcpHandler.HandleRequestAsync(requestBody, + HttpContext.Connection.RemoteIpAddress?.ToString(), cancellationToken); var redactedResponse = SensitiveDataRedactor.RedactSensitiveFields(response); _logger.LogDebug("Sending MCP response: {Response}", redactedResponse); diff --git a/Web/Resgrid.Web.Mcp/Dockerfile b/Web/Resgrid.Web.Mcp/Dockerfile index 70c71175e..7915e9841 100644 --- a/Web/Resgrid.Web.Mcp/Dockerfile +++ b/Web/Resgrid.Web.Mcp/Dockerfile @@ -21,6 +21,7 @@ COPY ["Web/Resgrid.Web.Mcp/Resgrid.Web.Mcp.csproj", "Web/Resgrid.Web.Mcp/"] COPY ["Providers/Resgrid.Providers.Bus.Rabbit/Resgrid.Providers.Bus.Rabbit.csproj", "Providers/Resgrid.Providers.Bus.Rabbit/"] COPY ["Core/Resgrid.Framework/Resgrid.Framework.csproj", "Core/Resgrid.Framework/"] COPY ["Core/Resgrid.Config/Resgrid.Config.csproj", "Core/Resgrid.Config/"] +COPY ["Web/Resgrid.Web.Common/Resgrid.Web.Common.csproj", "Web/Resgrid.Web.Common/"] COPY ["Core/Resgrid.Model/Resgrid.Model.csproj", "Core/Resgrid.Model/"] COPY ["Providers/Resgrid.Providers.AddressVerification/Resgrid.Providers.AddressVerification.csproj", "Providers/Resgrid.Providers.AddressVerification/"] COPY ["Core/Resgrid.Services/Resgrid.Services.csproj", "Core/Resgrid.Services/"] diff --git a/Web/Resgrid.Web.Mcp/IApiClient.cs b/Web/Resgrid.Web.Mcp/IApiClient.cs index 0c423087f..31025ac23 100644 --- a/Web/Resgrid.Web.Mcp/IApiClient.cs +++ b/Web/Resgrid.Web.Mcp/IApiClient.cs @@ -9,10 +9,16 @@ namespace Resgrid.Web.Mcp public interface IApiClient { /// - /// Authenticates a user and returns an access token + /// Authenticates a user and returns an access token and a refresh token /// Task AuthenticateAsync(string username, string password, CancellationToken cancellationToken = default); + /// + /// Exchanges a refresh token for a new access token and a new refresh token. The refresh token presented is + /// single use: the API rejects it once its short reuse window has passed. + /// + Task RefreshTokenAsync(string refreshToken, CancellationToken cancellationToken = default); + /// /// Makes an authenticated GET request to the API /// @@ -43,6 +49,7 @@ public sealed record AuthenticationResult public string AccessToken { get; init; } public string TokenType { get; init; } public int ExpiresIn { get; init; } + public string RefreshToken { get; init; } public string ErrorMessage { get; init; } } } diff --git a/Web/Resgrid.Web.Mcp/Infrastructure/RateLimiter.cs b/Web/Resgrid.Web.Mcp/Infrastructure/RateLimiter.cs index 0bd67d141..55c8efe72 100644 --- a/Web/Resgrid.Web.Mcp/Infrastructure/RateLimiter.cs +++ b/Web/Resgrid.Web.Mcp/Infrastructure/RateLimiter.cs @@ -11,7 +11,11 @@ namespace Resgrid.Web.Mcp.Infrastructure /// public interface IRateLimiter { - Task IsAllowedAsync(string clientId, string operation); + /// + /// Counts a request against the client's sliding one-minute window for the operation, and says whether it is + /// within . A rejected request is not counted. + /// + Task IsAllowedAsync(string clientId, string operation, int maxRequestsPerMinute); void Reset(string clientId); } @@ -22,8 +26,6 @@ public sealed class RateLimiter : IRateLimiter, IDisposable private readonly Timer _cleanupTimer; private bool _disposed; - // Rate limits: 100 requests per minute per client - private const int MaxRequestsPerMinute = 100; private static readonly TimeSpan Window = TimeSpan.FromMinutes(1); public RateLimiter(ILogger logger) @@ -33,13 +35,13 @@ public RateLimiter(ILogger logger) _cleanupTimer = new Timer(CleanupExpired, null, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(5)); } - public Task IsAllowedAsync(string clientId, string operation) + public Task IsAllowedAsync(string clientId, string operation, int maxRequestsPerMinute) { var key = $"{clientId}:{operation}"; var counter = _counters.GetOrAdd(key, _ => new RequestCounter()); var now = DateTime.UtcNow; - var allowed = counter.TryAddIfUnderLimit(now, Window, MaxRequestsPerMinute); + var allowed = counter.TryAddIfUnderLimit(now, Window, maxRequestsPerMinute); if (!allowed) { diff --git a/Web/Resgrid.Web.Mcp/Infrastructure/SensitiveDataRedactor.cs b/Web/Resgrid.Web.Mcp/Infrastructure/SensitiveDataRedactor.cs index ca06987e8..0ee205a84 100644 --- a/Web/Resgrid.Web.Mcp/Infrastructure/SensitiveDataRedactor.cs +++ b/Web/Resgrid.Web.Mcp/Infrastructure/SensitiveDataRedactor.cs @@ -123,6 +123,10 @@ private static void RedactNode(JsonNode node) { jsonObject[property.Key] = RedactedValue; } + else if (property.Value is JsonValue value && TryRedactEmbeddedJson(value, out var redacted)) + { + jsonObject[property.Key] = redacted; + } else if (property.Value != null) { RedactNode(property.Value); @@ -134,13 +138,54 @@ private static void RedactNode(JsonNode node) for (int i = 0; i < jsonArray.Count; i++) { var item = jsonArray[i]; - if (item != null) + if (item is JsonValue value && TryRedactEmbeddedJson(value, out var redacted)) + { + jsonArray[i] = redacted; + } + else if (item != null) { RedactNode(item); } } } } + + /// + /// Redacts JSON carried inside a string value. MCP tool results travel serialized into result.content[].text, + /// so the tokens a tool returns sit inside a string rather than under a property name the walk above can see. + /// + private static bool TryRedactEmbeddedJson(JsonValue value, out string redacted) + { + redacted = null; + + if (!value.TryGetValue(out var text)) + { + return false; + } + + var trimmed = text.TrimStart(); + if (!trimmed.StartsWith("{") && !trimmed.StartsWith("[")) + { + return false; + } + + try + { + var embedded = JsonNode.Parse(text); + if (embedded == null) + { + return false; + } + + RedactNode(embedded); + redacted = embedded.ToJsonString(new JsonSerializerOptions { WriteIndented = false }); + return true; + } + catch (JsonException) + { + return false; + } + } } } diff --git a/Web/Resgrid.Web.Mcp/Infrastructure/TokenRefreshService.cs b/Web/Resgrid.Web.Mcp/Infrastructure/TokenRefreshService.cs index ae3511f20..96c8c628c 100644 --- a/Web/Resgrid.Web.Mcp/Infrastructure/TokenRefreshService.cs +++ b/Web/Resgrid.Web.Mcp/Infrastructure/TokenRefreshService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Concurrent; using System.Threading; using System.Threading.Tasks; @@ -7,143 +7,70 @@ namespace Resgrid.Web.Mcp.Infrastructure { /// - /// Token refresh service for managing OAuth2 token lifecycle + /// Exchanges refresh tokens for new access tokens (the OAuth2 refresh_token grant). /// + /// + /// The MCP server is stateless: the client holds its tokens and passes the access token to every tool. When the + /// access token nears expiry the client calls the refresh_access_token tool with its refresh token, which lands here. + /// public interface ITokenRefreshService { - Task GetValidTokenAsync(string userId, string refreshToken); - void CacheToken(string userId, string accessToken, string refreshToken, int expiresIn); - void InvalidateToken(string userId); + Task RefreshAsync(string refreshToken, CancellationToken cancellationToken = default); } public sealed class TokenRefreshService : ITokenRefreshService { private readonly IApiClient _apiClient; private readonly ILogger _logger; - private readonly ConcurrentDictionary _tokenCache; - private readonly Timer _cleanupTimer; + private readonly ConcurrentDictionary>> _inFlight; public TokenRefreshService(IApiClient apiClient, ILogger logger) { _apiClient = apiClient; _logger = logger; - _tokenCache = new ConcurrentDictionary(); - _cleanupTimer = new Timer(CleanupExpired, null, TimeSpan.FromMinutes(10), TimeSpan.FromMinutes(10)); + _inFlight = new ConcurrentDictionary>>(StringComparer.Ordinal); } - public async Task GetValidTokenAsync(string userId, string refreshToken) + public Task RefreshAsync(string refreshToken, CancellationToken cancellationToken = default) { - if (_tokenCache.TryGetValue(userId, out var cached)) + if (string.IsNullOrWhiteSpace(refreshToken)) { - if (cached.ExpiresAt > DateTime.UtcNow.AddMinutes(5)) + return Task.FromResult(new AuthenticationResult { - _logger.LogDebug("Using cached token for user {UserId}", userId); - return cached.AccessToken; - } - - _logger.LogInformation("Token expired for user {UserId}, refreshing...", userId); - } - - try - { - // Call refresh token endpoint - var result = await RefreshTokenAsync(refreshToken); - - if (result.IsSuccess) - { - CacheToken(userId, result.AccessToken, result.RefreshToken, result.ExpiresIn); - return result.AccessToken; - } - - _logger.LogError("Failed to refresh token for user {UserId}", userId); - return null; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error refreshing token for user {UserId}", userId); - return null; + IsSuccess = false, + ErrorMessage = "Refresh token is required" + }); } - } - - public void CacheToken(string userId, string accessToken, string refreshToken, int expiresIn) - { - var cache = new TokenCache - { - AccessToken = accessToken, - RefreshToken = refreshToken, - ExpiresAt = DateTime.UtcNow.AddSeconds(expiresIn) - }; - _tokenCache.AddOrUpdate(userId, cache, (_, __) => cache); - _logger.LogDebug("Cached token for user {UserId}, expires at {ExpiresAt}", userId, cache.ExpiresAt); - } + // Refresh tokens are single use: each exchange returns a new one, and the API rejects the old one once its + // short reuse window has passed. Callers presenting the same refresh token at the same time therefore share + // one exchange and all receive the new pair, instead of racing to redeem the token twice. + var exchange = _inFlight.GetOrAdd(refreshToken, + token => new Lazy>(() => ExchangeAsync(token))); - public void InvalidateToken(string userId) - { - _tokenCache.TryRemove(userId, out _); - _logger.LogDebug("Invalidated token for user {UserId}", userId); + // One caller giving up must not cancel the exchange the others are waiting on. + return exchange.Value.WaitAsync(cancellationToken); } - private async Task RefreshTokenAsync(string refreshToken) + private async Task ExchangeAsync(string refreshToken) { try { - // This would call the actual refresh token endpoint - // For now, returning a placeholder - // In real implementation, call: POST /api/v4/connect/token with grant_type=refresh_token - - _logger.LogWarning("Token refresh not fully implemented - requires refresh_token grant type support"); + var result = await _apiClient.RefreshTokenAsync(refreshToken, CancellationToken.None); - return new RefreshTokenResult - { - IsSuccess = false - }; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error during token refresh"); - return new RefreshTokenResult { IsSuccess = false }; - } - } - - private void CleanupExpired(object state) - { - var keysToRemove = new System.Collections.Generic.List(); - var now = DateTime.UtcNow; + if (result.IsSuccess) + _logger.LogInformation("Access token refreshed, expires in {ExpiresIn} seconds", result.ExpiresIn); + else + _logger.LogWarning("Token refresh failed: {Error}", result.ErrorMessage); - foreach (var kvp in _tokenCache) - { - if (kvp.Value.ExpiresAt < now) - { - keysToRemove.Add(kvp.Key); - } + return result; } - - foreach (var key in keysToRemove) + finally { - _tokenCache.TryRemove(key, out _); + // Only in-flight exchanges are shared. A later call with the same (now redeemed) token goes back to the + // API, which decides whether it is still inside the reuse window. + _inFlight.TryRemove(refreshToken, out _); } - - if (keysToRemove.Count > 0) - { - _logger.LogDebug("Cleaned up {Count} expired tokens", keysToRemove.Count); - } - } - - private sealed class TokenCache - { - public string AccessToken { get; set; } - public string RefreshToken { get; set; } - public DateTime ExpiresAt { get; set; } - } - - private sealed class RefreshTokenResult - { - public bool IsSuccess { get; set; } - public string AccessToken { get; set; } - public string RefreshToken { get; set; } - public int ExpiresIn { get; set; } } } } - diff --git a/Web/Resgrid.Web.Mcp/McpServerHost.cs b/Web/Resgrid.Web.Mcp/McpServerHost.cs index 04406d62a..200293179 100644 --- a/Web/Resgrid.Web.Mcp/McpServerHost.cs +++ b/Web/Resgrid.Web.Mcp/McpServerHost.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Resgrid.Web.Mcp.Infrastructure; using Resgrid.Web.Mcp.ModelContextProtocol; using Resgrid.Config; using Sentry; @@ -17,6 +18,7 @@ public sealed class McpServerHost : IHostedService, IDisposable private readonly ILogger _logger; private readonly McpToolRegistry _toolRegistry; private readonly IHostApplicationLifetime _applicationLifetime; + private readonly IRateLimiter _rateLimiter; private McpServer _mcpServer; private Task _executingTask; private CancellationTokenSource _stoppingCts; @@ -25,11 +27,13 @@ public sealed class McpServerHost : IHostedService, IDisposable public McpServerHost( ILogger logger, McpToolRegistry toolRegistry, - IHostApplicationLifetime applicationLifetime) + IHostApplicationLifetime applicationLifetime, + IRateLimiter rateLimiter) { _logger = logger; _toolRegistry = toolRegistry; _applicationLifetime = applicationLifetime; + _rateLimiter = rateLimiter; } public Task StartAsync(CancellationToken cancellationToken) @@ -48,7 +52,7 @@ public Task StartAsync(CancellationToken cancellationToken) var serverVersion = McpConfig.ServerVersion; // Create MCP server with server information - _mcpServer = new McpServer(serverName, serverVersion, _logger); + _mcpServer = new McpServer(serverName, serverVersion, _logger, _rateLimiter); // Register all tools from the registry _toolRegistry.RegisterTools(_mcpServer); diff --git a/Web/Resgrid.Web.Mcp/ModelContextProtocol/IMcpRequestHandler.cs b/Web/Resgrid.Web.Mcp/ModelContextProtocol/IMcpRequestHandler.cs index 2929b0d7c..6317637cd 100644 --- a/Web/Resgrid.Web.Mcp/ModelContextProtocol/IMcpRequestHandler.cs +++ b/Web/Resgrid.Web.Mcp/ModelContextProtocol/IMcpRequestHandler.cs @@ -12,9 +12,10 @@ public interface IMcpRequestHandler /// Handles a JSON-RPC request and returns a JSON-RPC response /// /// The JSON-RPC request as a string + /// The caller's address, used to rate limit tool calls made without an access token /// Cancellation token /// The JSON-RPC response as a string - Task HandleRequestAsync(string requestJson, CancellationToken cancellationToken); + Task HandleRequestAsync(string requestJson, string clientAddress, CancellationToken cancellationToken); } } diff --git a/Web/Resgrid.Web.Mcp/ModelContextProtocol/McpServer.cs b/Web/Resgrid.Web.Mcp/ModelContextProtocol/McpServer.cs index a65a16347..7fb588364 100644 --- a/Web/Resgrid.Web.Mcp/ModelContextProtocol/McpServer.cs +++ b/Web/Resgrid.Web.Mcp/ModelContextProtocol/McpServer.cs @@ -1,11 +1,16 @@ using System; using System.Collections.Generic; using System.IO; +using System.Security.Cryptography; +using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; +using Newtonsoft.Json.Linq; +using Resgrid.Config; +using Resgrid.Web.Mcp.Infrastructure; namespace Resgrid.Web.Mcp.ModelContextProtocol { @@ -14,17 +19,21 @@ namespace Resgrid.Web.Mcp.ModelContextProtocol /// public sealed class McpServer : IMcpRequestHandler { + private const string ToolCallOperation = "tools/call"; + private readonly string _serverName; private readonly string _serverVersion; private readonly Dictionary _tools; private readonly ILogger _logger; + private readonly IRateLimiter _rateLimiter; - public McpServer(string serverName, string serverVersion, ILogger logger = null) + public McpServer(string serverName, string serverVersion, ILogger logger = null, IRateLimiter rateLimiter = null) { _serverName = serverName; _serverVersion = serverVersion; _tools = new Dictionary(); _logger = logger; + _rateLimiter = rateLimiter; } public void AddTool(string name, string description, Dictionary inputSchema, Func> handler) @@ -38,15 +47,26 @@ public void AddTool(string name, string description, Dictionary }; } + /// + /// Handles a JSON-RPC request string from a caller whose address is unknown + /// + public Task HandleRequestAsync(string requestJson, CancellationToken cancellationToken) + { + return HandleRequestAsync(requestJson, null, cancellationToken); + } + /// /// Handles a JSON-RPC request string and returns a JSON-RPC response string /// - public async Task HandleRequestAsync(string requestJson, CancellationToken cancellationToken) + /// The JSON-RPC request + /// The caller's address, used to rate limit tool calls made without an access token + /// Cancellation token + public async Task HandleRequestAsync(string requestJson, string clientAddress, CancellationToken cancellationToken) { try { var request = JsonSerializer.Deserialize(requestJson); - var response = await HandleRequestAsync(request, cancellationToken); + var response = await HandleRequestAsync(request, clientAddress, cancellationToken); return JsonSerializer.Serialize(response); } catch (Exception ex) @@ -101,7 +121,8 @@ public async Task RunAsync(CancellationToken cancellationToken) try { - var responseJson = await HandleRequestAsync(line, cancellationToken); + // The stdio transport has a single local client. + var responseJson = await HandleRequestAsync(line, "stdio", cancellationToken); await Console.Out.WriteLineAsync(responseJson); await Console.Out.FlushAsync(); } @@ -131,7 +152,7 @@ public async Task RunAsync(CancellationToken cancellationToken) } } - private async Task HandleRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken) + private async Task HandleRequestAsync(JsonRpcRequest request, string clientAddress, CancellationToken cancellationToken) { var response = new JsonRpcResponse { @@ -188,7 +209,24 @@ private async Task HandleRequestAsync(JsonRpcRequest request, C return response; } - var result = await toolDef.Handler(toolCallParams.Arguments); + object result; + try + { + await EnforceRateLimitAsync(toolCallParams.Arguments, clientAddress); + result = await toolDef.Handler(toolCallParams.Arguments); + } + catch (McpToolErrorException ex) + { + // Something the caller can act on (e.g. refresh an expired token): report it as the tool's + // result, in the shape tools use for their own errors, not as a JSON-RPC internal error. + _logger?.LogInformation("Tool {Tool} returned {ErrorCode}", toolCallParams.Name, ex.ErrorCode); + result = new { success = false, errorCode = ex.ErrorCode, error = ex.Message }; + } + + // Newtonsoft, not System.Text.Json: tool results carry the JObject/JArray payloads ApiClient + // deserializes, which System.Text.Json writes out as nested empty arrays. + var resultJson = result == null ? JValue.CreateNull() : JToken.FromObject(result); + response.Result = new { content = new[] @@ -196,11 +234,10 @@ private async Task HandleRequestAsync(JsonRpcRequest request, C new { type = "text", - // Newtonsoft, not System.Text.Json: tool results carry the JObject/JArray payloads - // ApiClient deserializes, which System.Text.Json writes out as nested empty arrays. - text = Newtonsoft.Json.JsonConvert.SerializeObject(result) + text = resultJson.ToString(Newtonsoft.Json.Formatting.None) } - } + }, + isError = IsFailedToolResult(resultJson) }; break; @@ -232,6 +269,57 @@ private async Task HandleRequestAsync(JsonRpcRequest request, C return response; } + /// + /// Limits tool calls per signed-in session, keyed by access token so that callers sharing an address (such as a + /// hosted AI client's egress) do not share a limit. Calls made without a token, in practice authenticate and + /// refresh_access_token, are keyed by client address and held to a tighter limit. + /// + private async Task EnforceRateLimitAsync(object arguments, string clientAddress) + { + if (_rateLimiter == null) + return; + + var accessToken = ReadAccessToken(arguments); + var clientId = accessToken != null ? $"token:{Fingerprint(accessToken)}" : $"address:{clientAddress ?? "unknown"}"; + var limit = accessToken != null ? McpConfig.ToolCallsPerMinute : McpConfig.UnauthenticatedCallsPerMinute; + + if (!await _rateLimiter.IsAllowedAsync(clientId, ToolCallOperation, limit)) + { + throw new McpToolErrorException(McpToolErrorException.RateLimited, + $"Rate limit reached: at most {limit} tool calls per minute. Wait before calling again."); + } + } + + private static string ReadAccessToken(object arguments) + { + if (arguments is JsonElement { ValueKind: JsonValueKind.Object } element + && element.TryGetProperty("accessToken", out var token) + && token.ValueKind == JsonValueKind.String + && !string.IsNullOrWhiteSpace(token.GetString())) + { + return token.GetString(); + } + + return null; + } + + /// A short hash identifying a token, so the token itself is never used as a key or written to a log. + private static string Fingerprint(string accessToken) + { + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(accessToken)), 0, 8); + } + + /// + /// Tools report a failure as { success = false, error }. MCP clients rely on isError instead, to tell a failed + /// call from data the model should use. + /// + private static bool IsFailedToolResult(JToken result) + { + return result is JObject obj + && obj["success"]?.Type == JTokenType.Boolean + && !obj.Value("success"); + } + private sealed class ToolDefinition { public string Name { get; set; } diff --git a/Web/Resgrid.Web.Mcp/ModelContextProtocol/McpToolErrorException.cs b/Web/Resgrid.Web.Mcp/ModelContextProtocol/McpToolErrorException.cs new file mode 100644 index 000000000..2a72bf3a1 --- /dev/null +++ b/Web/Resgrid.Web.Mcp/ModelContextProtocol/McpToolErrorException.cs @@ -0,0 +1,29 @@ +using System; + +namespace Resgrid.Web.Mcp.ModelContextProtocol +{ + /// + /// A tool failure the MCP client can act on, such as refreshing an expired access token. McpServer returns it as the + /// tool's result ({ success = false, errorCode, error }) instead of the generic failure message the tool's own + /// catch-all would give, so tool handlers must let it pass: catch (Exception ex) when (ex is not McpToolErrorException). + /// + public sealed class McpToolErrorException : Exception + { + /// The access token was rejected (expired, or its session revoked): refresh it, then retry. + public const string AccessTokenExpired = "access_token_expired"; + + /// The user is signed in but not permitted to do this; a new token will not help. + public const string Forbidden = "forbidden"; + + /// The client made too many tool calls in the last minute: wait, then retry. + public const string RateLimited = "rate_limited"; + + public McpToolErrorException(string errorCode, string message) + : base(message) + { + ErrorCode = errorCode; + } + + public string ErrorCode { get; } + } +} diff --git a/Web/Resgrid.Web.Mcp/Resgrid.Web.Mcp.csproj b/Web/Resgrid.Web.Mcp/Resgrid.Web.Mcp.csproj index c7eebb47e..30086d708 100644 --- a/Web/Resgrid.Web.Mcp/Resgrid.Web.Mcp.csproj +++ b/Web/Resgrid.Web.Mcp/Resgrid.Web.Mcp.csproj @@ -42,6 +42,7 @@ + diff --git a/Web/Resgrid.Web.Mcp/Startup.cs b/Web/Resgrid.Web.Mcp/Startup.cs index 29f1d6cf5..304312021 100644 --- a/Web/Resgrid.Web.Mcp/Startup.cs +++ b/Web/Resgrid.Web.Mcp/Startup.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Resgrid.Config; +using Resgrid.Web.Helpers; using Resgrid.Web.Mcp.Infrastructure; using Resgrid.Web.Mcp.ModelContextProtocol; using Resgrid.Web.Mcp.Tools; @@ -34,7 +35,7 @@ public void ConfigureServices(IServiceCollection services) var logger = sp.GetRequiredService>(); var serverName = McpConfig.ServerName; var serverVersion = McpConfig.ServerVersion; - var mcpServer = new McpServer(serverName, serverVersion, logger); + var mcpServer = new McpServer(serverName, serverVersion, logger, sp.GetRequiredService()); // Register tools with the server var toolRegistry = sp.GetRequiredService(); @@ -54,6 +55,11 @@ public void ConfigureServices(IServiceCollection services) services.AddHostedService(); } + // Behind the ingress proxy every request arrives from the proxy's address. Trusting its X-Forwarded-For lets + // tool calls made without an access token be rate limited per real client. + services.Configure(options => + ForwardedHeadersSetup.Configure(options, WebConfig.IngressProxyNetwork, WebConfig.IngressProxyNetworkCidr)); + // Add MVC controllers for MCP and health check endpoints services.AddControllers() .AddNewtonsoftJson(); @@ -138,6 +144,8 @@ public void ConfigureServices(IServiceCollection services) public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { + app.UseForwardedHeaders(); + // Enable CORS if configured if (McpConfig.EnableCors) { diff --git a/Web/Resgrid.Web.Mcp/Tools/AuthenticationToolProvider.cs b/Web/Resgrid.Web.Mcp/Tools/AuthenticationToolProvider.cs index acb16940d..24e3880d4 100644 --- a/Web/Resgrid.Web.Mcp/Tools/AuthenticationToolProvider.cs +++ b/Web/Resgrid.Web.Mcp/Tools/AuthenticationToolProvider.cs @@ -5,6 +5,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; +using Resgrid.Web.Mcp.Infrastructure; using Resgrid.Web.Mcp.ModelContextProtocol; using Newtonsoft.Json; @@ -16,16 +17,25 @@ namespace Resgrid.Web.Mcp.Tools public sealed class AuthenticationToolProvider { private readonly IApiClient _apiClient; + private readonly ITokenRefreshService _tokenRefreshService; private readonly ILogger _logger; private const string TOOL_NAME = "authenticate"; + private const string REFRESH_TOOL_NAME = "refresh_access_token"; - public AuthenticationToolProvider(IApiClient apiClient, ILogger logger) + public AuthenticationToolProvider(IApiClient apiClient, ITokenRefreshService tokenRefreshService, ILogger logger) { _apiClient = apiClient; + _tokenRefreshService = tokenRefreshService; _logger = logger; } public void RegisterTools(McpServer server) + { + RegisterAuthenticateTool(server); + RegisterRefreshAccessTokenTool(server); + } + + private void RegisterAuthenticateTool(McpServer server) { server.AddTool( TOOL_NAME, @@ -77,7 +87,9 @@ public void RegisterTools(McpServer server) accessToken = result.AccessToken, tokenType = result.TokenType, expiresIn = result.ExpiresIn, - message = "Authentication successful. Use this access token in subsequent API calls." + refreshToken = result.RefreshToken, + message = "Authentication successful. Use this access token in subsequent API calls. " + + $"Before it expires (in expiresIn seconds), call {REFRESH_TOOL_NAME} with the refresh token to get a new pair; each refresh token works once." }; } else @@ -90,7 +102,7 @@ public void RegisterTools(McpServer server) }; } } - catch (Exception ex) + catch (Exception ex) when (ex is not McpToolErrorException) { _logger.LogError(ex, "Error in authentication tool"); return new @@ -103,9 +115,74 @@ public void RegisterTools(McpServer server) ); } + private void RegisterRefreshAccessTokenTool(McpServer server) + { + var schema = SchemaBuilder.BuildObjectSchema( + new Dictionary + { + ["refreshToken"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "The refresh token from authenticate or from the previous refresh_access_token call" } + }, + new[] { "refreshToken" } + ); + + server.AddTool( + REFRESH_TOOL_NAME, + "Exchanges a refresh token for a new access token and a new refresh token, without asking for the password again. " + + "Each refresh token works once: always keep the new one this returns. If it fails, authenticate again.", + schema, + async (arguments) => + { + try + { + var args = JsonConvert.DeserializeObject(arguments.ToString()); + + if (string.IsNullOrWhiteSpace(args?.RefreshToken)) + { + return new + { + success = false, + error = "Refresh token is required" + }; + } + + var result = await _tokenRefreshService.RefreshAsync(args.RefreshToken); + + if (result.IsSuccess) + { + return new + { + success = true, + accessToken = result.AccessToken, + tokenType = result.TokenType, + expiresIn = result.ExpiresIn, + refreshToken = result.RefreshToken, + message = "Token refreshed. Use the new access token and keep the new refresh token; the old refresh token no longer works." + }; + } + + return new + { + success = false, + error = $"{(result.ErrorMessage ?? "Token refresh failed").TrimEnd('.')}. Call authenticate to sign in again." + }; + } + catch (Exception ex) when (ex is not McpToolErrorException) + { + _logger.LogError(ex, "Error in refresh access token tool"); + return new + { + success = false, + error = "Token refresh failed. Call authenticate to sign in again." + }; + } + } + ); + } + public IEnumerable GetToolNames() { yield return TOOL_NAME; + yield return REFRESH_TOOL_NAME; } /// @@ -143,6 +220,12 @@ private sealed class AuthenticateArgs [JsonProperty("password")] public string Password { get; set; } } + + private sealed class RefreshArgs + { + [JsonProperty("refreshToken")] + public string RefreshToken { get; set; } + } } } diff --git a/Web/Resgrid.Web.Mcp/Tools/CalendarToolProvider.cs b/Web/Resgrid.Web.Mcp/Tools/CalendarToolProvider.cs index 9b9570214..72b110be2 100644 --- a/Web/Resgrid.Web.Mcp/Tools/CalendarToolProvider.cs +++ b/Web/Resgrid.Web.Mcp/Tools/CalendarToolProvider.cs @@ -77,7 +77,7 @@ private void RegisterGetCalendarItemsTool(McpServer server) return new { success = true, data = result }; } - catch (Exception ex) + catch (Exception ex) when (ex is not McpToolErrorException) { _logger.LogError(ex, "Error retrieving calendar items"); return CreateErrorResponse("Failed to retrieve calendar items. Please try again later."); diff --git a/Web/Resgrid.Web.Mcp/Tools/CallsToolProvider.cs b/Web/Resgrid.Web.Mcp/Tools/CallsToolProvider.cs index 67db7dec6..91db9f01c 100644 --- a/Web/Resgrid.Web.Mcp/Tools/CallsToolProvider.cs +++ b/Web/Resgrid.Web.Mcp/Tools/CallsToolProvider.cs @@ -87,7 +87,7 @@ private void RegisterGetActiveCallsTool(McpServer server) data = result }; } - catch (Exception ex) + catch (Exception ex) when (ex is not McpToolErrorException) { _logger.LogError(ex, "Error retrieving active calls"); transaction.Status = SpanStatus.InternalError; @@ -166,7 +166,7 @@ private void RegisterGetCallDetailsTool(McpServer server) data = result }; } - catch (Exception ex) + catch (Exception ex) when (ex is not McpToolErrorException) { _logger.LogError(ex, "Error retrieving call details"); transaction.Status = SpanStatus.InternalError; @@ -273,7 +273,7 @@ private void RegisterCreateCallTool(McpServer server) message = "Call created successfully" }; } - catch (Exception ex) + catch (Exception ex) when (ex is not McpToolErrorException) { _logger.LogError(ex, "Error creating call"); return CreateErrorResponse("Failed to create call. Please try again later."); @@ -348,7 +348,7 @@ private void RegisterCloseCallTool(McpServer server) message = "Call closed successfully" }; } - catch (Exception ex) + catch (Exception ex) when (ex is not McpToolErrorException) { _logger.LogError(ex, "Error closing call"); return CreateErrorResponse("Failed to close call. Please try again later."); diff --git a/Web/Resgrid.Web.Mcp/Tools/DispatchToolProvider.cs b/Web/Resgrid.Web.Mcp/Tools/DispatchToolProvider.cs index ed4518690..a4f925c22 100644 --- a/Web/Resgrid.Web.Mcp/Tools/DispatchToolProvider.cs +++ b/Web/Resgrid.Web.Mcp/Tools/DispatchToolProvider.cs @@ -85,7 +85,7 @@ private void RegisterGetDispatchStatusTool(McpServer server) } }; } - catch (Exception ex) + catch (Exception ex) when (ex is not McpToolErrorException) { _logger.LogError(ex, "Error retrieving dispatch status"); return CreateErrorResponse("Failed to retrieve dispatch status. Please try again later."); diff --git a/Web/Resgrid.Web.Mcp/Tools/InventoryToolProvider.cs b/Web/Resgrid.Web.Mcp/Tools/InventoryToolProvider.cs index 6a74f1275..e6be42fcf 100644 --- a/Web/Resgrid.Web.Mcp/Tools/InventoryToolProvider.cs +++ b/Web/Resgrid.Web.Mcp/Tools/InventoryToolProvider.cs @@ -72,7 +72,7 @@ private void RegisterGetInventoryTool(McpServer server) return new { success = true, data = result }; } - catch (Exception ex) + catch (Exception ex) when (ex is not McpToolErrorException) { _logger.LogError("Error retrieving inventory ({ExceptionType})", ex.GetType().Name); return CreateErrorResponse("Failed to retrieve inventory. Please try again later."); @@ -120,7 +120,7 @@ private void RegisterGetInventoryItemTool(McpServer server) return new { success = true, data = result }; } - catch (Exception ex) + catch (Exception ex) when (ex is not McpToolErrorException) { _logger.LogError("Error retrieving inventory item ({ExceptionType})", ex.GetType().Name); return CreateErrorResponse("Failed to retrieve inventory item. Please try again later."); @@ -196,7 +196,7 @@ private void RegisterUpdateInventoryTool(McpServer server) return new { success = true, data = result, message = "Inventory adjustment request accepted" }; } - catch (Exception ex) + catch (Exception ex) when (ex is not McpToolErrorException) { _logger.LogError("Error updating inventory ({ExceptionType})", ex.GetType().Name); return CreateErrorResponse("The inventory adjustment could not be confirmed. Retry with the same requestId and unchanged values. Protected data requires the Inventory app."); @@ -244,7 +244,7 @@ private void RegisterLowStockItemsTool(McpServer server) return new { success = true, data = result }; } - catch (Exception ex) + catch (Exception ex) when (ex is not McpToolErrorException) { _logger.LogError("Error retrieving low stock items ({ExceptionType})", ex.GetType().Name); return CreateErrorResponse("Failed to retrieve low stock items. Please try again later."); diff --git a/Web/Resgrid.Web.Mcp/Tools/MessagesToolProvider.cs b/Web/Resgrid.Web.Mcp/Tools/MessagesToolProvider.cs index 8d959b1c3..bcc24d41c 100644 --- a/Web/Resgrid.Web.Mcp/Tools/MessagesToolProvider.cs +++ b/Web/Resgrid.Web.Mcp/Tools/MessagesToolProvider.cs @@ -72,7 +72,7 @@ private void RegisterGetInboxTool(McpServer server) return new { success = true, data = result }; } - catch (Exception ex) + catch (Exception ex) when (ex is not McpToolErrorException) { _logger.LogError(ex, "Error retrieving inbox"); return CreateErrorResponse("Failed to retrieve inbox. Please try again later."); @@ -118,7 +118,7 @@ private void RegisterGetOutboxTool(McpServer server) return new { success = true, data = result }; } - catch (Exception ex) + catch (Exception ex) when (ex is not McpToolErrorException) { _logger.LogError(ex, "Error retrieving outbox"); return CreateErrorResponse("Failed to retrieve outbox. Please try again later."); @@ -193,7 +193,7 @@ private void RegisterSendMessageTool(McpServer server) return new { success = true, data = result, message = "Message sent successfully" }; } - catch (Exception ex) + catch (Exception ex) when (ex is not McpToolErrorException) { _logger.LogError(ex, "Error sending message"); return CreateErrorResponse("Failed to send message. Please try again later."); @@ -240,7 +240,7 @@ private void RegisterGetMessageTool(McpServer server) return new { success = true, data = result }; } - catch (Exception ex) + catch (Exception ex) when (ex is not McpToolErrorException) { _logger.LogError(ex, "Error retrieving message"); return CreateErrorResponse("Failed to retrieve message. Please try again later."); @@ -287,7 +287,7 @@ private void RegisterDeleteMessageTool(McpServer server) return new { success, message = success ? "Message deleted successfully" : "Failed to delete message" }; } - catch (Exception ex) + catch (Exception ex) when (ex is not McpToolErrorException) { _logger.LogError(ex, "Error deleting message"); return CreateErrorResponse("Failed to delete message. Please try again later."); diff --git a/Web/Resgrid.Web.Mcp/Tools/PersonnelToolProvider.cs b/Web/Resgrid.Web.Mcp/Tools/PersonnelToolProvider.cs index 5112e8b31..54bb35291 100644 --- a/Web/Resgrid.Web.Mcp/Tools/PersonnelToolProvider.cs +++ b/Web/Resgrid.Web.Mcp/Tools/PersonnelToolProvider.cs @@ -75,7 +75,7 @@ private void RegisterGetPersonnelTool(McpServer server) data = result }; } - catch (Exception ex) + catch (Exception ex) when (ex is not McpToolErrorException) { _logger.LogError(ex, "Error retrieving personnel"); return CreateErrorResponse("Failed to retrieve personnel. Please try again later."); @@ -125,7 +125,7 @@ private void RegisterGetPersonnelStatusTool(McpServer server) data = V4ResponseReader.Project(V4ResponseReader.GetDataArray(result), V4ResponseReader.PersonnelStatusFields) }; } - catch (Exception ex) + catch (Exception ex) when (ex is not McpToolErrorException) { _logger.LogError(ex, "Error retrieving personnel statuses"); return CreateErrorResponse("Failed to retrieve personnel statuses. Please try again later."); @@ -198,7 +198,7 @@ private void RegisterSetPersonnelStatusTool(McpServer server) message = "Personnel status updated successfully" }; } - catch (Exception ex) + catch (Exception ex) when (ex is not McpToolErrorException) { _logger.LogError(ex, "Error setting personnel status"); return CreateErrorResponse("Failed to set personnel status. Please try again later."); @@ -250,7 +250,7 @@ private void RegisterGetPersonnelLocationTool(McpServer server) data = V4ResponseReader.GetMapMarkers(result, V4ResponseReader.PersonnelMarkerType, "UserId") }; } - catch (Exception ex) + catch (Exception ex) when (ex is not McpToolErrorException) { _logger.LogError(ex, "Error retrieving personnel locations"); return CreateErrorResponse("Failed to retrieve personnel locations. Please try again later."); diff --git a/Web/Resgrid.Web.Mcp/Tools/ReportsToolProvider.cs b/Web/Resgrid.Web.Mcp/Tools/ReportsToolProvider.cs index 350ab213a..e4a17d70e 100644 --- a/Web/Resgrid.Web.Mcp/Tools/ReportsToolProvider.cs +++ b/Web/Resgrid.Web.Mcp/Tools/ReportsToolProvider.cs @@ -96,7 +96,7 @@ private void RegisterReportTool(McpServer server, string toolName, string descri return new { success = true, data = result }; } - catch (Exception ex) + catch (Exception ex) when (ex is not McpToolErrorException) { _logger.LogError(ex, "Error generating {Report}", toolName); return CreateErrorResponse("Failed to generate report. Please try again later."); diff --git a/Web/Resgrid.Web.Mcp/Tools/ShiftsToolProvider.cs b/Web/Resgrid.Web.Mcp/Tools/ShiftsToolProvider.cs index e3f4d5b82..e60d4cd33 100644 --- a/Web/Resgrid.Web.Mcp/Tools/ShiftsToolProvider.cs +++ b/Web/Resgrid.Web.Mcp/Tools/ShiftsToolProvider.cs @@ -70,7 +70,7 @@ private void RegisterGetShiftsTool(McpServer server) return new { success = true, data = result }; } - catch (Exception ex) + catch (Exception ex) when (ex is not McpToolErrorException) { _logger.LogError(ex, "Error retrieving shifts"); return CreateErrorResponse("Failed to retrieve shifts. Please try again later."); @@ -117,7 +117,7 @@ private void RegisterGetShiftDetailsTool(McpServer server) return new { success = true, data = result }; } - catch (Exception ex) + catch (Exception ex) when (ex is not McpToolErrorException) { _logger.LogError(ex, "Error retrieving shift details"); return CreateErrorResponse("Failed to retrieve shift details. Please try again later."); @@ -163,7 +163,7 @@ private void RegisterGetCurrentShiftTool(McpServer server) return new { success = true, data = result }; } - catch (Exception ex) + catch (Exception ex) when (ex is not McpToolErrorException) { _logger.LogError(ex, "Error retrieving current shift"); return CreateErrorResponse("Failed to retrieve current shift. Please try again later."); @@ -229,7 +229,7 @@ private void RegisterSignupForShiftTool(McpServer server) return new { success = true, data = result, message = "Successfully signed up for shift" }; } - catch (Exception ex) + catch (Exception ex) when (ex is not McpToolErrorException) { _logger.LogError(ex, "Error signing up for shift"); return CreateErrorResponse("Failed to sign up for shift. Please try again later."); diff --git a/Web/Resgrid.Web.Mcp/Tools/UnitsToolProvider.cs b/Web/Resgrid.Web.Mcp/Tools/UnitsToolProvider.cs index b73508275..39525acdf 100644 --- a/Web/Resgrid.Web.Mcp/Tools/UnitsToolProvider.cs +++ b/Web/Resgrid.Web.Mcp/Tools/UnitsToolProvider.cs @@ -75,7 +75,7 @@ private void RegisterGetUnitsTool(McpServer server) data = result }; } - catch (Exception ex) + catch (Exception ex) when (ex is not McpToolErrorException) { _logger.LogError(ex, "Error retrieving units"); return CreateErrorResponse("Failed to retrieve units. Please try again later."); @@ -125,7 +125,7 @@ private void RegisterGetUnitStatusTool(McpServer server) data = result }; } - catch (Exception ex) + catch (Exception ex) when (ex is not McpToolErrorException) { _logger.LogError(ex, "Error retrieving unit statuses"); return CreateErrorResponse("Failed to retrieve unit statuses. Please try again later."); @@ -203,7 +203,7 @@ private void RegisterSetUnitStatusTool(McpServer server) message = "Unit status updated successfully" }; } - catch (Exception ex) + catch (Exception ex) when (ex is not McpToolErrorException) { _logger.LogError(ex, "Error setting unit status"); return CreateErrorResponse("Failed to set unit status. Please try again later."); @@ -255,7 +255,7 @@ private void RegisterGetUnitLocationTool(McpServer server) data = V4ResponseReader.GetMapMarkers(result, V4ResponseReader.UnitMarkerType, "UnitId") }; } - catch (Exception ex) + catch (Exception ex) when (ex is not McpToolErrorException) { _logger.LogError(ex, "Error retrieving unit locations"); return CreateErrorResponse("Failed to retrieve unit locations. Please try again later."); diff --git a/Web/Resgrid.Web.Services/Controllers/SignalWireController.cs b/Web/Resgrid.Web.Services/Controllers/SignalWireController.cs index 2fc551d03..e8ff4a1d6 100644 --- a/Web/Resgrid.Web.Services/Controllers/SignalWireController.cs +++ b/Web/Resgrid.Web.Services/Controllers/SignalWireController.cs @@ -230,11 +230,9 @@ public async Task Receive(CancellationToken cancellationToken) if (!String.IsNullOrWhiteSpace(dispatchNumbers)) isDispatchSource = _numbersService.DoesNumberMatchAnyPattern(dispatchNumbers.Split(Char.Parse(",")).ToList(), textMessage.Msisdn); - // If we don't have dispatchNumbers and Text Command isn't enabled it's a dispatch text - if (!isDispatchSource && !textCommandEnabled) - isDispatchSource = true; + var routing = TextIntakeRouting.Decide(TextIntakePath.SignalWire, isDispatchSource, textToCallEnabled, textCommandEnabled); - if (isDispatchSource && textToCallEnabled) + if (routing.CallBranch) { var users = await _departmentsService.GetAllUsersForDepartmentAsync(departmentId.Value, true); var c = await BuildTextToCallAsync(department, textMessage, users); @@ -272,7 +270,7 @@ public async Task Receive(CancellationToken cancellationToken) messageEvent.Processed = true; } - if (!isDispatchSource && textCommandEnabled && profile != null) + if (routing.CommandBranch && profile != null) { var request = new ChatbotMessage { diff --git a/Web/Resgrid.Web.Services/Controllers/TwilioController.cs b/Web/Resgrid.Web.Services/Controllers/TwilioController.cs index 71e9ce1ed..3d6f23521 100644 --- a/Web/Resgrid.Web.Services/Controllers/TwilioController.cs +++ b/Web/Resgrid.Web.Services/Controllers/TwilioController.cs @@ -60,6 +60,9 @@ public class TwilioController : ControllerBase private readonly ITextDepartmentSwitchService _textDepartmentSwitchService; private readonly IDispatchRecommendationService _dispatchRecommendationService; private readonly IDispatchScopeService _dispatchScopeService; + private readonly Model.Services.IAuthorizationService _authorizationService; + private readonly IAdpReleaseService _adpRelease; + private readonly IProtectedProjectionService _adpProjection; public TwilioController(IDepartmentSettingsService departmentSettingsService, INumbersService numbersService, ILimitsService limitsService, ICallsService callsService, IQueueService queueService, IDepartmentsService departmentsService, @@ -69,9 +72,13 @@ public TwilioController(IDepartmentSettingsService departmentSettingsService, IN IUsersService usersService, ICalendarService calendarService, ICommunicationTestService communicationTestService, IEncryptionService encryptionService, ITwilioVoiceResponseService twilioVoiceResponseService, IFeatureToggleService featureToggleService, ITextDepartmentSwitchService textDepartmentSwitchService, - IDispatchRecommendationService dispatchRecommendationService, IDispatchScopeService dispatchScopeService) + IDispatchRecommendationService dispatchRecommendationService, IDispatchScopeService dispatchScopeService, + Model.Services.IAuthorizationService authorizationService, IAdpReleaseService adpRelease, IProtectedProjectionService adpProjection) { _dispatchScopeService = dispatchScopeService; + _authorizationService = authorizationService; + _adpRelease = adpRelease; + _adpProjection = adpProjection; _departmentSettingsService = departmentSettingsService; _numbersService = numbersService; _limitsService = limitsService; @@ -145,12 +152,18 @@ private CancellationToken GetTtsPromptBudgetToken() && !(HttpContext?.RequestAborted.IsCancellationRequested ?? false); [HttpGet("IncomingMessage")] + [HttpPost("IncomingMessage")] [Produces("application/xml")] // Twilio signature validation: without it anyone who learns a member's mobile number can // forge inbound SMS and run text commands (respond, staffing, text-to-call) as that member. [ValidateRequest] public async Task IncomingMessage([FromQuery] TwilioMessage request) { + if (Request.HasFormContentType) + { + var form = await Request.ReadFormAsync(); + request = new TwilioMessage { To = form["To"], From = form["From"], Body = form["Body"], MessageSid = form["MessageSid"] }; + } if (request == null || string.IsNullOrWhiteSpace(request.To) || string.IsNullOrWhiteSpace(request.From) || string.IsNullOrWhiteSpace(request.Body)) return BadRequest(); // WhatsApp must use its separately authenticated native endpoint and explicit account link. @@ -159,6 +172,16 @@ public async Task IncomingMessage([FromQuery] TwilioMessage reques var response = new MessagingResponse(); + var pinCommand = request.Body.Trim().Split((char[])null, StringSplitOptions.RemoveEmptyEntries); + if (pinCommand[0].Equals("OPEN", StringComparison.OrdinalIgnoreCase)) + { + var text = Microsoft.AspNetCore.Http.HttpMethods.IsPost(Request.Method) && Request.HasFormContentType && pinCommand.Length == 3 + ? await _adpRelease.ReleaseAsync(pinCommand[1].ToUpperInvariant(), request.From, pinCommand[2], ProtectedDataEgressChannel.Sms) : null; + var profile = await _userProfileService.GetProfileByMobileNumberAsync(request.From.Replace("+", "")); + response.Message(text ?? Resgrid.Localization.Areas.User.SystemMessages.SystemMessagesResources.Get("AdpPinDenied", profile?.Language)); + return Content(response.ToString(), "application/xml"); + } + var textMessage = new TextMessage(); textMessage.To = request.To.Replace("+", ""); textMessage.Msisdn = request.From.Replace("+", ""); @@ -352,7 +375,9 @@ private async System.Threading.Tasks.Task ProcessTextCommandsAsync(TextMessage t if (!String.IsNullOrWhiteSpace(dispatchNumbers)) isDispatchSource = _numbersService.DoesNumberMatchAnyPattern(dispatchNumbers.Split(Char.Parse(",")).ToList(), textMessage.Msisdn); - if (isDispatchSource) + var routing = TextIntakeRouting.Decide(TextIntakePath.TwilioLegacy, isDispatchSource, false, false); + + if (routing.CallBranch) { var users = await _departmentsService.GetAllUsersForDepartmentAsync(departmentId.Value, true); var c = await BuildTextToCallAsync(department, textMessage, users); @@ -401,7 +426,7 @@ private async System.Threading.Tasks.Task ProcessTextCommandsAsync(TextMessage t messageEvent.Processed = true; } - if (!isDispatchSource) + if (routing.CommandBranch) { // 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. @@ -598,6 +623,10 @@ private async System.Threading.Tasks.Task ProcessTextCommandsAsync(TextMessage t foreach (var unit in unitStatus) { + // Security > View Units, the same filter the unit lists apply. + if (!await _authorizationService.CanUserViewUnitViaMatrixAsync(unit.UnitId, profile.UserId, department.DepartmentId)) + continue; + var unitState = await _customStateService.GetCustomUnitStateAsync(unit); unitStatusesText.Append($"{unit.Unit.Name} is {unitState.ButtonText}" + Environment.NewLine); } @@ -609,9 +638,11 @@ private async System.Threading.Tasks.Task ProcessTextCommandsAsync(TextMessage t 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) + // Guard against a missing call (NRE), against reading a call that belongs to another + // department (cross-department data leakage), and, with group-scoped dispatch on, against + // a call outside the texter's area that the CALLS list would not have shown them. + if (call == null || call.DepartmentId != department.DepartmentId + || !await _dispatchScopeService.CanUserAccessCallAsync(department.DepartmentId, profile.UserId, call)) { response.Message("Resgrid could not find that call."); break; @@ -772,6 +803,22 @@ public async Task VoiceCall(string userId, int callId, [FromQuery] return CreateVoiceContentResult(response); } + // The signed provider callback binds this challenge to the dialed, verified phone. + var destination = Request.Query["To"].ToString(); + string challenge = null; + try { challenge = await _adpRelease.CreateChallengeAsync(call.DepartmentId, callId, userId, destination, ProtectedDataEgressChannel.Voice); } + catch (Exception) { Logging.LogError($"ADP voice challenge unavailable for department {call.DepartmentId}; using the safe prompt."); } + if (challenge != null) + { + var profile = await _userProfileService.GetProfileByUserIdAsync(userId); + var gatherPin = new Gather(action: new Uri($"{Config.SystemBehaviorConfig.ResgridApiBaseUrl}/api/Twilio/AdpVoicePin?challenge={challenge}"), method: "POST", finishOnKey: "#"); + gatherPin.Say(Resgrid.Localization.Areas.User.SystemMessages.SystemMessagesResources.Get("AdpPinVoiceChallenge", profile?.Language)); + response.Append(gatherPin); + response.Hangup(); + return CreateVoiceContentResult(response); + } + call = await _adpProjection.BuildNotificationSafeCallAsync(call.DepartmentId, call, ProtectedDataEgressChannel.Voice); + // Load the department's custom priority so GetPriorityText() speaks its real // name. The broadcast worker loads it the same way before pre-warming the // dispatch TTS — the prompt text (and therefore the TTS cache key) must match. @@ -850,6 +897,19 @@ public async Task VoiceCall(string userId, int callId, [FromQuery] return CreateVoiceContentResult(response); } + [HttpPost("AdpVoicePin")] + [ValidateRequest] + public async Task AdpVoicePin([FromQuery] string challenge, [FromForm] VoiceRequest request) + { + var response = new VoiceResponse(); + var text = await _adpRelease.ReleaseAsync(challenge, request.To, request.Digits, ProtectedDataEgressChannel.Voice); + // Say sends no protected audio into Resgrid's shared TTS/URL cache. + foreach (var chunk in DispatchVoicePromptBuilder.ChunkText(text ?? Resgrid.Localization.Areas.User.SystemMessages.SystemMessagesResources.Get("AdpPinDenied", null))) + response.Say(chunk); + response.Hangup(); + return CreateVoiceContentResult(response); + } + [HttpGet("VoiceCallAction")] [Produces("application/xml")] [ValidateRequest] @@ -1238,6 +1298,10 @@ public async Task InboundVoiceAction(string userId, [FromQuery] Vo foreach (var unit in units) { + // Security > View Units, the same filter the unit lists apply. + if (!await _authorizationService.CanUserViewUnitViaMatrixAsync(unit.UnitId, userId, department.DepartmentId)) + continue; + var unitState = states.FirstOrDefault(x => x.UnitId == unit.UnitId); var unitStatus = await _customStateService.GetCustomUnitStateAsync(unitState); @@ -1247,7 +1311,8 @@ public async Task InboundVoiceAction(string userId, [FromQuery] Vo lines.Add($"{unit.Name}, Status {unitStatus?.ButtonText ?? "Unknown"}."); } - prompts.Add(string.Join(" ", lines)); + // Every unit may be outside what the caller can view; say so rather than speak an empty prompt. + prompts.Add(lines.Any() ? string.Join(" ", lines) : $"There are no units for department {department.Name}."); } else { diff --git a/Web/Resgrid.Web.Services/Controllers/v4/AdminAssistController.cs b/Web/Resgrid.Web.Services/Controllers/v4/AdminAssistController.cs new file mode 100644 index 000000000..1edac1311 --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/AdminAssistController.cs @@ -0,0 +1,182 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Localization; +using Resgrid.Model.AdminAssist; +using Labels = Resgrid.Localization.Areas.User.AdminAssist.AdminAssist; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// Deterministic department setup and configuration guidance. No configuration mutation or model calls. + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + public sealed class AdminAssistController(IAdminAssistService service, IAdminAssistAccessService access, + IAdminAssistCatalog catalog, IStringLocalizer labels, IAdminAssistReferenceSearch referenceSearch, + IAdminAssistWorklistService worklist, IAdminAssistMaintenanceStore maintenance, IConfigurationImpactService impacts, + IDispatchImpactService dispatchImpacts, IPermissionImpactService permissionImpacts, IModuleImpactService moduleImpacts, ITextImportImpactService textImportImpacts, IRetentionImpactService retentionImpacts, INotificationImpactService notificationImpacts, ISecurityImpactService securityImpacts) : V4AuthenticatedApiControllerbase + { + private AdminAssistActor Actor => new(DepartmentId, UserId, CultureInfo.CurrentUICulture.Name); + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + Converters = { new JsonStringEnumConverter() } + }; + + /// Read fresh, authorized configuration evidence and setup progress. + [HttpGet("Overview")] + public Task Overview(bool setup, CancellationToken cancellationToken) => ExecuteAsync(async () => + await service.GetOverviewAsync(Actor, setup, cancellationToken)); + + /// Read the release-pinned public setup and feature catalog. + [HttpGet("Catalog")] + public Task Catalog(bool setup, CancellationToken cancellationToken) => ExecuteAsync(async () => + { + if (!await access.CanAccessAsync(Actor, setup, cancellationToken)) throw new UnauthorizedAccessException(); + return new + { + CanSetup = await access.CanAccessAsync(Actor, true, cancellationToken), + ImpactSettings = catalog.Settings.Where(s => Resgrid.AdminAssist.ConfigurationImpactEvaluator.Supports(s.Id)).Select(s => s.Id).ToArray(), + ModuleImpactTypes = ModuleImpactSelection.Supported, + PermissionImpactTypes = Resgrid.Services.AdminAssist.PermissionImpactService.Supported, + catalog.Version, catalog.Areas, catalog.Capabilities, catalog.Settings, catalog.Packs, catalog.Articles, + Strings = labels.GetAllStrings(true).GroupBy(s => s.Name).ToDictionary(g => g.Key, g => g.First().Value), + RightToLeft = CultureInfo.CurrentUICulture.TextInfo.IsRightToLeft + }; + }); + + /// Update setup choices and personal learning metadata. + [HttpPost("Setup")] + [RequestSizeLimit(8192)] + public Task Setup([FromBody] SetupProgressCommand command, CancellationToken cancellationToken) => + ExecuteAsync(async () => await service.UpdateSetupAsync(Actor, command, cancellationToken)); + + /// Search the allowlisted public reference corpus. + [HttpGet("Search")] + public Task Search(string query, bool setup, CancellationToken cancellationToken) => ExecuteAsync(async () => + { + if (!await access.CanAccessAsync(Actor, setup, cancellationToken)) throw new UnauthorizedAccessException(); + return referenceSearch.Search(query, Actor.Locale); + }); + + /// Read tenant history with personal learning choices restricted to the current administrator. + [HttpGet("History")] + public Task History(int skip, int take = 30, CancellationToken cancellationToken = default) => + ExecuteAsync(async () => await service.GetHistoryAsync(Actor, skip, take, cancellationToken)); + + /// Read current findings through the department protection policy. + [HttpGet("Worklist")] + public Task Worklist(CancellationToken cancellationToken) => ExecuteAsync(async () => await worklist.GetAsync(Actor, cancellationToken)); + + /// Evaluate fresh evidence and persist meaningful finding transitions. + [HttpPost("Verify")] + [RequestSizeLimit(1024)] + public Task Verify(CancellationToken cancellationToken) => ExecuteAsync(async () => + { + await worklist.VerifyAsync(Actor, cancellationToken); + return new { verified = true }; + }); + + /// Update finding ownership or review metadata without changing the rule result. + [HttpPost("Review")] + [RequestSizeLimit(16384)] + public Task Review([FromBody] FindingReviewCommand command, CancellationToken cancellationToken) => ExecuteAsync(async () => + { + await worklist.ReviewAsync(Actor, command, cancellationToken); + return new { saved = true }; + }); + + /// Preview a scalar proposal against fresh evidence without saving configuration. + [HttpPost("Impact")] + [RequestSizeLimit(2048)] + public Task Impact([FromBody] ConfigurationImpactRequest request, CancellationToken cancellationToken) => + ExecuteAsync(async () => await impacts.PreviewAsync(Actor, request, cancellationToken)); + + /// Preview base-plan headroom for proposed total personnel and unit counts without provisioning. + [HttpPost("CapacityImpact")] + [RequestSizeLimit(1024)] + public Task CapacityImpact([FromBody] CapacityImpactRequest request, CancellationToken cancellationToken) => + ExecuteAsync(async () => await impacts.PreviewCapacityAsync(Actor, request, cancellationToken)); + + /// Simulate a saved call's routes using current authorized membership and an explicit roster time; never sends. + [HttpPost("DispatchImpact")] + [RequestSizeLimit(2048)] + public Task DispatchImpact([FromBody] DispatchImpactRequest request, CancellationToken cancellationToken) => + ExecuteAsync(async () => await dispatchImpacts.PreviewAsync(Actor, request, cancellationToken)); + + /// Compare the proposed permission gate for current members and resource targets; never changes access. + [HttpPost("PermissionImpact")] + [RequestSizeLimit(4096)] + public Task PermissionImpact([FromBody] PermissionImpactRequest request, CancellationToken cancellationToken) => + ExecuteAsync(async () => await permissionImpacts.PreviewAsync(Actor, request, cancellationToken)); + + /// List current department roles for a permission proposal, without exposing personnel details. + [HttpGet("PermissionRoles")] + public Task PermissionRoles(string expectedRevision, CancellationToken cancellationToken) => + ExecuteAsync(async () => await permissionImpacts.GetRoleOptionsAsync(Actor, expectedRevision, cancellationToken)); + + /// Preview module navigation and bounded content counts without changing module availability. + [HttpPost("ModuleImpact")] + [RequestSizeLimit(1024)] + public Task ModuleImpact([FromBody] ModuleImpactRequest request, CancellationToken cancellationToken) => + ExecuteAsync(async () => await moduleImpacts.PreviewAsync(Actor, request, cancellationToken)); + + /// Compare a masked sender scenario against provider-specific routing; never receives or sends a message. + [HttpPost("TextImportImpact")] + [RequestSizeLimit(2048)] + public Task TextImportImpact([FromBody] TextImportImpactRequest request, CancellationToken cancellationToken) => + ExecuteAsync(async () => await textImportImpacts.PreviewAsync(Actor, request, cancellationToken)); + + /// Compare supported department security-policy gates; never changes credentials or sessions. + [HttpPost("SecurityImpact")] + [RequestSizeLimit(1024)] + public Task SecurityImpact([FromBody] ConfigurationImpactRequest request, CancellationToken cancellationToken) => + ExecuteAsync(async () => await securityImpacts.PreviewAsync(Actor, request, cancellationToken)); + + /// Estimate ordinary notification channel gates for a declared future cohort scenario; never sends. + [HttpPost("NotificationImpact")] + [RequestSizeLimit(1024)] + public Task NotificationImpact([FromBody] NotificationImpactRequest request, CancellationToken cancellationToken) => + ExecuteAsync(async () => await notificationImpacts.PreviewAsync(Actor, request, cancellationToken)); + + /// Preview prospective retention windows and known hold exclusions using metadata only; never purges. + [HttpPost("RetentionImpact")] + [RequestSizeLimit(1024)] + public Task RetentionImpact([FromBody] RetentionImpactRequest request, CancellationToken cancellationToken) => + ExecuteAsync(async () => await retentionImpacts.PreviewAsync(Actor, request, cancellationToken)); + + /// Read personal digest preferences and the last completed worker evaluation. + [HttpGet("Preferences")] + public Task Preferences(CancellationToken cancellationToken) => ExecuteAsync(async () => + { + if (!await access.CanAccessAsync(Actor, false, cancellationToken)) throw new UnauthorizedAccessException(); + return new { DigestsAvailable = Config.AdminAssistConfig.SendAdminDigests, Preferences = await maintenance.GetPreferencesAsync(DepartmentId, UserId, cancellationToken), + Worker = await maintenance.GetWorkerStatusAsync(DepartmentId, cancellationToken) }; + }); + + /// Save the current administrator's explicit digest opt-in and quiet hours. + [HttpPost("Preferences")] + [RequestSizeLimit(2048)] + public Task Preferences([FromBody] AdminAssistPreferencesCommand command, CancellationToken cancellationToken) => ExecuteAsync(async () => + { + if (!await access.CanAccessAsync(Actor, false, cancellationToken)) throw new UnauthorizedAccessException(); + await maintenance.SavePreferencesAsync(Actor, command, cancellationToken); + return new { saved = true }; + }); + + private async Task ExecuteAsync(Func> action) + { + Response.Headers.CacheControl = "no-store, no-cache, max-age=0"; + Response.Headers["X-Content-Type-Options"] = "nosniff"; + try { return Content(JsonSerializer.Serialize(await action(), JsonOptions), "application/json"); } + catch (UnauthorizedAccessException) { return StatusCode(403); } + catch (AdminAssistConcurrencyException) { return Conflict(new { code = "WorkspaceChanged" }); } + catch (ArgumentException) { return BadRequest(new { code = "InvalidSetupChoice" }); } + } + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs index b3308587e..a012469b9 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs @@ -1891,7 +1891,7 @@ public async Task> DeleteCall(string callId) return Ok(result); } - var canDoOperation = await _authorizationService.CanUserCloseCallAsync(UserId, int.Parse(callId), DepartmentId); + var canDoOperation = await _authorizationService.CanUserDeleteCallAsync(UserId, int.Parse(callId), DepartmentId); if (!canDoOperation) return Unauthorized(); @@ -1941,7 +1941,7 @@ public async Task> CloseCall([FromBody] CloseCallI return Ok(result); } - var canDoOperation = await _authorizationService.CanUserDeleteCallAsync(UserId, int.Parse(closeCallInput.Id), DepartmentId); + var canDoOperation = await _authorizationService.CanUserCloseCallAsync(UserId, int.Parse(closeCallInput.Id), DepartmentId); if (!canDoOperation) return Unauthorized(); @@ -2257,7 +2257,9 @@ public async Task> GetCalls(DateTime startDate, var result = new ActiveCallsResult(); - var calls = (await _callsService.GetAllCallsByDepartmentDateRangeAsync(DepartmentId, startDate, endDate)).OrderByDescending(x => x.LoggedOn).ToList(); + // Group-scoped dispatch (off by default) trims this to the caller's area and the calls they are on, as GetCall does. + var calls = (await _dispatchScopeService.FilterCallsForUserAsync(DepartmentId, UserId, await _callsService.GetAllCallsByDepartmentDateRangeAsync(DepartmentId, startDate, endDate))) + .OrderByDescending(x => x.LoggedOn).ToList(); var destinationPois = await _mappingService.GetPOIsForDepartmentAsync(DepartmentId); var destinationPoiLookup = destinationPois.ToDictionary(x => x.PoiId); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/DataProtectionController.cs b/Web/Resgrid.Web.Services/Controllers/v4/DataProtectionController.cs index 387eb7a6a..6f6b6c0ad 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/DataProtectionController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/DataProtectionController.cs @@ -31,6 +31,17 @@ public class DataProtectionController : V4AuthenticatedApiControllerbase private const int StepUpMaxAttempts = 5; private static readonly TimeSpan StepUpAttemptWindow = TimeSpan.FromMinutes(5); + [HttpPost("EnrollPin")] + [Authorize] + public async Task EnrollPin([FromBody] PinEnrollmentInput input) + { + Response.Headers["Cache-Control"] = "no-store"; + return Ok(new { success = await _adpRelease.EnrollPinAsync(DepartmentId, UserId, + Request.Headers[GrantHeader].ToString(), input?.Pin) }); + } + + public sealed class PinEnrollmentInput { public string Pin { get; set; } } + /// Header carrying the caller's Protected Data Grant on MFA-gated commands. public const string GrantHeader = "X-Resgrid-Protected-Grant"; @@ -42,12 +53,14 @@ public class DataProtectionController : V4AuthenticatedApiControllerbase private readonly UserManager _userManager; private readonly ICacheProvider _cacheProvider; private readonly IProtectedDataGrantService _grantService; + private readonly IAdpReleaseService _adpRelease; + private readonly Resgrid.Model.Repositories.IAdpAuditRepository _adpAudit; public DataProtectionController(IDepartmentDataProtectionService dataProtectionService, IDepartmentLockService departmentLockService, IProtectedFieldCatalog protectedFieldCatalog, IDepartmentsService departmentsService, IFeatureToggleService featureToggleService, UserManager userManager, ICacheProvider cacheProvider, - IProtectedDataGrantService grantService) + IProtectedDataGrantService grantService, IAdpReleaseService adpRelease, Resgrid.Model.Repositories.IAdpAuditRepository adpAudit) { _dataProtectionService = dataProtectionService; _departmentLockService = departmentLockService; @@ -57,6 +70,8 @@ public DataProtectionController(IDepartmentDataProtectionService dataProtectionS _userManager = userManager; _cacheProvider = cacheProvider; _grantService = grantService; + _adpRelease = adpRelease; + _adpAudit = adpAudit; } /// @@ -178,6 +193,8 @@ public async Task> RequestGrant() MfaAtUtc = DateTime.UtcNow, StepUpExempt = true }); + await _adpAudit.AppendAsync(new AdpAuditEvent { DepartmentId = DepartmentId, Layer = "identity", + Operation = "grant-issued", Outcome = "step-up-exempt", ActorId = UserId, CorrelationId = issued.GrantId }); var exemptResult = new StepUpResult { @@ -223,6 +240,8 @@ public async Task> VerifyStepUp([FromBody] VerifyStep var valid = await _userManager.VerifyTwoFactorTokenAsync(user, _userManager.Options.Tokens.AuthenticatorTokenProvider, input.Code.Trim()); + await _adpAudit.AppendAsync(new AdpAuditEvent { DepartmentId = DepartmentId, Layer = "identity", + Operation = "mfa-verify", Outcome = valid ? "verified" : "denied", ActorId = UserId }); if (!valid) return Problem(type: "invalid_totp", title: "The verification code is invalid or has expired.", @@ -262,6 +281,8 @@ public async Task> VerifyStepUp([FromBody] VerifyStep Scopes = new[] { ProtectedDataGrantScopes.Read, ProtectedDataGrantScopes.Write }, MfaAtUtc = DateTime.UtcNow }); + await _adpAudit.AppendAsync(new AdpAuditEvent { DepartmentId = DepartmentId, Layer = "identity", + Operation = "grant-issued", Outcome = "mfa-verified", ActorId = UserId, CorrelationId = issued.GrantId }); result.GrantId = issued.GrantId; result.GrantToken = issued.Token; diff --git a/Web/Resgrid.Web.Services/Controllers/v4/DispatchController.cs b/Web/Resgrid.Web.Services/Controllers/v4/DispatchController.cs index 3ddbb7258..350aac176 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/DispatchController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/DispatchController.cs @@ -166,17 +166,25 @@ public async Task> GetNewCallData() var unitStatuses = await _unitsService.GetAllLatestStatusForUnitsByDepartmentIdAsync(DepartmentId); + // Security > View Units, the same filter GetAllUnits applies, for the units and their statuses alike. + var viewableUnitIds = new HashSet(); foreach (var unit in units) + { + if (await _authorizationService.CanUserViewUnitViaMatrixAsync(unit.UnitId, UserId, DepartmentId)) + viewableUnitIds.Add(unit.UnitId); + } + + foreach (var unit in units.Where(u => viewableUnitIds.Contains(u.UnitId))) { if (!string.IsNullOrWhiteSpace(unit.Type)) { var unitType = unitTypes.FirstOrDefault(x => x.Type == unit.Type); - result.Units.Add(UnitsController.ConvertUnitsData(unit, unitStatuses.FirstOrDefault(x => x.UnitId == unit.UnitId), null, TimeZone)); + result.Units.Add(await UnitWithLocationIfVisibleAsync(UnitsController.ConvertUnitsData(unit, unitStatuses.FirstOrDefault(x => x.UnitId == unit.UnitId), null, TimeZone), unit.UnitId)); } else { - result.Units.Add(UnitsController.ConvertUnitsData(unit, unitStatuses.FirstOrDefault(x => x.UnitId == unit.UnitId), null, TimeZone)); + result.Units.Add(await UnitWithLocationIfVisibleAsync(UnitsController.ConvertUnitsData(unit, unitStatuses.FirstOrDefault(x => x.UnitId == unit.UnitId), null, TimeZone), unit.UnitId)); } // Add unit roles for this unit @@ -187,13 +195,16 @@ public async Task> GetNewCallData() } } - foreach (var us in unitStatuses) + foreach (var us in unitStatuses.Where(x => viewableUnitIds.Contains(x.UnitId))) { var customState = await CustomStatesHelper.GetCustomUnitState(us); var latestUnitLocation = await _unitsService.GetLatestUnitLocationAsync(us.UnitId, us.Timestamp); var group = allGroups.FirstOrDefault(x => x.DepartmentGroupId == us.Unit.StationGroupId); - result.UnitStatuses.Add(UnitStatusController.ConvertUnitStatusData(us.Unit, us, latestUnitLocation, customState, group, TimeZone, activeCalls, allGroups, pois)); + var unitStatus = UnitStatusController.ConvertUnitStatusData(us.Unit, us, latestUnitLocation, customState, group, TimeZone, activeCalls, allGroups, pois); + if (!await UnitLocationVisibility.CanSeeAsync(_authorizationService, us.UnitId, UserId, DepartmentId)) + UnitLocationVisibility.Withhold(unitStatus); + result.UnitStatuses.Add(unitStatus); } foreach (var role in allRoles) @@ -537,6 +548,14 @@ public async Task> GetGroupsForCallGrid return Ok(result); } + private async Task UnitWithLocationIfVisibleAsync(UnitResultData data, int unitId) + { + if (!await UnitLocationVisibility.CanSeeAsync(_authorizationService, unitId, UserId, DepartmentId)) + UnitLocationVisibility.Withhold(data); + + return data; + } + /// /// Nearest available unit board for an incident location. Lists every unit (a team, an apparatus or an /// individual set up as a unit) and every responder in the caller's dispatch scope, ranked available-first diff --git a/Web/Resgrid.Web.Services/Controllers/v4/MappingController.cs b/Web/Resgrid.Web.Services/Controllers/v4/MappingController.cs index 9e18dce9d..7b0a12dd8 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/MappingController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/MappingController.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Resgrid.Framework; @@ -378,14 +378,6 @@ public async Task> GetMapDataAndMarkers() try { - // Department has a TTL setup for units - if (unitLocationTTL > 0 && latestLocation != null) - { - // Unit location TTL has expired the latest unit location we have. - if (DateTime.UtcNow.AddMinutes(-unitLocationTTL) > latestLocation.Timestamp) - latestLocation = null; - } - if (unitTypes != null && unitTypes.Count > 0 && !String.IsNullOrWhiteSpace(unit.Type)) { var type = unitTypes.FirstOrDefault(x => x.Type == unit.Type); @@ -394,42 +386,18 @@ public async Task> GetMapDataAndMarkers() info.ImagePath = ((MapIconTypes)type.MapIconType.Value).ToString(); } - if (latestLocation != null && state != null) + var markerSource = MappingMarkerSelection.Select(latestLocation?.Timestamp, state?.Timestamp, () => state?.HasLocation() == true, + unitLocationTTL, unitAllowStatusWithNoLocationToOverwrite, DateTime.UtcNow); + if (markerSource == MappingMarkerSource.LocationPing) { - if (latestLocation.Timestamp > state.Timestamp) - { - info.Latitude = double.Parse(latestLocation.Latitude.ToString()); - info.Longitude = double.Parse(latestLocation.Longitude.ToString()); - - result.Data.MapMakerInfos.Add(info); - } - else if (state.HasLocation()) - { - info.Latitude = double.Parse(state.Latitude.Value.ToString()); - info.Longitude = double.Parse(state.Longitude.Value.ToString()); - - result.Data.MapMakerInfos.Add(info); - } - else if (!unitAllowStatusWithNoLocationToOverwrite) // State was newer then location ping but did not have a valid location - { - info.Latitude = double.Parse(latestLocation.Latitude.ToString()); - info.Longitude = double.Parse(latestLocation.Longitude.ToString()); - - result.Data.MapMakerInfos.Add(info); - } - } - else if (latestLocation != null) - { - info.Latitude = double.Parse(latestLocation.Latitude.ToString()); - info.Longitude = double.Parse(latestLocation.Longitude.ToString()); - + info.Latitude = (double)latestLocation.Latitude; + info.Longitude = (double)latestLocation.Longitude; result.Data.MapMakerInfos.Add(info); } - else if (state != null && state.HasLocation()) + else if (markerSource == MappingMarkerSource.Status) { - info.Latitude = double.Parse(state.Latitude.Value.ToString()); - info.Longitude = double.Parse(state.Longitude.Value.ToString()); - + info.Latitude = (double)state.Latitude.Value; + info.Longitude = (double)state.Longitude.Value; result.Data.MapMakerInfos.Add(info); } } @@ -457,57 +425,20 @@ public async Task> GetMapDataAndMarkers() try { - // Department has a TTL setup for personnel - if (personnelLocationTTL > 0 && latestLocation != null) - { - // Person location TTL has expired the latest personnel location we have. - if (DateTime.UtcNow.AddMinutes(-personnelLocationTTL) > latestLocation.Timestamp) - latestLocation = null; - } - - if (latestLocation != null && state != null) - { - if (latestLocation.Timestamp > state.Timestamp) // Location ping newer then state - { - info.Latitude = double.Parse(latestLocation.Latitude.ToString()); - info.Longitude = double.Parse(latestLocation.Longitude.ToString()); - - result.Data.MapMakerInfos.Add(info); - } - else if (state.HasLocation()) // State is newer then location ping and has a valid location - { - var location = state.GetCoordinates(); - info.Latitude = double.Parse(location.Latitude.Value.ToString()); - info.Longitude = double.Parse(location.Longitude.Value.ToString()); - - result.Data.MapMakerInfos.Add(info); - } - else if (!personnelAllowStatusWithNoLocationToOverwrite) // State was newer then location ping but did not have a valid location - { - info.Latitude = double.Parse(latestLocation.Latitude.ToString()); - info.Longitude = double.Parse(latestLocation.Longitude.ToString()); - - result.Data.MapMakerInfos.Add(info); - } - } - else if (latestLocation != null) + var markerSource = MappingMarkerSelection.Select(latestLocation?.Timestamp, state?.Timestamp, () => state?.HasLocation() == true, + personnelLocationTTL, personnelAllowStatusWithNoLocationToOverwrite, DateTime.UtcNow); + if (markerSource == MappingMarkerSource.LocationPing) { info.Latitude = (double)latestLocation.Latitude; info.Longitude = (double)latestLocation.Longitude; - result.Data.MapMakerInfos.Add(info); } - else if (state != null) + else if (markerSource == MappingMarkerSource.Status) { - if (state.HasLocation()) - { - var location = state.GetCoordinates(); - - info.Latitude = location.Latitude.Value; - info.Longitude = location.Longitude.Value; - - result.Data.MapMakerInfos.Add(info); - } + var location = state.GetCoordinates(); + info.Latitude = location.Latitude.Value; + info.Longitude = location.Longitude.Value; + result.Data.MapMakerInfos.Add(info); } } catch { } diff --git a/Web/Resgrid.Web.Services/Controllers/v4/UnitLocationController.cs b/Web/Resgrid.Web.Services/Controllers/v4/UnitLocationController.cs index 9b4f567db..61aa570b9 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/UnitLocationController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/UnitLocationController.cs @@ -26,11 +26,14 @@ public class UnitLocationController : V4AuthenticatedApiControllerbase #region Members and Constructors private readonly IUnitsService _unitsService; private readonly IUnitLocationEventProvider _unitLocationEventProvider; + private readonly Model.Services.IAuthorizationService _authorizationService; - public UnitLocationController(IUnitsService unitsService, IUnitLocationEventProvider unitLocationEventProvider) + public UnitLocationController(IUnitsService unitsService, IUnitLocationEventProvider unitLocationEventProvider, + Model.Services.IAuthorizationService authorizationService) { _unitsService = unitsService; _unitLocationEventProvider = unitLocationEventProvider; + _authorizationService = authorizationService; } #endregion Members and Constructors @@ -151,6 +154,10 @@ public async Task> GetLatestUnitLocation(string if (unit.DepartmentId != DepartmentId) return Unauthorized(); + // This endpoint returns nothing but the location, so a caller who may not see it is refused outright. + if (!await UnitLocationVisibility.CanSeeAsync(_authorizationService, unit.UnitId, UserId, DepartmentId)) + return Unauthorized(); + var lastLocation = await _unitsService.GetLatestUnitLocationAsync(int.Parse(unitId)); if (lastLocation != null) diff --git a/Web/Resgrid.Web.Services/Controllers/v4/UnitStatusController.cs b/Web/Resgrid.Web.Services/Controllers/v4/UnitStatusController.cs index 32936b351..055ce7d20 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/UnitStatusController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/UnitStatusController.cs @@ -38,6 +38,7 @@ public class UnitStatusController : V4AuthenticatedApiControllerbase private readonly IIncidentCommandService _incidentCommandService; private readonly IDepartmentsService _departmentsService; private readonly IDispatchScopeService _dispatchScopeService; + private readonly Model.Services.IAuthorizationService _authorizationService; public UnitStatusController( ICallsService callsService, @@ -48,9 +49,11 @@ public UnitStatusController( IMappingService mappingService, IIncidentCommandService incidentCommandService, IDepartmentsService departmentsService, - IDispatchScopeService dispatchScopeService + IDispatchScopeService dispatchScopeService, + Model.Services.IAuthorizationService authorizationService ) { + _authorizationService = authorizationService; _dispatchScopeService = dispatchScopeService; _callsService = callsService; _unitsService = unitsService; @@ -97,6 +100,10 @@ public async Task> GetAllUnitStatuses() DateTime timestamp = DateTime.UtcNow; foreach (var unit in sortedUnits) { + // Security > View Units, the same filter GetAllUnits applies. + if (!await _authorizationService.CanUserViewUnitViaMatrixAsync(unit.Unit.UnitId, UserId, DepartmentId)) + continue; + var stateFound = unitStates.FirstOrDefault(x => x.UnitId == unit.Unit.UnitId); if (stateFound != null) @@ -105,12 +112,12 @@ public async Task> GetAllUnitStatuses() var customState = await CustomStatesHelper.GetCustomUnitState(stateFound); var latestUnitLocation = await _unitsService.GetLatestUnitLocationAsync(unit.Unit.UnitId, timestamp); - result.Data.Add(ConvertUnitStatusData(unit.Unit, stateFound, latestUnitLocation, customState, unit.Station, TimeZone, activeCalls, groups, pois)); + result.Data.Add(await WithLocationIfVisibleAsync(ConvertUnitStatusData(unit.Unit, stateFound, latestUnitLocation, customState, unit.Station, TimeZone, activeCalls, groups, pois), unit.Unit.UnitId)); } else { var latestUnitLocation = await _unitsService.GetLatestUnitLocationAsync(unit.Unit.UnitId, timestamp); - result.Data.Add(ConvertUnitStatusData(unit.Unit, stateFound, latestUnitLocation, null, unit.Station, TimeZone, activeCalls, groups, pois)); + result.Data.Add(await WithLocationIfVisibleAsync(ConvertUnitStatusData(unit.Unit, stateFound, latestUnitLocation, null, unit.Station, TimeZone, activeCalls, groups, pois), unit.Unit.UnitId)); } } @@ -153,6 +160,13 @@ public async Task> GetUnitStatus(string unitId) if (unit.DepartmentId != DepartmentId) return Unauthorized(); + // A unit the caller may not see reads as not found, as in GetUnitByName. + if (!await _authorizationService.CanUserViewUnitViaMatrixAsync(unit.UnitId, UserId, DepartmentId)) + { + ResponseHelper.PopulateV4ResponseNotFound(result); + return Ok(result); + } + var activeCalls = await _callsService.GetActiveCallsByDepartmentAsync(DepartmentId); var groups = await _departmentGroupsService.GetAllGroupsForDepartmentAsync(DepartmentId); var pois = await _mappingService.GetPOIsForDepartmentAsync(DepartmentId); @@ -169,12 +183,12 @@ public async Task> GetUnitStatus(string unitId) var customState = await CustomStatesHelper.GetCustomUnitState(status); var latestUnitLocation = await _unitsService.GetLatestUnitLocationAsync(status.UnitId, timestamp); - result.Data = ConvertUnitStatusData(unit, status, latestUnitLocation, customState, group, TimeZone, activeCalls, groups, pois); + result.Data = await WithLocationIfVisibleAsync(ConvertUnitStatusData(unit, status, latestUnitLocation, customState, group, TimeZone, activeCalls, groups, pois), unit.UnitId); } else { var latestUnitLocation = await _unitsService.GetLatestUnitLocationAsync(unit.UnitId, timestamp); - result.Data = ConvertUnitStatusData(unit, null, latestUnitLocation, null, group, TimeZone, activeCalls, groups, pois); + result.Data = await WithLocationIfVisibleAsync(ConvertUnitStatusData(unit, null, latestUnitLocation, null, group, TimeZone, activeCalls, groups, pois), unit.UnitId); } result.PageSize = 1; @@ -385,6 +399,14 @@ public async Task> GetUnitStatus(string unitId) } + private async Task WithLocationIfVisibleAsync(UnitStatusResultData data, int unitId) + { + if (!await UnitLocationVisibility.CanSeeAsync(_authorizationService, unitId, UserId, DepartmentId)) + UnitLocationVisibility.Withhold(data); + + return data; + } + public static UnitStatusResultData ConvertUnitStatusData(Unit unit, UnitState stateFound, UnitsLocation latestUnitLocation, CustomStateDetail customState, DepartmentGroup group, string timeZone, List activeCalls, List groups, List pois) { diff --git a/Web/Resgrid.Web.Services/Controllers/v4/UnitsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/UnitsController.cs index d86b6749f..bcd886773 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/UnitsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/UnitsController.cs @@ -108,6 +108,9 @@ public async Task> GetAllUnits() var unitData = ConvertUnitsData(unit, unitStatuses.FirstOrDefault(x => x.UnitId == unit.UnitId), type, TimeZone); + if (!await UnitLocationVisibility.CanSeeAsync(_authorizationService, unit.UnitId, UserId, DepartmentId)) + UnitLocationVisibility.Withhold(unitData); + if (udfValuesByEntityId.TryGetValue(unit.UnitId.ToString(), out var unitUdfValues) && unitUdfValues.Any()) { unitData.UdfValues = unitUdfValues.Select(v => new UdfFieldValueResultData @@ -175,7 +178,7 @@ public async Task> GetAllUnitsInfos(string activeF if (types != null && types.Any()) type = types.FirstOrDefault(x => x.Type == unit.Type); - var canViewLocation = await _authorizationService.CanUserViewUnitLocationAsync(UserId, unit.UnitId, DepartmentId); + var canViewLocation = await UnitLocationVisibility.CanSeeAsync(_authorizationService, unit.UnitId, UserId, DepartmentId); var unitState = unitStatuses.FirstOrDefault(x => x.UnitId == unit.UnitId); var customState = await _customStateService.GetCustomUnitStateAsync(unitState); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/UserDefinedFieldsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/UserDefinedFieldsController.cs index 1c0aaa8af..d4e8e87e8 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/UserDefinedFieldsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/UserDefinedFieldsController.cs @@ -83,12 +83,23 @@ public async Task> SaveDefinition([FromBody] S var isNew = previousDefinition == null; // A client that does not send Sensitivity keeps each field's current tag (a silent reset would re-open a Part 2 field). - var currentSensitivity = isNew - ? new Dictionary(StringComparer.OrdinalIgnoreCase) - : (await _udfService.GetFieldsForActiveDefinitionAsync(DepartmentId, input.EntityType)) - .Where(f => !string.IsNullOrWhiteSpace(f.Name)) - .GroupBy(f => f.Name.Trim(), StringComparer.OrdinalIgnoreCase) - .ToDictionary(g => g.Key, g => g.First().Sensitivity, StringComparer.OrdinalIgnoreCase); + // The field's id is matched first, so renaming the field keeps its tag too; the machine name is the fallback. + var currentFields = isNew + ? new List() + : await _udfService.GetFieldsForActiveDefinitionAsync(DepartmentId, input.EntityType) ?? new List(); + var sensitivityById = currentFields + .Where(f => !string.IsNullOrWhiteSpace(f.UdfFieldId)) + .GroupBy(f => f.UdfFieldId, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First().Sensitivity, StringComparer.OrdinalIgnoreCase); + var sensitivityByName = currentFields + .Where(f => !string.IsNullOrWhiteSpace(f.Name)) + .GroupBy(f => f.Name.Trim(), StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First().Sensitivity, StringComparer.OrdinalIgnoreCase); + + int CurrentSensitivity(string fieldId, string name) => + fieldId != null && sensitivityById.TryGetValue(fieldId, out var byId) ? byId + : name != null && sensitivityByName.TryGetValue(name.Trim(), out var byName) ? byName + : 0; var fields = input.Fields?.Select(f => new UdfField { @@ -110,7 +121,7 @@ public async Task> SaveDefinition([FromBody] S Visibility = f.Visibility, Sensitivity = f.Sensitivity is >= 0 and <= 2 ? f.Sensitivity.Value - : (f.Name != null && currentSensitivity.TryGetValue(f.Name.Trim(), out var tag) ? tag : 0) + : CurrentSensitivity(f.UdfFieldId, f.Name) }).ToList() ?? new List(); UdfDefinition saved; diff --git a/Web/Resgrid.Web.Services/Helpers/UnitLocationVisibility.cs b/Web/Resgrid.Web.Services/Helpers/UnitLocationVisibility.cs new file mode 100644 index 000000000..2cf172e20 --- /dev/null +++ b/Web/Resgrid.Web.Services/Helpers/UnitLocationVisibility.cs @@ -0,0 +1,39 @@ +using System.Threading.Tasks; +using Resgrid.Model.Services; +using Resgrid.Web.Services.Models.v4.Units; +using Resgrid.Web.Services.Models.v4.UnitStatus; + +namespace Resgrid.Web.Services.Helpers +{ + /// + /// The one rule for a unit's coordinates leaving the v4 API, the same one the map applies: they go out only when + /// the caller passes See Unit Locations for that unit (the unit-location visibility matrix). A unit the caller may + /// see but not locate is still returned, with its coordinates withheld (null). An endpoint that returns nothing + /// but a location refuses the request instead. + /// + public static class UnitLocationVisibility + { + public static Task CanSeeAsync(IAuthorizationService authorizationService, int unitId, string userId, int departmentId) + { + return authorizationService.CanUserViewUnitLocationViaMatrixAsync(unitId, userId, departmentId); + } + + public static void Withhold(UnitResultData data) + { + if (data == null) + return; + + data.Latitude = null; + data.Longitude = null; + } + + public static void Withhold(UnitStatusResultData data) + { + if (data == null) + return; + + data.Latitude = null; + data.Longitude = null; + } + } +} diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index 13b6853f0..1dee7f2c8 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -69,6 +69,72 @@ Text-To-Call has always done it. + + Deterministic department setup and configuration guidance. No configuration mutation or model calls. + + + Deterministic department setup and configuration guidance. No configuration mutation or model calls. + + + Read fresh, authorized configuration evidence and setup progress. + + + Read the release-pinned public setup and feature catalog. + + + Update setup choices and personal learning metadata. + + + Search the allowlisted public reference corpus. + + + Read tenant history with personal learning choices restricted to the current administrator. + + + Read current findings through the department protection policy. + + + Evaluate fresh evidence and persist meaningful finding transitions. + + + Update finding ownership or review metadata without changing the rule result. + + + Preview a scalar proposal against fresh evidence without saving configuration. + + + Preview base-plan headroom for proposed total personnel and unit counts without provisioning. + + + Simulate a saved call's routes using current authorized membership and an explicit roster time; never sends. + + + Compare the proposed permission gate for current members and resource targets; never changes access. + + + List current department roles for a permission proposal, without exposing personnel details. + + + Preview module navigation and bounded content counts without changing module availability. + + + Compare a masked sender scenario against provider-specific routing; never receives or sends a message. + + + Compare supported department security-policy gates; never changes credentials or sessions. + + + Estimate ordinary notification channel gates for a declared future cohort scenario; never sends. + + + Preview prospective retention windows and known hold exclusions using metadata only; never purges. + + + Read personal digest preferences and the last completed worker evaluation. + + + Save the current administrator's explicit digest opt-in and quiet hours. + Call Priorities, for example Low, Medium, High. Call Priorities can be system provided ones or custom for a department @@ -6926,6 +6992,14 @@ the department. Callers treat null-with-a-system-principal as a denial, not as "unrestricted". + + + The one rule for a unit's coordinates leaving the v4 API, the same one the map applies: they go out only when + the caller passes See Unit Locations for that unit (the unit-location visibility matrix). A unit the caller may + see but not locate is still returned, with its coordinates withheld (null). An endpoint that returns nothing + but a location refuses the request instead. + + Serialises a that is known to hold a UTC instant with an explicit "Z". diff --git a/Web/Resgrid.Web.Services/Startup.cs b/Web/Resgrid.Web.Services/Startup.cs index 2504f7121..756e44406 100644 --- a/Web/Resgrid.Web.Services/Startup.cs +++ b/Web/Resgrid.Web.Services/Startup.cs @@ -52,7 +52,6 @@ using Microsoft.AspNetCore.Authentication; using Sentry.Extensibility; using Resgrid.Web.ServicesCore.Middleware; -using IPNetwork = Microsoft.AspNetCore.HttpOverrides.IPNetwork; using System.Net.Http; using Resgrid.Providers.Messaging; using Resgrid.Web.Services; @@ -229,10 +228,7 @@ public void ConfigureServices(IServiceCollection services) }); services.Configure(options => - { - options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; - options.KnownNetworks.Add(new IPNetwork(IPAddress.Parse($"::ffff:{WebConfig.IngressProxyNetwork}"), WebConfig.IngressProxyNetworkCidr)); - }); + Resgrid.Web.Helpers.ForwardedHeadersSetup.Configure(options, WebConfig.IngressProxyNetwork, WebConfig.IngressProxyNetworkCidr)); #region Auth Roles services.AddAuthorization(options => diff --git a/Web/Resgrid.Web.Tts/Configuration/TtsRequestIdentity.cs b/Web/Resgrid.Web.Tts/Configuration/TtsRequestIdentity.cs index 2870294d3..807b788d0 100644 --- a/Web/Resgrid.Web.Tts/Configuration/TtsRequestIdentity.cs +++ b/Web/Resgrid.Web.Tts/Configuration/TtsRequestIdentity.cs @@ -1,8 +1,8 @@ -using System.Net; using System.Net.Sockets; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.HttpOverrides; using Resgrid.Config; +using Resgrid.Web.Helpers; namespace Resgrid.Web.Tts.Configuration { @@ -10,8 +10,7 @@ public static class TtsRequestIdentity { public static void ConfigureForwardedHeaders(ForwardedHeadersOptions options) { - options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; - AddKnownNetwork(options, WebConfig.IngressProxyNetwork, WebConfig.IngressProxyNetworkCidr); + ForwardedHeadersSetup.Configure(options, WebConfig.IngressProxyNetwork, WebConfig.IngressProxyNetworkCidr); } public static string ResolveRateLimitPartitionKey(HttpContext httpContext) @@ -28,20 +27,5 @@ public static string ResolveRateLimitPartitionKey(HttpContext httpContext) ? remoteIpAddress.MapToIPv4().ToString() : remoteIpAddress.ToString(); } - - private static void AddKnownNetwork(ForwardedHeadersOptions options, string network, int prefixLength) - { - if (string.IsNullOrWhiteSpace(network) || !IPAddress.TryParse(network, out var parsedNetwork)) - { - return; - } - - options.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(parsedNetwork, prefixLength)); - - if (parsedNetwork.AddressFamily == AddressFamily.InterNetwork) - { - options.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(parsedNetwork.MapToIPv6(), prefixLength)); - } - } } } diff --git a/Web/Resgrid.Web.Tts/Dockerfile b/Web/Resgrid.Web.Tts/Dockerfile index afd127f6c..7c4d0f6d1 100644 --- a/Web/Resgrid.Web.Tts/Dockerfile +++ b/Web/Resgrid.Web.Tts/Dockerfile @@ -24,6 +24,7 @@ COPY ["Directory.Build.props", "./"] COPY ["Directory.Build.targets", "./"] COPY ["Web/Resgrid.Web.Tts/Resgrid.Web.Tts.csproj", "Web/Resgrid.Web.Tts/"] COPY ["Core/Resgrid.Config/Resgrid.Config.csproj", "Core/Resgrid.Config/"] +COPY ["Web/Resgrid.Web.Common/Resgrid.Web.Common.csproj", "Web/Resgrid.Web.Common/"] RUN dotnet restore "Web/Resgrid.Web.Tts/Resgrid.Web.Tts.csproj" COPY . . diff --git a/Web/Resgrid.Web.Tts/Resgrid.Web.Tts.csproj b/Web/Resgrid.Web.Tts/Resgrid.Web.Tts.csproj index 8b6695072..e2dbc3240 100644 --- a/Web/Resgrid.Web.Tts/Resgrid.Web.Tts.csproj +++ b/Web/Resgrid.Web.Tts/Resgrid.Web.Tts.csproj @@ -18,5 +18,6 @@ + \ No newline at end of file diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/components/adminAssist/AdminAssistElement.tsx b/Web/Resgrid.Web/Areas/User/Apps/src/components/adminAssist/AdminAssistElement.tsx new file mode 100644 index 000000000..1649add2b --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Apps/src/components/adminAssist/AdminAssistElement.tsx @@ -0,0 +1,328 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { ApiError, apiFetchJson } from '../../runtime/api'; +import './adminAssist.css'; +import ImpactPreview from './ImpactPreview'; +import CapacityPreview from './CapacityPreview'; +import DispatchPreview from './DispatchPreview'; +import PermissionPreview from './PermissionPreview'; +import ModulePreview from './ModulePreview'; +import TextImportPreview from './TextImportPreview'; +import RetentionPreview from './RetentionPreview'; +import NotificationPreview from './NotificationPreview'; +import SecurityPreview from './SecurityPreview'; +import AreaSetupChoice from './AreaSetupChoice'; +import SetupJourney from './SetupJourney'; + +export interface AdminAssistElementProps { + page: string; + setup: boolean; + loadingLabel: string; + errorLabel: string; +} +type AreaChoice = 'UseNow' | 'LearnLater' | 'NotApplicable'; +type Workspace = { scopeRevision?: number; revision: number; mode: string; areas: Record; areaReasons?: Record; learnedCapabilityIds: string[]; interestedCapabilityIds: string[]; reviewedOnUtc: string | null; revisitOnUtc?: string | null; + reviewEvidence?: { scopeRevision?: number; catalogVersion: string; snapshotRevision: string; asOfUtc: string; required: number; verified: number; failed: number; unknown: number } | null }; +type Location = { url: string; field: string | null }; +type Capability = { id: string; areaId: string; labelKey: string; purposeKey: string; valueKey: string; exampleKey: string; adoptionKey: string; releaseStatus: string; requirements: { kind: string; id: string }[] }; +type Finding = { ruleId: string; areaId: string; severity: string; result: string; titleKey: string; explanationKey: string; nextActionKey: string; destination: string; reasonCode: string | null; scopeIndependent: boolean }; +type Catalog = { + moduleImpactTypes: string[]; permissionImpactTypes: string[]; impactSettings: string[]; version: string; strings: Record; rightToLeft: boolean; canSetup: boolean; + areas: { id: string; labelKey: string; purposeKey: string }[]; + capabilities: Capability[]; + settings: { id: string; valueType: string; labelKey: string; helpKey: string; location: Location; impact: { risk: string; timingKey: string; reversibilityKey: string } }[]; + articles: { id: string; locale: string; body: string; sourcePath: string; anchor: string; packVersion: string }[]; + packs: { id: string; labelKey: string; purposeKey: string; areaIds: string[]; prerequisiteKeys: string[] }[]; +}; +type Overview = { + catalogVersion: string; workspace: Workspace; + report: { verified: number; required: number; unknown: number; failed: number; hasCriticalUncertainty: boolean; selectedAreas: string[]; uncheckedAreaIds: string[]; findings: Finding[]; snapshot: { asOfUtc: string; consistent: boolean; revision: string; evidence: Record } }; + access: { capabilityId: string; state: string; reasonCodes: string[]; canConfigure: boolean; subscriptionDestination: string | null; destination: string | null }[]; + capabilitySetup?: { capabilityId: string; state: string; opportunityKey: string; guidanceKey: string; ruleIds: string[] }[]; +}; +type History = { id: string; occurredOnUtc: string; action: string; subjectId: string; beforeCode: string; afterCode: string }; +type WorkItem = { adminAssistFindingId: string; ruleId: string; result: number; reviewStatus: number; ownerId: string | null; reviewOn: string | null; exceptionUntil: string | null; content: string | null; revision: number; lastObservedOn: string }; +type SearchHit = { id: string; titleKey: string; excerpt: string; sourcePath: string; anchor: string; packVersion: string; locale: string }; +type Followup = { digestsAvailable: boolean; preferences: { revision: number; digestEnabled: boolean; quietStartHour: number; quietEndHour: number; lastAttemptOutcome: string | null }; worker: { lastEvaluatedOn: string | null } }; +const endpoint = 'api/v4/AdminAssist/'; +// No external URLs, scheme-relative links or redirects from catalog data. +const safeLocalLink = (url: string | null | undefined) => url && /^\/User\/[A-Za-z0-9]+\/[A-Za-z0-9]+(?:\?aa=[A-Za-z0-9._-]+)?$/.test(url) ? url : undefined; + +export default function AdminAssistElement({ page, setup, loadingLabel, errorLabel }: AdminAssistElementProps) { + const [tab, setTab] = useState(page); + const [catalog, setCatalog] = useState(null); + const [overview, setOverview] = useState(null); + const localLink = (url: string | null | undefined) => { + const destination = safeLocalLink(url); + return !destination || !catalog?.canSetup ? destination : `${destination}${destination.includes('?') ? '&' : '?'}aaReturn=${tab === 'wizard' ? 'wizard' : 'report'}`; + }; + const [query, setQuery] = useState(''); + const [addonsOnly, setAddonsOnly] = useState(false); + const [error, setError] = useState(''); + const [busy, setBusy] = useState(false); + const [history, setHistory] = useState([]); + const [hasMore, setHasMore] = useState(true); + const [worklist, setWorklist] = useState([]); + const [hits, setHits] = useState([]); + const [workFilter, setWorkFilter] = useState('active'); + const [followup, setFollowup] = useState(null); + const request = useRef(null); + const t = (key: string) => catalog?.strings[key] ?? key; + const ui = (key: string) => t(`Ui.${key}`); + + const reload = useCallback(async () => { + request.current?.abort(); + const controller = new AbortController(); + request.current = controller; + setBusy(true); + setError(''); + try { + const [nextCatalog, nextOverview] = await Promise.all([ + apiFetchJson(`${endpoint}Catalog`, { signal: controller.signal }, { setup }), + apiFetchJson(`${endpoint}Overview`, { signal: controller.signal }, { setup }), + ]); + if (controller.signal.aborted) return; + if (nextCatalog.version !== nextOverview.catalogVersion) throw new Error('CatalogChanged'); + setCatalog(nextCatalog); + setOverview(nextOverview); + } catch (failure) { + if (!controller.signal.aborted) { setOverview(null); setError(errorLabel); } + } finally { if (!controller.signal.aborted) setBusy(false); } + }, [setup, errorLabel]); + useEffect(() => { void reload(); return () => request.current?.abort(); }, [reload]); + + async function save(operation: string, targetId: string | null = null, choice: string | null = null, reasonCode: string | null = null, revisitOnUtc: string | null = null) { + if (!overview || !catalog || busy) return; + setBusy(true); + setError(''); + try { + const workspace = await apiFetchJson(`${endpoint}Setup`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ expectedRevision: overview.workspace.revision, operation, targetId, choice, catalogVersion: catalog.version, reasonCode, revisitOnUtc, + evidenceRevision: operation === 'review' ? overview.report.snapshot.revision : null }), + }); + setOverview(previous => previous && { ...previous, workspace }); + await reload(); + } catch (failure) { + if (failure instanceof ApiError && failure.status === 409) { await reload(); setError(ui('Conflict')); } + else setError(ui('SaveError')); + } finally { setBusy(false); } + } + + async function loadHistory(reset = false) { + setBusy(true); + try { + const rows = await apiFetchJson(`${endpoint}History`, undefined, { skip: reset ? 0 : history.length, take: 30 }); + setHistory(current => reset ? rows : [...current, ...rows]); + setHasMore(rows.length === 30); + } catch { setError(errorLabel); } + finally { setBusy(false); } + } + + async function loadWorklist() { + setBusy(true); setError(''); + try { + const [items, preferences] = await Promise.all([apiFetchJson(`${endpoint}Worklist`), apiFetchJson(`${endpoint}Preferences`)]); + setWorklist(items); setFollowup(preferences); + } + catch { setWorklist([]); setError(errorLabel); } + finally { setBusy(false); } + } + async function verify() { + setBusy(true); setError(''); + try { + if (!setup) await apiFetchJson(`${endpoint}Verify`, { method: 'POST' }); + await reload(); + if (!setup) await loadWorklist(); + } catch (failure) { setError(failure instanceof ApiError && failure.status === 409 ? ui('Conflict') : errorLabel); } + finally { setBusy(false); } + } + async function review(item: WorkItem, operation: string, form?: HTMLFormElement) { + setBusy(true); setError(''); + const fields = form ? new FormData(form) : null; + const date = fields?.get('date')?.toString(); + try { + await apiFetchJson(`${endpoint}Review`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ + findingId: item.adminAssistFindingId, expectedRevision: item.revision, operation, + note: fields?.get('note')?.toString() ?? null, + exceptionUntilUtc: operation === 'exception' && date ? new Date(date).toISOString() : null, + reviewOnUtc: operation === 'claim' && date ? new Date(date).toISOString() : null, + }) }); + await loadWorklist(); + } catch (failure) { + if (failure instanceof ApiError && failure.status === 409) { await loadWorklist(); setError(ui('Conflict')); } + else setError(ui('SaveError')); + } finally { setBusy(false); } + } + async function searchReference() { + setBusy(true); setError(''); + try { setHits(await apiFetchJson(`${endpoint}Search`, undefined, { query, setup })); } + catch { setHits([]); setError(errorLabel); } + finally { setBusy(false); } + } + async function savePreferences(form: HTMLFormElement) { + if (!followup) return; + setBusy(true); setError(''); const fields = new FormData(form); + try { + await apiFetchJson(`${endpoint}Preferences`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ + expectedRevision: followup.preferences.revision, digestEnabled: fields.get('digest') === 'on', quietStartHour: Number(fields.get('start')), quietEndHour: Number(fields.get('end')), + }) }); + await loadWorklist(); + } catch (failure) { setError(failure instanceof ApiError && failure.status === 409 ? ui('Conflict') : ui('SaveError')); } + finally { setBusy(false); } + } + + if (!catalog || !overview) return
+

{error || loadingLabel}

+ {error && } +
; + const { workspace, report } = overview; + const operatingPacks = overview.report.snapshot.evidence?.operatingPackIds; + const selectedPackIds = operatingPacks?.state === 'Known' ? (operatingPacks.code ?? '').split(',').filter(Boolean) : []; + const suggestedAreas = new Set(catalog.packs.filter(pack => selectedPackIds.includes(pack.id)).flatMap(pack => pack.areaIds)); + const tabs = setup ? ['wizard', 'report', 'explore'] : ['overview', ...(catalog.canSetup ? ['wizard'] : []), 'report', 'explore', 'health', 'worklist', 'reference', 'history']; + const selected = (finding: Finding) => finding.areaId === 'security' || finding.scopeIndependent || report.selectedAreas.includes(finding.areaId); + const next = report.findings.filter(f => selected(f) && f.result === 'Fail').sort((a, b) => Number(b.severity === 'Critical') - Number(a.severity === 'Critical')).slice(0, 3); + const filtered = catalog.capabilities.filter(c => (!addonsOnly || c.id.startsWith('addon-')) && `${t(c.labelKey)} ${t(c.purposeKey)} ${t(catalog.areas.find(a => a.id === c.areaId)?.labelKey ?? "")}`.toLocaleLowerCase().includes(query.toLocaleLowerCase())); + const interests = catalog.capabilities.filter(c => workspace.interestedCapabilityIds.includes(c.id)); + const addonNames = (capability: Capability) => capability.requirements.filter(r => r.kind === 'addon').map(r => { + const addon = catalog.capabilities.find(c => c.id.startsWith('addon-') && c.requirements.some(a => a.kind === 'addon' && a.id === r.id)); + return addon ? t(addon.labelKey) : ui('Unknown'); + }); + const selectedWorkflows = <>

{ui('SelectedWorkflows')}

{ui('WorkflowProgressHelp')}

+ {interests.length === 0 &&

{ui('SelectWorkflowHelp')}

} +
{interests.map(capability => { + const availability = overview.access.find(a => a.capabilityId === capability.id); + return
+

{t(capability.labelKey)}

+

{workspace.learnedCapabilityIds.includes(capability.id) ? ui('LearningComplete') : ui('LearningPending')}

+

{availability?.state === 'Known' ? ui('AvailableForSetup') : availability?.state === 'Unavailable' ? ui('WorkflowBlocked') : ui('WorkflowAccessUnknown')}

+ {availability?.reasonCodes.map(reason =>

{ui(reason)}

)} + {addonNames(capability).length > 0 &&

{ui('RequiredAddons')}: {addonNames(capability).join(', ')}

} + {(() => { const progress = overview.capabilitySetup?.find(s => s.capabilityId === capability.id); return <> +

{ui(`SetupState.${progress?.state ?? 'NotAssessed'}`)}

+

{t(progress?.guidanceKey ?? 'Ui.ConfigurationNotAssessed')}

+ ; })()} +

{t(capability.adoptionKey)}

+ {availability?.canConfigure && localLink(availability.destination) &&

{ui('Configure')}

} + {availability?.subscriptionDestination && localLink(availability.subscriptionDestination) &&

{ui('SubscriptionOptions')}

} + +
; + })}
+ ; + const findingCard = (finding: Finding) =>
+

{ui(finding.result)} · {ui(finding.severity)}

+

{t(finding.titleKey)}

{t(finding.explanationKey)}

+ {t(finding.nextActionKey)} +
; + const summary = <> +

{ui('ReportBoundary')}

+
+

{report.verified} / {report.required}{ui('Checks')}

+

{report.failed}{ui('Failures')}

{report.unknown}{ui('UnknownChecks')}

+

{workspace.learnedCapabilityIds.length} / {catalog.capabilities.length}{ui('Learning')}

+
+ {report.hasCriticalUncertainty &&

{ui('CriticalUnknown')}

} + {!report.snapshot.consistent &&

{ui('Inconsistent')}

} + {(report.uncheckedAreaIds?.length ?? 0) > 0 &&

{ui('UncheckedAreas')}: {report.uncheckedAreaIds.map(id => t(catalog.areas.find(area => area.id === id)?.labelKey ?? id)).join(', ')}.

} +

{ui('VerificationCoverage')}

+

{ui('Freshness')}:

+

{ui('Optional')}

+ ; + return
+ +

{ui(tab)}

+ {busy &&

{ui('Loading')}

}{error &&

{error}

} + {(tab === 'overview' || tab === 'report') && <>{summary}

{ui('Next')}

{next.map(findingCard)}{next.length === 0 &&

{ui('None')}

}
+

{ui('Reviewed')}: {workspace.reviewedOnUtc ? new Date(workspace.reviewedOnUtc).toLocaleString() : ui('NoReview')}

+ {workspace.reviewEvidence &&

{ui('ReviewEvidence')}: {workspace.reviewEvidence.verified} / {workspace.reviewEvidence.required} · {ui('Failures')}: {workspace.reviewEvidence.failed} · {ui('UnknownChecks')}: {workspace.reviewEvidence.unknown} · {new Date(workspace.reviewEvidence.asOfUtc).toLocaleString()}

} + {workspace.reviewEvidence && (workspace.reviewEvidence.catalogVersion !== catalog.version || workspace.reviewEvidence.snapshotRevision !== report.snapshot.revision || (workspace.reviewEvidence.scopeRevision ?? 0) !== (workspace.scopeRevision ?? 0)) &&

{ui('ReviewChanged')}

} + {catalog.canSetup && } + {catalog.canSetup &&
{ event.preventDefault(); const value = new FormData(event.currentTarget).get('revisit')?.toString(); void save('revisit', null, null, null, value ? `${value}T12:00:00.000Z` : null); }}> +

{ui('RevisitHelp')}

+
} + {!setup && } + {!setup && } + {!setup && } + {!setup && } + {!setup && } + {!setup && } + {!setup && } + {!setup && } + {tab === 'report' &&

{ui('PrintableReport')}

} + {selectedWorkflows} + {tab === 'report' &&
{ui('CapabilitySetupTitle')}

{ui('CapabilitySetupHelp')}

{catalog.capabilities.filter(c => !c.id.startsWith('addon-')).map(capability => { + const progress = overview.capabilitySetup?.find(s => s.capabilityId === capability.id); + const availability = overview.access.find(a => a.capabilityId === capability.id); + return

{t(capability.labelKey)}

+

{t(progress?.opportunityKey ?? 'Ui.OpportunityUnknown')}

{ui(`SetupState.${progress?.state ?? 'NotAssessed'}`)}

+

{t(progress?.guidanceKey ?? 'Ui.ConfigurationNotAssessed')}

+ {(progress?.ruleIds.length ?? 0) > 0 &&

{ui('ConfigurationChecks')}: {progress!.ruleIds.map(id => t(report.findings.find(f => f.ruleId === id)?.titleKey ?? id)).join(', ')}

} + {availability?.canConfigure && localLink(availability.destination) &&

{ui('Configure')}

} + {availability?.subscriptionDestination && localLink(availability.subscriptionDestination) &&

{ui('SubscriptionOptions')}

} + +
; + })}
} + {tab === 'report' &&
{report.findings.filter(f => selected(f) && f.result !== 'NotApplicable').map(findingCard)}
} + } + {tab === 'wizard' && <> +

{ui('Welcome')}

{ui('NoAutomaticActions')}

+ + {workspace.mode === 'Import' &&

{ui('ImportHelp')}

} + { setTab(page); if (page === 'explore') { setQuery(''); setAddonsOnly(false); } }} showAddons={() => { setQuery(''); setAddonsOnly(true); setTab('explore'); }} /> +

{ui('OperatingProfile')}

{operatingPacks?.state !== 'Known' &&

{ui('ProfileUnavailable')}

}

{ui('ProfileHelp')}

{ui('OperatingProfile')} +
{ui('Packs')}
{catalog.packs.map(pack =>

{t(pack.labelKey)}

{selectedPackIds.includes(pack.id) &&

{ui('SelectedPack')}

}

{t(pack.purposeKey)}

{pack.prerequisiteKeys.map(key =>

{t(key)}

)}
)}
+
{catalog.areas.map(area =>

{t(area.labelKey)}

{suggestedAreas.has(area.id) &&

{ui('SuggestedArea')}

}

{t(area.purposeKey)}

+ void save('area', area.id, choice, reason)} /> + +
)}
+ } + {tab === 'explore' && <> +

{ui('NoAutomaticActions')}

+
{filtered.map(capability => { + const access = overview.access.find(a => a.capabilityId === capability.id); + return
+

{t(capability.labelKey)}

{t(capability.purposeKey)}

+

{ui('Value')}

{t(capability.valueKey)}

{ui('Example')}

{t(capability.exampleKey)}

{ui('Adoption')}

{t(capability.adoptionKey)}

+ {addonNames(capability).length > 0 &&

{ui('RequiredAddons')}: {addonNames(capability).join(', ')}

} + {capability.id.startsWith('addon-') &&
{ui('AddonFeatures')}
    {catalog.capabilities.filter(child => !child.id.startsWith('addon-') && child.requirements.some(r => r.kind === 'addon' && capability.requirements.some(a => a.kind === 'addon' && a.id === r.id))).map(child =>
  • )}
} +

{ui('AvailableReasons')}: {ui(access?.state ?? 'Unknown')}

{access?.reasonCodes.map(reason =>

{ui(reason)}

)} + {access?.canConfigure && localLink(access.destination) &&

{ui('Configure')}

} + {access?.subscriptionDestination && localLink(access.subscriptionDestination) &&

{ui('SubscriptionOptions')}

} + {catalog.canSetup && <>} +
; + })}
{filtered.length === 0 &&

{ui('NoResults')}

} + } + {tab === 'health' && <>{summary}
{report.findings.map(findingCard)}
} + {tab === 'worklist' && <> +

{ui('WorklistHelp')}

+ {followup &&
{ui('Preferences')}

{followup.worker.lastEvaluatedOn ? `${ui('WorkerLastRun')}: ${new Date(followup.worker.lastEvaluatedOn).toLocaleString()}` : ui('WorkerNever')}

{ui('DigestHelp')}

{!followup.digestsAvailable &&

{ui('DigestUnavailable')}

} +
{ e.preventDefault(); void savePreferences(e.currentTarget); }}> + + + + +
{followup.preferences.lastAttemptOutcome &&

{ui('DigestOutcome')}: {followup.preferences.lastAttemptOutcome}

} +
} +
{worklist.filter(item => workFilter === 'all' || (workFilter === 'exceptions' ? item.reviewStatus === 3 : workFilter === 'unknown' ? item.result === 2 : item.result === 1)).map(item => { + const finding = report.findings.find(f => f.ruleId === item.ruleId); + return
+

{finding ? t(finding.titleKey) : item.ruleId}

{ui(['Pass', 'Fail', 'Unknown', 'NotApplicable'][item.result])} · {ui(['Unassigned', 'Assigned', 'InReview', 'AcceptedException', 'Resolved'][item.reviewStatus])}

+

{ui('Freshness')}: {new Date(item.lastObservedOn).toLocaleString()}

+ {item.reviewOn &&

{ui('ReviewDate')}: {new Date(item.reviewOn).toLocaleString()}

}{item.exceptionUntil &&

{ui('ExceptionExpiry')}: {new Date(item.exceptionUntil).toLocaleString()}

} + {item.content &&

{item.content}

} + {finding &&

{t(finding.nextActionKey)}

} + {item.reviewStatus !== 4 && (item.result === 1 || item.result === 2) && <> +
{ e.preventDefault(); void review(item, 'claim', e.currentTarget); }}>
} + {item.result === 1 && item.reviewStatus !== 4 &&
{ui('AcceptException')}
{ e.preventDefault(); void review(item, 'exception', e.currentTarget); }}> +
@@ -118,8 +110,7 @@ - @Html.DropDownListFor(m => m.DataClassificationLevel, Model.DataClassificationLevels, - new { @class = "form-control" }) + @localizer["SecurityPolicyDataClassNote"] @@ -140,8 +131,7 @@ - @Html.TextBoxFor(m => m.PasswordExpirationDays, - new { @class = "form-control", type = "number", min = "0", max = "3650" }) + @localizer["SecurityPolicyPasswordExpirationNote"] @@ -150,8 +140,7 @@ - @Html.TextBoxFor(m => m.MinPasswordLength, - new { @class = "form-control", type = "number", min = "8", max = "128" }) + @localizer["SecurityPolicyMinPasswordLengthNote"] diff --git a/Web/Resgrid.Web/Areas/User/Views/Shared/_AdminAssistReturn.cshtml b/Web/Resgrid.Web/Areas/User/Views/Shared/_AdminAssistReturn.cshtml new file mode 100644 index 000000000..104a78258 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Shared/_AdminAssistReturn.cshtml @@ -0,0 +1,6 @@ +@model string +@inject IStringLocalizer aaLabels + diff --git a/Web/Resgrid.Web/Areas/User/Views/Shared/_AdminAssistSetupPrompt.cshtml b/Web/Resgrid.Web/Areas/User/Views/Shared/_AdminAssistSetupPrompt.cshtml new file mode 100644 index 000000000..629aeac96 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Shared/_AdminAssistSetupPrompt.cshtml @@ -0,0 +1,12 @@ +@model Resgrid.Model.AdminAssist.SetupWorkspace +@inject IStringLocalizer aaLabels + diff --git a/Web/Resgrid.Web/Areas/User/Views/Shared/_TopNavbar.cshtml b/Web/Resgrid.Web/Areas/User/Views/Shared/_TopNavbar.cshtml index 15d405af2..ba0360732 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Shared/_TopNavbar.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Shared/_TopNavbar.cshtml @@ -1,6 +1,11 @@ @using Resgrid.Framework @inject Resgrid.Model.Services.IFeatureToggleService featureToggleService +@inject Microsoft.Extensions.Localization.IStringLocalizer aaLocalizer @{ + var adminSetupVisible = ClaimsAuthorizationHelper.IsUserDepartmentAdmin() && + await Resgrid.Services.AdminAssist.AdminAssistFeatureAvailability.IsEnabledAsync(featureToggleService, ClaimsAuthorizationHelper.GetDepartmentId(), true, Context.RequestAborted); + var adminAssistVisible = ClaimsAuthorizationHelper.IsUserDepartmentAdmin() && + await Resgrid.Services.AdminAssist.AdminAssistFeatureAvailability.IsEnabledAsync(featureToggleService, ClaimsAuthorizationHelper.GetDepartmentId(), false, Context.RequestAborted); // Chat.System gates the moderation console link; it is only useful to department or group admins. var chatModerationVisible = ClaimsAuthorizationHelper.IsUserDepartmentOrGroupAdmin(0) && await featureToggleService.IsEnabledAsync(FeatureFlagKeys.ChatSystem, ClaimsAuthorizationHelper.GetDepartmentId()); @@ -21,6 +26,15 @@