diff --git a/Core/Resgrid.Model/EventingTypes.cs b/Core/Resgrid.Model/EventingTypes.cs index 1a8ee0c8..a8b3715d 100644 --- a/Core/Resgrid.Model/EventingTypes.cs +++ b/Core/Resgrid.Model/EventingTypes.cs @@ -9,6 +9,7 @@ public enum EventingTypes CallAdded = 5, CallClosed = 6, PersonnelLocationUpdated = 7, - UnitLocationUpdated = 8 + UnitLocationUpdated = 8, + IncidentCommandUpdated = 9 } } diff --git a/Core/Resgrid.Model/Events/IncidentCommandEvents.cs b/Core/Resgrid.Model/Events/IncidentCommandEvents.cs index 3985b1c6..db2a7200 100644 --- a/Core/Resgrid.Model/Events/IncidentCommandEvents.cs +++ b/Core/Resgrid.Model/Events/IncidentCommandEvents.cs @@ -93,4 +93,15 @@ public class CriticalParDetectedEvent public int CallId { get; set; } public string UserId { get; set; } } + + /// + /// Raised whenever an incident command board changes (establish/transfer/close, lane/resource/objective/timer/ + /// annotation/role mutations, check-in/PAR). Drives the real-time SignalR "Real Time Sync" fan-out to connected + /// IC clients via the eventing topic, mirroring . + /// + public class IncidentCommandUpdatedEvent + { + public int DepartmentId { get; set; } + public int CallId { get; set; } + } } diff --git a/Core/Resgrid.Model/IncidentCommand/IncidentCommandBundle.cs b/Core/Resgrid.Model/IncidentCommand/IncidentCommandBundle.cs new file mode 100644 index 00000000..a368dffb --- /dev/null +++ b/Core/Resgrid.Model/IncidentCommand/IncidentCommandBundle.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; + +namespace Resgrid.Model +{ + /// + /// Shift-start aggregate for offline IC clients: a render-ready snapshot of every ACTIVE incident command in the + /// caller's department in a single round-trip. Each carries the COMPUTED + /// accountability / PAR status that the row-based delta cannot, plus the + /// active ad-hoc resources. The client stores and uses it as the since + /// cursor for subsequent incremental /Sync/Changes pulls. See + /// docs/architecture/offline-first-architecture.md (§6 / §9.5). + /// + public class IncidentCommandBundle + { + /// Server clock (Unix epoch ms) captured at the start of the read; seeds the next /Sync/Changes cursor. + public long ServerTimestampMs { get; set; } + + /// One render-ready board (incl. accountability / PAR) per active incident command in the department. + public List Boards { get; set; } = new List(); + + /// Active ad-hoc units across the department's active incidents (aggregated by the caller). + public List AdHocUnits { get; set; } = new List(); + + /// Active ad-hoc personnel across the department's active incidents (aggregated by the caller). + public List AdHocPersonnel { get; set; } = new List(); + } +} diff --git a/Core/Resgrid.Model/IncidentCommand/SyncReferenceData.cs b/Core/Resgrid.Model/IncidentCommand/SyncReferenceData.cs new file mode 100644 index 00000000..1313e2b1 --- /dev/null +++ b/Core/Resgrid.Model/IncidentCommand/SyncReferenceData.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Generic; +using ProtoBuf; + +namespace Resgrid.Model +{ + /// + /// Offline shift-start REFERENCE data: the slowly-changing department configuration + roster an IC app needs to + /// START and RUN an incident in the field (call types, command templates, units, personnel, groups, POIs, + /// protocols, accountability config, statuses, feature flags). Pulled once at shift start / on manual refresh; + /// the LIVE per-incident state comes from /Sync/Bundle (active boards) and /Sync/Changes (deltas). See + /// docs/architecture/offline-first-architecture.md. Personnel is a SAFE PROJECTION () + /// — never the raw IdentityUser/UserProfile (which carry credentials + verification codes). + /// + public class SyncReferenceData + { + /// Server clock (Unix epoch ms) captured at the start of the read. + public long ServerTimestampMs { get; set; } + + public List CallTypes { get; set; } = new List(); + + public List CallPriorities { get; set; } = new List(); + + /// Command-definition templates (predefined swimlanes per call type) used to seed a new command. + public List CommandTemplates { get; set; } = new List(); + + public List Units { get; set; } = new List(); + + public List UnitTypes { get; set; } = new List(); + + public List Groups { get; set; } = new List(); + + public List Pois { get; set; } = new List(); + + public List PoiTypes { get; set; } = new List(); + + public List Protocols { get; set; } = new List(); + + public List CheckInTimerConfigs { get; set; } = new List(); + + /// Department-defined personnel custom statuses. + public List PersonnelStates { get; set; } = new List(); + + /// Department-defined unit custom statuses. + public List UnitStates { get; set; } = new List(); + + /// Safe personnel roster projection (no credentials / contact-verification secrets). + public List Personnel { get; set; } = new List(); + + /// Resolved feature flags for the department (drives addon/feature gating offline). + public List Features { get; set; } = new List(); + } + + /// + /// Safe, minimal personnel projection for offline rosters — mirrors the field exposure of the existing v4 + /// PersonnelInfoResultData. Deliberately EXCLUDES the IdentityUser nav, password/security fields, and the + /// UserProfile contact-verification codes + CalendarSyncToken. + /// + public class ReferencePersonnel + { + public string UserId { get; set; } + + public string FirstName { get; set; } + + public string LastName { get; set; } + + public string MobilePhone { get; set; } + + /// Primary group/station membership, if any. + public int? GroupId { get; set; } + + public string GroupName { get; set; } + + /// Current personnel state (UserState.State); 0 when unknown. + public int StateId { get; set; } + + public DateTime? StateTimestamp { get; set; } + } + + /// Safe, minimal department group/station projection — excludes the member IdentityUser navs. + public class ReferenceGroup + { + public int GroupId { get; set; } + + public string Name { get; set; } + + public int? Type { get; set; } + + public int? ParentGroupId { get; set; } + } + + /// + /// Protobuf-safe cache envelope for the reference bootstrap. The cache provider serializes via protobuf-net, but + /// most of 's contained entities are not [ProtoContract]; rather than + /// ProtoContract-tag ~8 shared entities, the reference payload is cached as a JSON snapshot inside this contract. + /// + [ProtoContract] + public class ReferenceCacheEnvelope + { + [ProtoMember(1)] + public string Json { get; set; } + } +} diff --git a/Core/Resgrid.Model/Providers/IRabbitInboundEventProvider.cs b/Core/Resgrid.Model/Providers/IRabbitInboundEventProvider.cs index b9c3b76d..bb11588d 100644 --- a/Core/Resgrid.Model/Providers/IRabbitInboundEventProvider.cs +++ b/Core/Resgrid.Model/Providers/IRabbitInboundEventProvider.cs @@ -14,6 +14,7 @@ void RegisterForEvents(Func personnelStatusChanged, Func callAdded, Func callClosed, Func personnelLocationUpdated, - Func unitLocationUpdated); + Func unitLocationUpdated, + Func incidentCommandUpdated); } } diff --git a/Core/Resgrid.Model/Services/ICoreEventService.cs b/Core/Resgrid.Model/Services/ICoreEventService.cs index 943a2e66..3cba08c1 100644 --- a/Core/Resgrid.Model/Services/ICoreEventService.cs +++ b/Core/Resgrid.Model/Services/ICoreEventService.cs @@ -10,8 +10,9 @@ public interface ICoreEventService { /// /// Publishes a real-time "incident command updated" notification for a call so connected IC clients - /// re-sync the board (Tablet Command-style Real Time Sync). Fans out via the CQRS/eventing pipeline - /// to the per-department SignalR group. + /// re-sync the board (Tablet Command-style Real Time Sync). Fans out via the eventing topic pipeline + /// (OutboundEventProvider -> RabbitTopicProvider -> EventingTopic -> Eventing Worker) to the + /// per-department SignalR group as the "incidentCommandUpdated" client message. /// Task IncidentCommandUpdatedAsync(int departmentId, int callId); } diff --git a/Core/Resgrid.Model/Services/IIncidentCommandService.cs b/Core/Resgrid.Model/Services/IIncidentCommandService.cs index 5f899093..53267463 100644 --- a/Core/Resgrid.Model/Services/IIncidentCommandService.cs +++ b/Core/Resgrid.Model/Services/IIncidentCommandService.cs @@ -26,6 +26,16 @@ public interface IIncidentCommandService /// Task GetChangesSinceAsync(int departmentId, System.DateTime sinceUtc); + /// Returns every ACTIVE incident command for the department (Status == Active). + Task> GetActiveCommandsForDepartmentAsync(int departmentId); + + /// + /// Offline shift-start aggregate: a render-ready board (incl. computed accountability / PAR) for every active + /// incident in the department, plus the next-sync cursor, in one pull — cutting shift-start round-trips vs. + /// fanning out per incident. Ad-hoc resources live in a sibling service and are aggregated by the caller. + /// + Task GetBundleForDepartmentAsync(int departmentId, bool includeAccountability = true); + /// /// Sweeps personnel accountability (PAR) for the call and raises CriticalParDetectedEvent once per /// member each time they transition into the Critical (overdue) state. Idempotent via a timeline marker — diff --git a/Core/Resgrid.Model/Services/IIncidentResourcesService.cs b/Core/Resgrid.Model/Services/IIncidentResourcesService.cs index b99bfffc..01b5520d 100644 --- a/Core/Resgrid.Model/Services/IIncidentResourcesService.cs +++ b/Core/Resgrid.Model/Services/IIncidentResourcesService.cs @@ -33,5 +33,11 @@ public interface IIncidentResourcesService /// Aggregated into the unified /Sync/Changes payload by SyncController. See offline-first-architecture.md. /// Task<(List Units, List Personnel)> GetAdHocChangesSinceAsync(int departmentId, System.DateTime sinceUtc); + + /// + /// Returns all ACTIVE (non-released) ad-hoc units + personnel across the department's active incidents in one + /// batched read (one scan per ad-hoc table), for the shift-start bundle — replaces the per-incident N+1 lookups. + /// + Task<(List Units, List Personnel)> GetActiveAdHocResourcesForDepartmentAsync(int departmentId); } } diff --git a/Core/Resgrid.Model/Services/ISyncService.cs b/Core/Resgrid.Model/Services/ISyncService.cs new file mode 100644 index 00000000..3107a842 --- /dev/null +++ b/Core/Resgrid.Model/Services/ISyncService.cs @@ -0,0 +1,14 @@ +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// Aggregates the offline shift-start REFERENCE data set (department configuration + a safe personnel roster) into + /// a single payload, so an IC/Unit app can pull everything it needs to start and run an incident in one round-trip. + /// The live per-incident state is delivered separately by IIncidentCommandService (board bundle + change deltas). + /// + public interface ISyncService + { + Task GetReferenceDataAsync(int departmentId, bool bypassCache = false); + } +} diff --git a/Core/Resgrid.Services/CoreEventService.cs b/Core/Resgrid.Services/CoreEventService.cs index 30b76061..80256f22 100644 --- a/Core/Resgrid.Services/CoreEventService.cs +++ b/Core/Resgrid.Services/CoreEventService.cs @@ -11,12 +11,10 @@ namespace Resgrid.Services public class CoreEventService : ICoreEventService { private readonly IEventAggregator _eventAggregator; - private static ICqrsProvider _cqrsProvider; - public CoreEventService(IEventAggregator eventAggregator, ICqrsProvider cqrsProvider) + public CoreEventService(IEventAggregator eventAggregator) { _eventAggregator = eventAggregator; - _cqrsProvider = cqrsProvider; _eventAggregator.AddListener(departmentSettingsUpdateHandler); } @@ -27,16 +25,18 @@ public CoreEventService(IEventAggregator eventAggregator, ICqrsProvider cqrsProv var result = await departmentSettingsService.SaveOrUpdateSettingAsync(message.DepartmentId, DateTime.UtcNow.ToString("G"), DepartmentSettingTypes.UpdateTimestamp); }; - public async Task IncidentCommandUpdatedAsync(int departmentId, int callId) + public Task IncidentCommandUpdatedAsync(int departmentId, int callId) { - var cqrsEvent = new CqrsEvent + // Raise the domain event onto the eventing/topic rail (OutboundEventProvider -> + // RabbitTopicProvider -> EventingTopic -> Eventing Worker -> SignalR "incidentCommandUpdated"), + // mirroring how CallUpdatedEvent drives "callsUpdated". + _eventAggregator.SendMessage(new IncidentCommandUpdatedEvent { - Type = (int)CqrsEventTypes.IncidentCommandUpdated, - AggregateId = callId.ToString(), - Data = $"{{\"departmentId\":{departmentId},\"callId\":{callId}}}" - }; + DepartmentId = departmentId, + CallId = callId + }); - await _cqrsProvider.EnqueueCqrsEventAsync(cqrsEvent); + return Task.CompletedTask; } } } diff --git a/Core/Resgrid.Services/IncidentCommandService.cs b/Core/Resgrid.Services/IncidentCommandService.cs index 5261d014..0356ee10 100644 --- a/Core/Resgrid.Services/IncidentCommandService.cs +++ b/Core/Resgrid.Services/IncidentCommandService.cs @@ -157,6 +157,15 @@ public async Task GetActiveCommandForCallAsync(int departmentId return items?.FirstOrDefault(x => x.CallId == callId && x.Status == (int)IncidentCommandStatus.Active); } + public async Task> GetActiveCommandsForDepartmentAsync(int departmentId) + { + var items = await _incidentCommandRepository.GetAllByDepartmentIdAsync(departmentId); + if (items == null) + return new List(); + + return items.Where(x => x.Status == (int)IncidentCommandStatus.Active).ToList(); + } + public async Task GetCommandByIdAsync(string incidentCommandId) { return await _incidentCommandRepository.GetByIdAsync(incidentCommandId); @@ -351,6 +360,61 @@ public async Task GetCommandBoardAsync(int departmentId, i return board; } + public async Task GetBundleForDepartmentAsync(int departmentId, bool includeAccountability = true) + { + // Capture the cursor before reading so the client's first incremental /Sync/Changes call doesn't miss a + // row committed during this read (a re-returned row is harmless — the client upserts idempotently). + var bundle = new IncidentCommandBundle { ServerTimestampMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() }; + + var active = await GetActiveCommandsForDepartmentAsync(departmentId); + if (active.Count == 0) + return bundle; + + // Pull each board table ONCE for the whole department and index by CallId, instead of re-scanning every + // table per incident. The per-call getters each do a full GetAllByDepartmentIdAsync, and GetCommandBoardAsync + // additionally fires the write-side PAR sweep — so assembling N boards that way is O(active incidents × + // department size) plus N marker-writes / SignalR pushes. Doing it here keeps the bundle O(number of tables) + // and side-effect free, which is what hurts departments with many open/active incidents. + var nodes = ToCallLookup(await _commandStructureNodeRepository.GetAllByDepartmentIdAsync(departmentId), x => x.CallId); + var assignments = ToCallLookup(await _resourceAssignmentRepository.GetAllByDepartmentIdAsync(departmentId), x => x.CallId); + var objectives = ToCallLookup(await _tacticalObjectiveRepository.GetAllByDepartmentIdAsync(departmentId), x => x.CallId); + var timers = ToCallLookup(await _incidentTimerRepository.GetAllByDepartmentIdAsync(departmentId), x => x.CallId); + var annotations = ToCallLookup(await _incidentMapAnnotationRepository.GetAllByDepartmentIdAsync(departmentId), x => x.CallId); + var roles = ToCallLookup(await _incidentRoleAssignmentRepository.GetAllByDepartmentIdAsync(departmentId), x => x.CallId); + + foreach (var command in active) + { + var callId = command.CallId; + + var board = new IncidentCommandBoard + { + Command = command, + // These mirror the per-call getter filters exactly (DeletedOn / ReleasedOn / RemovedOn tombstones + + // the active-timer rule), so the bundled board matches what GetCommandBoardAsync would return. + Nodes = nodes[callId].Where(x => x.DeletedOn == null).OrderBy(x => x.SortOrder).ToList(), + Assignments = assignments[callId].Where(x => x.ReleasedOn == null).ToList(), + Objectives = objectives[callId].OrderBy(x => x.SortOrder).ToList(), + Timers = timers[callId].Where(x => x.Status != (int)IncidentTimerStatus.Stopped).ToList(), + Annotations = annotations[callId].Where(x => x.DeletedOn == null).ToList(), + Roles = roles[callId].Where(x => x.RemovedOn == null).ToList() + }; + + // Accountability/PAR is the one per-incident read here, and it is READ-ONLY (no marker writes / SignalR + // pushes — unlike GetCommandBoardAsync's sweep). A department with very many open incidents can opt out + // via includeAccountability=false and fetch PAR per incident on demand. + if (includeAccountability) + board.Accountability = await GetAccountabilityForCallAsync(departmentId, callId); + + bundle.Boards.Add(board); + } + + return bundle; + } + + /// Indexes a department-wide row set by CallId; a missing key yields an empty sequence (no exception). + private static ILookup ToCallLookup(IEnumerable items, Func callIdSelector) + => (items ?? Enumerable.Empty()).ToLookup(callIdSelector); + public async Task GetChangesSinceAsync(int departmentId, DateTime sinceUtc) { // Capture the cursor before reading so a row committed during the read is not missed next time (it may be diff --git a/Core/Resgrid.Services/IncidentResourcesService.cs b/Core/Resgrid.Services/IncidentResourcesService.cs index 1490c45d..e057e6dc 100644 --- a/Core/Resgrid.Services/IncidentResourcesService.cs +++ b/Core/Resgrid.Services/IncidentResourcesService.cs @@ -217,6 +217,21 @@ public async Task> GetAdHocPersonnelForCallAsync(in personnel?.Where(Changed).ToList() ?? new List()); } + public async Task<(List Units, List Personnel)> GetActiveAdHocResourcesForDepartmentAsync(int departmentId) + { + // Scope to the department's active incidents and exclude released rows, in ONE scan per ad-hoc table — + // replaces the bundle's previous per-board (N+1) GetAdHoc*ForCallAsync lookups. + var activeCallIds = (await _incidentCommandService.GetActiveCommandsForDepartmentAsync(departmentId)) + .Select(c => c.CallId).ToHashSet(); + + var units = await _adHocUnitRepository.GetAllByDepartmentIdAsync(departmentId); + var personnel = await _adHocPersonnelRepository.GetAllByDepartmentIdAsync(departmentId); + + return ( + units?.Where(u => u.ReleasedOn == null && activeCallIds.Contains(u.CallId)).ToList() ?? new List(), + personnel?.Where(p => p.ReleasedOn == null && activeCallIds.Contains(p.CallId)).ToList() ?? new List()); + } + #endregion Offline sync #region Private helpers diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs index d2cbab64..f198cab6 100644 --- a/Core/Resgrid.Services/ServicesModule.cs +++ b/Core/Resgrid.Services/ServicesModule.cs @@ -20,6 +20,7 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Core/Resgrid.Services/SyncService.cs b/Core/Resgrid.Services/SyncService.cs new file mode 100644 index 00000000..fffbffad --- /dev/null +++ b/Core/Resgrid.Services/SyncService.cs @@ -0,0 +1,180 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + /// + /// Aggregates the offline shift-start REFERENCE data set (department configuration + a SAFE personnel roster) into + /// one payload. Read-only. Personnel is projected to so no credentials, security + /// fields, or contact-verification secrets are exposed. The live per-incident state is delivered separately by the + /// incident-command bundle (/Sync/Bundle) and delta (/Sync/Changes) endpoints. + /// + public class SyncService : ISyncService + { + private readonly ICallsService _callsService; + private readonly ICommandsService _commandsService; + private readonly IUnitsService _unitsService; + private readonly IDepartmentGroupsService _departmentGroupsService; + private readonly IMappingService _mappingService; + private readonly IProtocolsService _protocolsService; + private readonly ICheckInTimerService _checkInTimerService; + private readonly ICustomStateService _customStateService; + private readonly IUserProfileService _userProfileService; + private readonly IUserStateService _userStateService; + private readonly IFeatureToggleService _featureToggleService; + private readonly ICacheProvider _cacheProvider; + + private static readonly string CacheKey = "SyncReferenceData_{0}"; + private static readonly TimeSpan CacheLength = TimeSpan.FromMinutes(5); + + public SyncService( + ICallsService callsService, + ICommandsService commandsService, + IUnitsService unitsService, + IDepartmentGroupsService departmentGroupsService, + IMappingService mappingService, + IProtocolsService protocolsService, + ICheckInTimerService checkInTimerService, + ICustomStateService customStateService, + IUserProfileService userProfileService, + IUserStateService userStateService, + IFeatureToggleService featureToggleService, + ICacheProvider cacheProvider) + { + _callsService = callsService; + _commandsService = commandsService; + _unitsService = unitsService; + _departmentGroupsService = departmentGroupsService; + _mappingService = mappingService; + _protocolsService = protocolsService; + _checkInTimerService = checkInTimerService; + _customStateService = customStateService; + _userProfileService = userProfileService; + _userStateService = userStateService; + _featureToggleService = featureToggleService; + _cacheProvider = cacheProvider; + } + + public async Task GetReferenceDataAsync(int departmentId, bool bypassCache = false) + { + async Task build() + { + var data = new SyncReferenceData { ServerTimestampMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() }; + + // Configuration / reference entities returned as-is (audited: no secret scalar fields, no IdentityUser navs). + data.CallTypes = await _callsService.GetCallTypesForDepartmentAsync(departmentId) ?? new List(); + data.CallPriorities = await _callsService.GetCallPrioritiesForDepartmentAsync(departmentId) ?? new List(); + data.CommandTemplates = await _commandsService.GetAllCommandsForDepartmentAsync(departmentId) ?? new List(); + data.Units = await _unitsService.GetUnitsForDepartmentAsync(departmentId) ?? new List(); + data.UnitTypes = await _unitsService.GetUnitTypesForDepartmentAsync(departmentId) ?? new List(); + data.Pois = await _mappingService.GetPOIsForDepartmentAsync(departmentId) ?? new List(); + data.PoiTypes = await _mappingService.GetPOITypesForDepartmentAsync(departmentId) ?? new List(); + data.Protocols = await _protocolsService.GetAllProtocolsForDepartmentAsync(departmentId) ?? new List(); + data.CheckInTimerConfigs = await _checkInTimerService.GetTimerConfigsForDepartmentAsync(departmentId) ?? new List(); + data.PersonnelStates = await _customStateService.GetAllActiveCustomStatesForDepartmentAsync(departmentId) ?? new List(); + data.UnitStates = await _customStateService.GetAllActiveUnitStatesForDepartmentAsync(departmentId) ?? new List(); + data.Features = await _featureToggleService.EvaluateAllForDepartmentAsync(departmentId) ?? new List(); + + // Groups: project to a safe shape. The raw DepartmentGroup.Members carry IdentityUser navs we must not leak, + // and mutating the (possibly cached) entities to strip them would be unsafe. + var groups = await _departmentGroupsService.GetAllGroupsForDepartmentAsync(departmentId) ?? new List(); + data.Groups = groups.Select(g => new ReferenceGroup + { + GroupId = g.DepartmentGroupId, + Name = g.Name, + Type = g.Type, + ParentGroupId = g.ParentDepartmentGroupId + }).ToList(); + + data.Personnel = await BuildPersonnelAsync(departmentId, groups); + + return data; + } + + if (bypassCache || !Config.SystemBehaviorConfig.CacheEnabled) + return await build(); + + // Cache-aside, department-scoped. The cache provider serializes via protobuf-net and SyncReferenceData's + // contained entities are mostly not [ProtoContract], so the payload is cached as a JSON snapshot inside a + // protobuf-safe envelope rather than ProtoContract-tagging ~8 shared entities (see ReferenceCacheEnvelope). + var envelope = await _cacheProvider.RetrieveAsync( + string.Format(CacheKey, departmentId), + async () => new ReferenceCacheEnvelope { Json = JsonConvert.SerializeObject(await build()) }, + CacheLength); + + return !string.IsNullOrEmpty(envelope?.Json) + ? JsonConvert.DeserializeObject(envelope.Json) + : await build(); + } + + /// + /// Builds the SAFE personnel roster (name + mobile, primary group, current state) projected from UserProfile + + /// UserState — never exposing the IdentityUser nav, password/security fields, or the UserProfile contact- + /// verification codes / CalendarSyncToken. Mirrors the field exposure of the existing v4 PersonnelInfoResultData. + /// + private async Task> BuildPersonnelAsync(int departmentId, List groups) + { + var personnel = new List(); + + var profiles = await _userProfileService.GetAllProfilesForDepartmentAsync(departmentId); + if (profiles == null || profiles.Count == 0) + return personnel; + + // First group membership wins as the member's "primary" group. + var userGroup = new Dictionary(); + foreach (var g in groups) + { + if (g.Members == null) + continue; + + foreach (var m in g.Members) + { + if (!string.IsNullOrWhiteSpace(m.UserId) && !userGroup.ContainsKey(m.UserId)) + userGroup[m.UserId] = new ReferenceGroup { GroupId = g.DepartmentGroupId, Name = g.Name }; + } + } + + var states = await _userStateService.GetStatesForDepartmentAsync(departmentId) ?? new List(); + var stateByUser = states + .Where(s => !string.IsNullOrWhiteSpace(s.UserId)) + .GroupBy(s => s.UserId) + .ToDictionary(grp => grp.Key, grp => grp.OrderByDescending(s => s.Timestamp).First()); + + foreach (var profile in profiles.Values) + { + if (profile == null || string.IsNullOrWhiteSpace(profile.UserId)) + continue; + + var person = new ReferencePersonnel + { + UserId = profile.UserId, + FirstName = profile.FirstName, + LastName = profile.LastName, + MobilePhone = profile.MobileNumber + }; + + if (userGroup.TryGetValue(profile.UserId, out var grp)) + { + person.GroupId = grp.GroupId; + person.GroupName = grp.Name; + } + + if (stateByUser.TryGetValue(profile.UserId, out var state)) + { + person.StateId = state.State; + person.StateTimestamp = state.Timestamp; + } + + personnel.Add(person); + } + + return personnel; + } + } +} diff --git a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitInboundEventProvider.cs b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitInboundEventProvider.cs index cbb04081..a0b086cc 100644 --- a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitInboundEventProvider.cs +++ b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitInboundEventProvider.cs @@ -27,6 +27,7 @@ public class RabbitInboundEventProvider : IRabbitInboundEventProvider public Func ProcessPersonnelStaffingChanged; public Func PersonnelLocationUpdated; public Func UnitLocationUpdated; + public Func ProcessIncidentCommandUpdated; public async Task Start(string clientName, string queueName) { @@ -76,48 +77,59 @@ await _channel.QueueBindAsync(queue: queue.QueueName, var body = ea.Body.ToArray(); var message = Encoding.UTF8.GetString(body); - var eventingMessage = JsonConvert.DeserializeObject(message); - - if (eventingMessage != null) + try { - switch ((EventingTypes)eventingMessage.Type) + var eventingMessage = JsonConvert.DeserializeObject(message); + + if (eventingMessage != null) { - case EventingTypes.PersonnelStatusUpdated: - if (ProcessPersonnelStatusChanged != null) - await ProcessPersonnelStatusChanged(eventingMessage.DepartmentId, eventingMessage.ItemId); - break; - case EventingTypes.UnitStatusUpdated: - if (ProcessUnitStatusChanged != null) - await ProcessUnitStatusChanged.Invoke(eventingMessage.DepartmentId, eventingMessage.ItemId); - break; - case EventingTypes.CallsUpdated: - if (ProcessCallStatusChanged != null) - await ProcessCallStatusChanged.Invoke(eventingMessage.DepartmentId, eventingMessage.ItemId); - break; - case EventingTypes.CallAdded: - if (ProcessCallAdded != null) - await ProcessCallAdded.Invoke(eventingMessage.DepartmentId, eventingMessage.ItemId); - break; - case EventingTypes.CallClosed: - if (ProcessCallClosed != null) - await ProcessCallClosed.Invoke(eventingMessage.DepartmentId, eventingMessage.ItemId); - break; - case EventingTypes.PersonnelStaffingUpdated: - if (ProcessPersonnelStaffingChanged != null) - await ProcessPersonnelStaffingChanged.Invoke(eventingMessage.DepartmentId, eventingMessage.ItemId); - break; - case EventingTypes.PersonnelLocationUpdated: - if (PersonnelLocationUpdated != null) - await PersonnelLocationUpdated.Invoke(eventingMessage.DepartmentId, JsonConvert.DeserializeObject(eventingMessage.Payload)); - break; - case EventingTypes.UnitLocationUpdated: - if (UnitLocationUpdated != null) - await UnitLocationUpdated.Invoke(eventingMessage.DepartmentId, JsonConvert.DeserializeObject(eventingMessage.Payload)); - break; - default: - throw new ArgumentOutOfRangeException(); + switch ((EventingTypes)eventingMessage.Type) + { + case EventingTypes.PersonnelStatusUpdated: + if (ProcessPersonnelStatusChanged != null) + await ProcessPersonnelStatusChanged(eventingMessage.DepartmentId, eventingMessage.ItemId); + break; + case EventingTypes.UnitStatusUpdated: + if (ProcessUnitStatusChanged != null) + await ProcessUnitStatusChanged.Invoke(eventingMessage.DepartmentId, eventingMessage.ItemId); + break; + case EventingTypes.CallsUpdated: + if (ProcessCallStatusChanged != null) + await ProcessCallStatusChanged.Invoke(eventingMessage.DepartmentId, eventingMessage.ItemId); + break; + case EventingTypes.CallAdded: + if (ProcessCallAdded != null) + await ProcessCallAdded.Invoke(eventingMessage.DepartmentId, eventingMessage.ItemId); + break; + case EventingTypes.CallClosed: + if (ProcessCallClosed != null) + await ProcessCallClosed.Invoke(eventingMessage.DepartmentId, eventingMessage.ItemId); + break; + case EventingTypes.PersonnelStaffingUpdated: + if (ProcessPersonnelStaffingChanged != null) + await ProcessPersonnelStaffingChanged.Invoke(eventingMessage.DepartmentId, eventingMessage.ItemId); + break; + case EventingTypes.PersonnelLocationUpdated: + if (PersonnelLocationUpdated != null) + await PersonnelLocationUpdated.Invoke(eventingMessage.DepartmentId, JsonConvert.DeserializeObject(eventingMessage.Payload)); + break; + case EventingTypes.UnitLocationUpdated: + if (UnitLocationUpdated != null) + await UnitLocationUpdated.Invoke(eventingMessage.DepartmentId, JsonConvert.DeserializeObject(eventingMessage.Payload)); + break; + case EventingTypes.IncidentCommandUpdated: + if (ProcessIncidentCommandUpdated != null) + await ProcessIncidentCommandUpdated.Invoke(eventingMessage.DepartmentId, eventingMessage.ItemId); + break; + default: + throw new ArgumentOutOfRangeException(); + } } } + catch (Exception ex) + { + Logging.LogException(ex); + } }; await _channel.BasicConsumeAsync(queue: queue.QueueName, autoAck: true, @@ -139,7 +151,8 @@ public void RegisterForEvents(Func personnelStatusChanged, Func callAdded, Func callClosed, Func personnelLocationUpdated, - Func unitLocationUpdated) + Func unitLocationUpdated, + Func incidentCommandUpdated) { ProcessPersonnelStatusChanged = personnelStatusChanged; ProcessUnitStatusChanged = unitStatusChanged; @@ -149,6 +162,7 @@ public void RegisterForEvents(Func personnelStatusChanged, ProcessCallClosed = callClosed; PersonnelLocationUpdated = personnelLocationUpdated; UnitLocationUpdated = unitLocationUpdated; + ProcessIncidentCommandUpdated = incidentCommandUpdated; } } } diff --git a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitTopicProvider.cs b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitTopicProvider.cs index 3f0eabc1..9285e949 100644 --- a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitTopicProvider.cs +++ b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitTopicProvider.cs @@ -81,6 +81,18 @@ public async Task CallUpdated(CallUpdatedEvent message) }.SerializeJson()); } + public async Task IncidentCommandUpdated(IncidentCommandUpdatedEvent message) + { + return await SendMessage(Topics.EventingTopic, new EventingMessage + { + Id = Guid.NewGuid(), + Type = (int)EventingTypes.IncidentCommandUpdated, + TimeStamp = DateTime.UtcNow, + DepartmentId = message.DepartmentId, + ItemId = message.CallId.ToString() + }.SerializeJson()); + } + public async Task CallClosed(CallClosedEvent message) { return await SendMessage(Topics.EventingTopic, new EventingMessage diff --git a/Providers/Resgrid.Providers.Bus/OutboundEventProvider.cs b/Providers/Resgrid.Providers.Bus/OutboundEventProvider.cs index 196e7545..805e1136 100644 --- a/Providers/Resgrid.Providers.Bus/OutboundEventProvider.cs +++ b/Providers/Resgrid.Providers.Bus/OutboundEventProvider.cs @@ -56,6 +56,7 @@ public OutboundEventProvider(IEventAggregator eventAggregator, IOutboundQueuePro _eventAggregator.AddListener(callAddedTopicHandler); _eventAggregator.AddListener(callUpdatedTopicHandler); _eventAggregator.AddListener(callClosedTopicHandler); + _eventAggregator.AddListener(incidentCommandUpdatedTopicHandler); _eventAggregator.AddListener(personnelLocationUpdatedTopicHandler); _eventAggregator.AddListener(unitLocationUpdatedTopicHandler); } @@ -585,6 +586,14 @@ public OutboundEventProvider(IEventAggregator eventAggregator, IOutboundQueuePro _rabbitTopicProvider.CallUpdated(message); }; + public Action incidentCommandUpdatedTopicHandler = async delegate (IncidentCommandUpdatedEvent message) + { + if (_rabbitTopicProvider == null) + _rabbitTopicProvider = new RabbitTopicProvider(); + + _rabbitTopicProvider.IncidentCommandUpdated(message); + }; + public Action callClosedTopicHandler = async delegate (CallClosedEvent message) { if (_rabbitTopicProvider == null) diff --git a/Tests/Resgrid.Tests/Services/CoreEventServiceTests.cs b/Tests/Resgrid.Tests/Services/CoreEventServiceTests.cs new file mode 100644 index 00000000..8ceb53dc --- /dev/null +++ b/Tests/Resgrid.Tests/Services/CoreEventServiceTests.cs @@ -0,0 +1,31 @@ +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Resgrid.Model.Events; +using Resgrid.Model.Providers; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + /// + /// Covers the IC real-time publish side. IncidentCommandUpdatedAsync must raise an IncidentCommandUpdatedEvent + /// onto the eventing/topic rail (OutboundEventProvider -> RabbitTopicProvider -> EventingTopic -> Eventing Worker + /// -> SignalR "incidentCommandUpdated"), mirroring how CallUpdatedEvent drives "callsUpdated" — NOT the CQRS rail. + /// + [TestFixture] + public class CoreEventServiceTests + { + [Test] + public async Task IncidentCommandUpdatedAsync_RaisesIncidentCommandUpdatedEvent_WithDeptAndCall() + { + var eventAggregator = new Mock(); + var service = new CoreEventService(eventAggregator.Object); + + await service.IncidentCommandUpdatedAsync(42, 1001); + + eventAggregator.Verify(x => x.SendMessage( + It.Is(e => e.DepartmentId == 42 && e.CallId == 1001)), + Times.Once); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/IncidentCommandServiceParTests.cs b/Tests/Resgrid.Tests/Services/IncidentCommandServiceParTests.cs index 26ec7c48..a8639977 100644 --- a/Tests/Resgrid.Tests/Services/IncidentCommandServiceParTests.cs +++ b/Tests/Resgrid.Tests/Services/IncidentCommandServiceParTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using FluentAssertions; @@ -113,6 +114,96 @@ private void ArrangeTimeline(params CommandLogEntry[] entries) LastCheckIn = lastCheckIn }; + [Test] + public async Task GetActiveCommandsForDepartmentAsync_ReturnsOnlyActiveCommands() + { + _commandRepo.Setup(x => x.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List + { + new IncidentCommand { IncidentCommandId = "ic1", DepartmentId = Dept, CallId = 1, Status = (int)IncidentCommandStatus.Active }, + new IncidentCommand { IncidentCommandId = "ic2", DepartmentId = Dept, CallId = 2, Status = (int)IncidentCommandStatus.Closed }, + new IncidentCommand { IncidentCommandId = "ic3", DepartmentId = Dept, CallId = 3, Status = (int)IncidentCommandStatus.Active } + }); + + var active = await _service.GetActiveCommandsForDepartmentAsync(Dept); + + active.Should().HaveCount(2); + active.Select(c => c.IncidentCommandId).Should().BeEquivalentTo(new[] { "ic1", "ic3" }); + } + + [Test] + public async Task GetBundleForDepartmentAsync_IncludesABoardPerActiveCommand_AndSetsCursor() + { + ArrangeCall(); // call on CallId=1, check-in timers enabled, owned by Dept + ArrangeActiveCommand(); // one active command on CallId=1 + + var bundle = await _service.GetBundleForDepartmentAsync(Dept); + + bundle.ServerTimestampMs.Should().BeGreaterThan(0); + bundle.Boards.Should().ContainSingle(); + bundle.Boards[0].Command.CallId.Should().Be(CallId); + } + + [Test] + public async Task GetBundleForDepartmentAsync_ReturnsEmptyBoards_WhenNoActiveCommands() + { + _commandRepo.Setup(x => x.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List + { + new IncidentCommand { IncidentCommandId = "ic9", DepartmentId = Dept, CallId = 9, Status = (int)IncidentCommandStatus.Closed } + }); + + var bundle = await _service.GetBundleForDepartmentAsync(Dept); + + bundle.Boards.Should().BeEmpty(); + bundle.ServerTimestampMs.Should().BeGreaterThan(0); + } + + [Test] + public async Task GetBundleForDepartmentAsync_AssemblesBoardContent_AndAppliesTombstoneFilters() + { + ArrangeCall(); + ArrangeActiveCommand(); // one active command on CallId=1 + + _nodeRepo.Setup(x => x.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List + { + new CommandStructureNode { CommandStructureNodeId = "n1", DepartmentId = Dept, CallId = CallId, Name = "Division A", SortOrder = 0 }, + new CommandStructureNode { CommandStructureNodeId = "n2", DepartmentId = Dept, CallId = CallId, Name = "Removed lane", DeletedOn = DateTime.UtcNow }, + new CommandStructureNode { CommandStructureNodeId = "n3", DepartmentId = Dept, CallId = 999, Name = "Other call lane" } + }); + _assignmentRepo.Setup(x => x.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List + { + new ResourceAssignment { ResourceAssignmentId = "a1", DepartmentId = Dept, CallId = CallId }, + new ResourceAssignment { ResourceAssignmentId = "a2", DepartmentId = Dept, CallId = CallId, ReleasedOn = DateTime.UtcNow } + }); + + var bundle = await _service.GetBundleForDepartmentAsync(Dept); + + bundle.Boards.Should().ContainSingle(); + var board = bundle.Boards[0]; + board.Nodes.Should().ContainSingle().Which.Name.Should().Be("Division A"); // deleted + other-call excluded + board.Assignments.Should().ContainSingle().Which.ResourceAssignmentId.Should().Be("a1"); // released excluded + } + + [Test] + public async Task GetBundleForDepartmentAsync_IsReadOnly_AndHonorsIncludeAccountabilityFlag() + { + ArrangeCall(); + ArrangeActiveCommand(); + ArrangeStatuses(Critical("user1")); // would be flagged PAR-critical if the write-side sweep ran + + var without = await _service.GetBundleForDepartmentAsync(Dept, includeAccountability: false); + without.Boards.Should().ContainSingle(); + without.Boards[0].Accountability.Should().BeEmpty(); + + var with = await _service.GetBundleForDepartmentAsync(Dept, includeAccountability: true); + with.Boards[0].Accountability.Should().ContainSingle().Which.UserId.Should().Be("user1"); + + // The bundle must NOT run the write-side PAR sweep (no ParCritical marker writes / SignalR storm). + _logRepo.Verify(x => x.InsertAsync( + It.Is(e => e.EntryType == (int)CommandLogEntryType.ParCritical), + It.IsAny(), It.IsAny()), Times.Never); + _eventAggregator.Verify(x => x.SendMessage(It.IsAny()), Times.Never); + } + [Test] public async Task EvaluateCriticalParAsync_RaisesEventAndWritesMarker_OnFirstTransitionToCritical() { diff --git a/Tests/Resgrid.Tests/Services/IncidentResourcesServiceTests.cs b/Tests/Resgrid.Tests/Services/IncidentResourcesServiceTests.cs index 374d1768..3adf308a 100644 --- a/Tests/Resgrid.Tests/Services/IncidentResourcesServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/IncidentResourcesServiceTests.cs @@ -49,6 +49,36 @@ private void ArrangeActiveCommand() }); } + [Test] + public async Task GetActiveAdHocResourcesForDepartmentAsync_ReturnsActiveScopedToActiveIncidents_InOneBatch() + { + _commandService.Setup(x => x.GetActiveCommandsForDepartmentAsync(Dept)).ReturnsAsync(new List + { + new IncidentCommand { IncidentCommandId = "ic1", DepartmentId = Dept, CallId = CallId, Status = (int)IncidentCommandStatus.Active } + }); + + _unitRepo.Setup(x => x.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List + { + new IncidentAdHocUnit { IncidentAdHocUnitId = "u1", DepartmentId = Dept, CallId = CallId }, // active incident + active row + new IncidentAdHocUnit { IncidentAdHocUnitId = "u2", DepartmentId = Dept, CallId = CallId, ReleasedOn = DateTime.UtcNow }, // released → excluded + new IncidentAdHocUnit { IncidentAdHocUnitId = "u3", DepartmentId = Dept, CallId = 99 } // not an active incident → excluded + }); + _personnelRepo.Setup(x => x.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List + { + new IncidentAdHocPersonnel { IncidentAdHocPersonnelId = "p1", DepartmentId = Dept, CallId = CallId }, + new IncidentAdHocPersonnel { IncidentAdHocPersonnelId = "p2", DepartmentId = Dept, CallId = 99 } // not active → excluded + }); + + var (units, personnel) = await _service.GetActiveAdHocResourcesForDepartmentAsync(Dept); + + units.Should().ContainSingle().Which.IncidentAdHocUnitId.Should().Be("u1"); + personnel.Should().ContainSingle().Which.IncidentAdHocPersonnelId.Should().Be("p1"); + + // Batched: ONE scan per ad-hoc table regardless of incident count (no N+1). + _unitRepo.Verify(x => x.GetAllByDepartmentIdAsync(Dept), Times.Once); + _personnelRepo.Verify(x => x.GetAllByDepartmentIdAsync(Dept), Times.Once); + } + [Test] public async Task CreateAdHocUnitAsync_ReturnsNull_WhenNoActiveCommandForCall() { diff --git a/Tests/Resgrid.Tests/Services/SyncServiceTests.cs b/Tests/Resgrid.Tests/Services/SyncServiceTests.cs new file mode 100644 index 00000000..849657ea --- /dev/null +++ b/Tests/Resgrid.Tests/Services/SyncServiceTests.cs @@ -0,0 +1,169 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + /// + /// Covers : the shift-start reference aggregate. Focuses on the + /// SAFE personnel projection (group + current-state join, and that the DTO structurally cannot carry the + /// UserProfile secrets) plus passthrough of the raw config lists. + /// + [TestFixture] + public class SyncServiceTests + { + private const int Dept = 10; + + private Mock _calls; + private Mock _commands; + private Mock _units; + private Mock _groups; + private Mock _mapping; + private Mock _protocols; + private Mock _checkInTimers; + private Mock _customStates; + private Mock _profiles; + private Mock _userStates; + private Mock _features; + private Mock _cacheProvider; + private SyncService _service; + + [SetUp] + public void SetUp() + { + SystemBehaviorConfig.CacheEnabled = false; // exercise the direct build() path unless a test opts into caching + _calls = new Mock(); + _commands = new Mock(); + _units = new Mock(); + _groups = new Mock(); + _mapping = new Mock(); + _protocols = new Mock(); + _checkInTimers = new Mock(); + _customStates = new Mock(); + _profiles = new Mock(); + _userStates = new Mock(); + _features = new Mock(); + _cacheProvider = new Mock(); + + // Unset methods return null under Loose mocks; SyncService coalesces those to empty lists. + _service = new SyncService(_calls.Object, _commands.Object, _units.Object, _groups.Object, + _mapping.Object, _protocols.Object, _checkInTimers.Object, _customStates.Object, + _profiles.Object, _userStates.Object, _features.Object, _cacheProvider.Object); + } + + [Test] + public async Task GetReferenceDataAsync_ProjectsSafePersonnel_WithGroupAndState() + { + _calls.Setup(x => x.GetCallTypesForDepartmentAsync(Dept)) + .ReturnsAsync(new List { new CallType { CallTypeId = 1, DepartmentId = Dept } }); + + _profiles.Setup(x => x.GetAllProfilesForDepartmentAsync(Dept, It.IsAny())) + .ReturnsAsync(new Dictionary + { + ["u1"] = new UserProfile + { + UserId = "u1", FirstName = "John", LastName = "Doe", MobileNumber = "5551234", + // Secrets that must NEVER reach the client — ReferencePersonnel structurally cannot carry them. + EmailVerificationCode = "SECRET", CalendarSyncToken = "SECRET-TOKEN" + } + }); + + _groups.Setup(x => x.GetAllGroupsForDepartmentAsync(Dept)) + .ReturnsAsync(new List + { + new DepartmentGroup + { + DepartmentGroupId = 5, DepartmentId = Dept, Name = "Station 1", + Members = new List { new DepartmentGroupMember { UserId = "u1", DepartmentGroupId = 5 } } + } + }); + + _userStates.Setup(x => x.GetStatesForDepartmentAsync(Dept)) + .ReturnsAsync(new List { new UserState { UserId = "u1", State = 2, Timestamp = DateTime.UtcNow } }); + + var data = await _service.GetReferenceDataAsync(Dept); + + data.ServerTimestampMs.Should().BeGreaterThan(0); + data.CallTypes.Should().ContainSingle(); // raw config list passthrough + data.Groups.Should().ContainSingle().Which.GroupId.Should().Be(5); // projected to the safe ReferenceGroup + + var person = data.Personnel.Should().ContainSingle().Subject; + person.UserId.Should().Be("u1"); + person.FirstName.Should().Be("John"); + person.LastName.Should().Be("Doe"); + person.MobilePhone.Should().Be("5551234"); + person.GroupId.Should().Be(5); + person.GroupName.Should().Be("Station 1"); + person.StateId.Should().Be(2); + person.StateTimestamp.Should().NotBeNull(); + } + + [Test] + public async Task GetReferenceDataAsync_ReturnsEmptyPersonnel_WhenNoProfiles() + { + _profiles.Setup(x => x.GetAllProfilesForDepartmentAsync(Dept, It.IsAny())) + .ReturnsAsync(new Dictionary()); + + var data = await _service.GetReferenceDataAsync(Dept); + + data.Personnel.Should().BeEmpty(); + data.ServerTimestampMs.Should().BeGreaterThan(0); + } + + [Test] + public async Task GetReferenceDataAsync_UsesDepartmentScopedCacheAside_WhenCacheEnabled() + { + SystemBehaviorConfig.CacheEnabled = true; + try + { + _profiles.Setup(x => x.GetAllProfilesForDepartmentAsync(Dept, It.IsAny())) + .ReturnsAsync(new Dictionary()); + + // Simulate a cache miss: invoke the fallback and return its result (round-trips through the envelope). + _cacheProvider + .Setup(c => c.RetrieveAsync(It.IsAny(), It.IsAny>>(), It.IsAny())) + .Returns>, TimeSpan>((key, fallback, ttl) => fallback()); + + var data = await _service.GetReferenceDataAsync(Dept); + + data.Should().NotBeNull(); + _cacheProvider.Verify(c => c.RetrieveAsync( + "SyncReferenceData_" + Dept, It.IsAny>>(), It.IsAny()), Times.Once); + } + finally + { + SystemBehaviorConfig.CacheEnabled = false; + } + } + + [Test] + public async Task GetReferenceDataAsync_BypassesCache_WhenBypassCacheTrue() + { + SystemBehaviorConfig.CacheEnabled = true; + try + { + _profiles.Setup(x => x.GetAllProfilesForDepartmentAsync(Dept, It.IsAny())) + .ReturnsAsync(new Dictionary()); + + var data = await _service.GetReferenceDataAsync(Dept, bypassCache: true); + + data.Should().NotBeNull(); + _cacheProvider.Verify(c => c.RetrieveAsync( + It.IsAny(), It.IsAny>>(), It.IsAny()), Times.Never); + } + finally + { + SystemBehaviorConfig.CacheEnabled = false; + } + } + } +} diff --git a/Tests/Resgrid.Tests/Web/Services/RequiresIncidentCapabilityFilterTests.cs b/Tests/Resgrid.Tests/Web/Services/RequiresIncidentCapabilityFilterTests.cs new file mode 100644 index 00000000..af8cdadf --- /dev/null +++ b/Tests/Resgrid.Tests/Web/Services/RequiresIncidentCapabilityFilterTests.cs @@ -0,0 +1,255 @@ +using System; +using System.Collections.Generic; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Authorization; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Web.Services.Controllers.v4; +using Resgrid.Web.Services.Filters; + +namespace Resgrid.Tests.Web.Services +{ + /// + /// Unit tests for — the per-endpoint incident-capability gate + /// layered on top of the broad Command_* claims. Verifies the three Call-resolution strategies, the + /// allow/deny decision, the ownership check, and the fail-open behaviour when the target Call can't be classified. + /// + [TestFixture] + public class RequiresIncidentCapabilityFilterTests + { + private const int DepartmentId = 10; + private const string UserId = "user-1"; + + private Mock _service; + + [SetUp] + public void SetUp() + { + _service = new Mock(MockBehavior.Loose); + } + + [Test] + public async Task AllowsRequest_WhenUserHasRequiredCapability_ViaIncidentCommandId() + { + // Arrange — SaveNode-style: body carries IncidentCommandId, which resolves to its owned Call. + _service.Setup(s => s.GetCommandByIdAsync("cmd-1")) + .ReturnsAsync(new IncidentCommand { CallId = 5, DepartmentId = DepartmentId }); + _service.Setup(s => s.GetCapabilitiesForUserAsync(DepartmentId, 5, UserId)) + .ReturnsAsync(IncidentCapabilities.ViewBoard | IncidentCapabilities.ManageStructure); + + var filter = new RequiresIncidentCapabilityAttribute(IncidentCapabilities.ManageStructure); + var context = BuildContext( + args: new Dictionary { ["node"] = new CommandStructureNode { IncidentCommandId = "cmd-1" } }); + + // Act + var nextCalled = await Invoke(filter, context); + + // Assert + nextCalled.Should().BeTrue(); + context.Result.Should().BeNull(); + } + + [Test] + public async Task Returns403_WhenUserLacksRequiredCapability_ViaIncidentCommandId() + { + // Arrange — same path, but the user's role only grants ViewBoard. + _service.Setup(s => s.GetCommandByIdAsync("cmd-1")) + .ReturnsAsync(new IncidentCommand { CallId = 5, DepartmentId = DepartmentId }); + _service.Setup(s => s.GetCapabilitiesForUserAsync(DepartmentId, 5, UserId)) + .ReturnsAsync(IncidentCapabilities.ViewBoard); + + var filter = new RequiresIncidentCapabilityAttribute(IncidentCapabilities.ManageStructure); + var context = BuildContext( + args: new Dictionary { ["node"] = new CommandStructureNode { IncidentCommandId = "cmd-1" } }); + + // Act + var nextCalled = await Invoke(filter, context); + + // Assert + nextCalled.Should().BeFalse(); + context.Result.Should().BeOfType() + .Which.StatusCode.Should().Be(StatusCodes.Status403Forbidden); + } + + [Test] + public async Task AllowsRequest_WhenCapabilityPresent_ViaExplicitRouteCallId() + { + // Arrange — EvaluateAccountability/{callId}: callId comes from the route, no body. + _service.Setup(s => s.GetCapabilitiesForUserAsync(DepartmentId, 7, UserId)) + .ReturnsAsync(IncidentCapabilities.All); + + var filter = new RequiresIncidentCapabilityAttribute(IncidentCapabilities.ManageAccountability); + var context = BuildContext( + args: new Dictionary { ["callId"] = 7 }, + routeValues: new Dictionary { ["callId"] = 7 }); + + // Act + var nextCalled = await Invoke(filter, context); + + // Assert + nextCalled.Should().BeTrue(); + context.Result.Should().BeNull(); + _service.Verify(s => s.GetCommandByIdAsync(It.IsAny()), Times.Never); + } + + [Test] + public async Task AllowsRequest_WhenCapabilityPresent_ViaBodyCallId() + { + // Arrange — CreateAdHocUnit-style: the body object exposes CallId (no IncidentCommandId). + _service.Setup(s => s.GetCapabilitiesForUserAsync(DepartmentId, 9, UserId)) + .ReturnsAsync(IncidentCapabilities.ViewBoard | IncidentCapabilities.ManageResources); + + var filter = new RequiresIncidentCapabilityAttribute(IncidentCapabilities.ManageResources); + var context = BuildContext( + args: new Dictionary { ["unit"] = new IncidentAdHocUnit { CallId = 9 } }); + + // Act + var nextCalled = await Invoke(filter, context); + + // Assert + nextCalled.Should().BeTrue(); + context.Result.Should().BeNull(); + } + + [Test] + public async Task FailsOpen_WhenCallCannotBeResolved() + { + // Arrange — a release-style entity-id route (no callId / IncidentCommandId / CallId on the request). + // The filter must NOT block; the broad Command_* claim still governs. Capabilities are never evaluated. + var filter = new RequiresIncidentCapabilityAttribute(IncidentCapabilities.AssignResources); + var context = BuildContext( + args: new Dictionary { ["resourceAssignmentId"] = "ra-1" }); + + // Act + var nextCalled = await Invoke(filter, context); + + // Assert + nextCalled.Should().BeTrue(); + context.Result.Should().BeNull(); + _service.Verify(s => s.GetCapabilitiesForUserAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task FailsOpen_WhenIncidentCommandBelongsToAnotherDepartment() + { + // Arrange — a forged IncidentCommandId pointing at another department must not classify the request, + // so the filter falls through to fail-open (the service-layer ownership guard then rejects it). + _service.Setup(s => s.GetCommandByIdAsync("cmd-foreign")) + .ReturnsAsync(new IncidentCommand { CallId = 5, DepartmentId = 999 }); + + var filter = new RequiresIncidentCapabilityAttribute(IncidentCapabilities.ManageStructure); + var context = BuildContext( + args: new Dictionary { ["node"] = new CommandStructureNode { IncidentCommandId = "cmd-foreign" } }); + + // Act + var nextCalled = await Invoke(filter, context); + + // Assert + nextCalled.Should().BeTrue(); + context.Result.Should().BeNull(); + _service.Verify(s => s.GetCapabilitiesForUserAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task FailsOpen_WhenCallerHasNoIdentity() + { + // Arrange — no claims principal; identity can't be established, so defer to the [Authorize] gate. + var filter = new RequiresIncidentCapabilityAttribute(IncidentCapabilities.ManageCommand); + var context = BuildContext( + args: new Dictionary { ["callId"] = 7 }, + routeValues: new Dictionary { ["callId"] = 7 }, + authenticated: false); + + // Act + var nextCalled = await Invoke(filter, context); + + // Assert + nextCalled.Should().BeTrue(); + context.Result.Should().BeNull(); + _service.Verify(s => s.GetCapabilitiesForUserAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public void EstablishCommand_IsNotCapabilityGated_SoBootstrapCanCreateTheCommand() + { + // EstablishCommand bootstraps the command — before it runs there is no command/commander/role and thus no + // IncidentCapabilities, so it must NOT carry [RequiresIncidentCapability]; it stays on the Command_Create claim. + var method = typeof(IncidentCommandController).GetMethod(nameof(IncidentCommandController.EstablishCommand)); + method.Should().NotBeNull(); + method.GetCustomAttributes(typeof(RequiresIncidentCapabilityAttribute), true).Should().BeEmpty(); + method.GetCustomAttributes(typeof(AuthorizeAttribute), true).Should().NotBeEmpty(); + } + + #region Helpers + + private ActionExecutingContext BuildContext( + IDictionary args, + IDictionary routeValues = null, + bool authenticated = true) + { + var httpContext = new DefaultHttpContext + { + RequestServices = new StubServiceProvider(_service.Object) + }; + + if (authenticated) + { + var identity = new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.PrimarySid, UserId), + new Claim(ClaimTypes.PrimaryGroupSid, DepartmentId.ToString()) + }, "TestAuth"); + httpContext.User = new ClaimsPrincipal(identity); + } + + var routeData = new RouteData(); + if (routeValues != null) + { + foreach (var kvp in routeValues) + routeData.Values[kvp.Key] = kvp.Value; + } + + var actionContext = new ActionContext(httpContext, routeData, new ActionDescriptor()); + return new ActionExecutingContext(actionContext, new List(), args, controller: new object()); + } + + private static async Task Invoke(RequiresIncidentCapabilityAttribute filter, ActionExecutingContext context) + { + var nextCalled = false; + + Task Next() + { + nextCalled = true; + return Task.FromResult(new ActionExecutedContext(context, new List(), context.Controller)); + } + + await filter.OnActionExecutionAsync(context, Next); + return nextCalled; + } + + private sealed class StubServiceProvider : IServiceProvider + { + private readonly IIncidentCommandService _service; + + public StubServiceProvider(IIncidentCommandService service) + { + _service = service; + } + + public object GetService(Type serviceType) + => serviceType == typeof(IIncidentCommandService) ? _service : null; + } + + #endregion Helpers + } +} diff --git a/Web/Resgrid.Web.Eventing/Worker.cs b/Web/Resgrid.Web.Eventing/Worker.cs index fdd0d68b..d0728725 100644 --- a/Web/Resgrid.Web.Eventing/Worker.cs +++ b/Web/Resgrid.Web.Eventing/Worker.cs @@ -45,7 +45,8 @@ protected override Task ExecuteAsync(CancellationToken stoppingToken = default) CallAdded, CallClosed, PersonnelLocationUpdated, - UnitLocationUpdated); + UnitLocationUpdated, + IncidentCommandUpdated); _rabbitInboundEventProvider.Start("Eventing-Web", "EventingWeb").ConfigureAwait(false); @@ -110,6 +111,16 @@ public async Task CallsUpdated(int departmentId, string id) await group.SendAsync("callsUpdated", id); } + public async Task IncidentCommandUpdated(int departmentId, string id) + { + Console.WriteLine($"Processing RabbitMQ IncidentCommandUpdated Event For {departmentId}"); + + var group = _eventingHub.Clients.Group(departmentId.ToString()); + + if (group != null) + await group.SendAsync("incidentCommandUpdated", id); + } + public async Task DepartmentUpdated(int departmentId) { Console.WriteLine($"Processing RabbitMQ DepartmentUpdated Event For {departmentId}"); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/IncidentCommandController.cs b/Web/Resgrid.Web.Services/Controllers/v4/IncidentCommandController.cs index 20e2a871..f80300cd 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/IncidentCommandController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/IncidentCommandController.cs @@ -4,6 +4,7 @@ using Resgrid.Model; using Resgrid.Model.Services; using Resgrid.Providers.Claims; +using Resgrid.Web.Services.Filters; using Resgrid.Web.Services.Helpers; using System.Threading; using System.Threading.Tasks; @@ -32,6 +33,10 @@ public IncidentCommandController(IIncidentCommandService incidentCommandService) #region Command lifecycle /// Establishes command on a call, optionally seeding lanes from a command definition. + // Bootstrap: intentionally NOT [RequiresIncidentCapability]. EstablishCommand CREATES the command, so at call + // time no command/commander/role (hence no IncidentCapabilities) exists yet — GetCapabilitiesForUserAsync would + // return None and 403 every establish. Department-level [Authorize(Command_Create)] is the correct gate here; + // the incident-scoped capability checks apply to the lifecycle endpoints that operate on an existing command. [HttpPost("EstablishCommand")] [ProducesResponseType(StatusCodes.Status200OK)] [Authorize(Policy = ResgridResources.Command_Create)] @@ -85,6 +90,7 @@ public IncidentCommandController(IIncidentCommandService incidentCommandService) [HttpPost("TransferCommand")] [ProducesResponseType(StatusCodes.Status200OK)] [Authorize(Policy = ResgridResources.Command_Update)] + [RequiresIncidentCapability(IncidentCapabilities.ManageCommand)] public async Task> TransferCommand([FromBody] ICModels.TransferCommandInput input) { if (input == null || string.IsNullOrWhiteSpace(input.IncidentCommandId) || string.IsNullOrWhiteSpace(input.ToUserId)) @@ -111,6 +117,7 @@ public IncidentCommandController(IIncidentCommandService incidentCommandService) [HttpPut("CloseCommand/{incidentCommandId}")] [ProducesResponseType(StatusCodes.Status200OK)] [Authorize(Policy = ResgridResources.Command_Update)] + [RequiresIncidentCapability(IncidentCapabilities.ManageCommand)] public async Task> CloseCommand(string incidentCommandId) { var result = new ICModels.IncidentCommandResult(); @@ -134,6 +141,7 @@ public IncidentCommandController(IIncidentCommandService incidentCommandService) [HttpPut("UpdateActionPlan")] [ProducesResponseType(StatusCodes.Status200OK)] [Authorize(Policy = ResgridResources.Command_Update)] + [RequiresIncidentCapability(IncidentCapabilities.ManageCommand)] public async Task> UpdateActionPlan([FromBody] ICModels.UpdateActionPlanInput input) { if (input == null || string.IsNullOrWhiteSpace(input.IncidentCommandId)) @@ -178,6 +186,7 @@ public IncidentCommandController(IIncidentCommandService incidentCommandService) [HttpPost("EvaluateAccountability/{callId}")] [ProducesResponseType(StatusCodes.Status200OK)] [Authorize(Policy = ResgridResources.Command_Update)] + [RequiresIncidentCapability(IncidentCapabilities.ManageAccountability)] public async Task> EvaluateAccountability(int callId) { var result = new ICModels.EvaluateAccountabilityResult(); @@ -196,6 +205,7 @@ public IncidentCommandController(IIncidentCommandService incidentCommandService) [HttpPost("SaveNode")] [ProducesResponseType(StatusCodes.Status200OK)] [Authorize(Policy = ResgridResources.Command_Update)] + [RequiresIncidentCapability(IncidentCapabilities.ManageStructure)] public async Task> SaveNode([FromBody] CommandStructureNode node) { if (node == null || string.IsNullOrWhiteSpace(node.IncidentCommandId)) @@ -242,6 +252,7 @@ public IncidentCommandController(IIncidentCommandService incidentCommandService) [HttpPost("AssignResource")] [ProducesResponseType(StatusCodes.Status200OK)] [Authorize(Policy = ResgridResources.Command_Update)] + [RequiresIncidentCapability(IncidentCapabilities.AssignResources)] public async Task> AssignResource([FromBody] ResourceAssignment assignment) { if (assignment == null || string.IsNullOrWhiteSpace(assignment.IncidentCommandId)) @@ -313,6 +324,7 @@ public IncidentCommandController(IIncidentCommandService incidentCommandService) [HttpPost("SaveObjective")] [ProducesResponseType(StatusCodes.Status200OK)] [Authorize(Policy = ResgridResources.Command_Update)] + [RequiresIncidentCapability(IncidentCapabilities.ManageObjectives)] public async Task> SaveObjective([FromBody] TacticalObjective objective) { if (objective == null || string.IsNullOrWhiteSpace(objective.IncidentCommandId)) @@ -368,6 +380,7 @@ public IncidentCommandController(IIncidentCommandService incidentCommandService) [HttpPost("StartTimer")] [ProducesResponseType(StatusCodes.Status200OK)] [Authorize(Policy = ResgridResources.Command_Update)] + [RequiresIncidentCapability(IncidentCapabilities.ManageTimers)] public async Task> StartTimer([FromBody] IncidentTimer timer) { if (timer == null || string.IsNullOrWhiteSpace(timer.IncidentCommandId)) @@ -423,6 +436,7 @@ public IncidentCommandController(IIncidentCommandService incidentCommandService) [HttpPost("SaveAnnotation")] [ProducesResponseType(StatusCodes.Status200OK)] [Authorize(Policy = ResgridResources.Command_Update)] + [RequiresIncidentCapability(IncidentCapabilities.ManageAnnotations)] public async Task> SaveAnnotation([FromBody] IncidentMapAnnotation annotation) { if (annotation == null || string.IsNullOrWhiteSpace(annotation.IncidentCommandId)) diff --git a/Web/Resgrid.Web.Services/Controllers/v4/IncidentResourcesController.cs b/Web/Resgrid.Web.Services/Controllers/v4/IncidentResourcesController.cs index 4227cb44..d6b94200 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/IncidentResourcesController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/IncidentResourcesController.cs @@ -4,6 +4,7 @@ using Resgrid.Model; using Resgrid.Model.Services; using Resgrid.Providers.Claims; +using Resgrid.Web.Services.Filters; using Resgrid.Web.Services.Helpers; using System.Threading; using System.Threading.Tasks; @@ -35,6 +36,7 @@ public IncidentResourcesController(IIncidentResourcesService incidentResourcesSe [HttpPost("CreateAdHocUnit")] [ProducesResponseType(StatusCodes.Status200OK)] [Authorize(Policy = ResgridResources.Command_Update)] + [RequiresIncidentCapability(IncidentCapabilities.ManageResources)] public async Task> CreateAdHocUnit([FromBody] IncidentAdHocUnit unit) { if (unit == null || unit.CallId <= 0) @@ -95,6 +97,7 @@ public IncidentResourcesController(IIncidentResourcesService incidentResourcesSe [HttpPost("CreateAdHocPersonnel")] [ProducesResponseType(StatusCodes.Status200OK)] [Authorize(Policy = ResgridResources.Command_Update)] + [RequiresIncidentCapability(IncidentCapabilities.ManageResources)] public async Task> CreateAdHocPersonnel([FromBody] IncidentAdHocPersonnel personnel) { if (personnel == null || personnel.CallId <= 0) @@ -180,6 +183,7 @@ public IncidentResourcesController(IIncidentResourcesService incidentResourcesSe [HttpPost("FormUnit")] [ProducesResponseType(StatusCodes.Status200OK)] [Authorize(Policy = ResgridResources.Command_Update)] + [RequiresIncidentCapability(IncidentCapabilities.ManageResources)] public async Task> FormUnit([FromBody] ICModels.FormUnitInput input) { if (input == null || input.CallId <= 0 || string.IsNullOrWhiteSpace(input.Name)) diff --git a/Web/Resgrid.Web.Services/Controllers/v4/IncidentRolesController.cs b/Web/Resgrid.Web.Services/Controllers/v4/IncidentRolesController.cs index b298849f..f4626bdd 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/IncidentRolesController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/IncidentRolesController.cs @@ -4,6 +4,7 @@ using Resgrid.Model; using Resgrid.Model.Services; using Resgrid.Providers.Claims; +using Resgrid.Web.Services.Filters; using Resgrid.Web.Services.Helpers; using System; using System.Threading; @@ -34,6 +35,7 @@ public IncidentRolesController(IIncidentCommandService incidentCommandService) [HttpPost("AssignRole")] [ProducesResponseType(StatusCodes.Status200OK)] [Authorize(Policy = ResgridResources.Command_Update)] + [RequiresIncidentCapability(IncidentCapabilities.ManageCommand)] public async Task> AssignRole([FromBody] IncidentRoleAssignment assignment) { if (assignment == null || string.IsNullOrWhiteSpace(assignment.IncidentCommandId) || string.IsNullOrWhiteSpace(assignment.UserId)) diff --git a/Web/Resgrid.Web.Services/Controllers/v4/IncidentVoiceController.cs b/Web/Resgrid.Web.Services/Controllers/v4/IncidentVoiceController.cs index ceaa8be5..9767ab93 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/IncidentVoiceController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/IncidentVoiceController.cs @@ -1,8 +1,10 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Resgrid.Model; using Resgrid.Model.Services; using Resgrid.Providers.Claims; +using Resgrid.Web.Services.Filters; using Resgrid.Web.Services.Helpers; using System.Threading; using System.Threading.Tasks; @@ -31,6 +33,7 @@ public IncidentVoiceController(IIncidentVoiceService incidentVoiceService) [HttpPost("CreateIncidentChannel")] [ProducesResponseType(StatusCodes.Status200OK)] [Authorize(Policy = ResgridResources.Command_Update)] + [RequiresIncidentCapability(IncidentCapabilities.ManageChannels)] public async Task> CreateIncidentChannel([FromBody] ICModels.CreateIncidentChannelInput input) { if (input == null || input.CallId <= 0) @@ -72,6 +75,7 @@ public IncidentVoiceController(IIncidentVoiceService incidentVoiceService) [HttpPost("CloseIncidentChannels/{callId}")] [ProducesResponseType(StatusCodes.Status200OK)] [Authorize(Policy = ResgridResources.Command_Update)] + [RequiresIncidentCapability(IncidentCapabilities.ManageChannels)] public async Task> CloseIncidentChannels(int callId) { var result = new ICModels.IncidentCommandActionResult(); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/SyncController.cs b/Web/Resgrid.Web.Services/Controllers/v4/SyncController.cs index 00121e58..8e104949 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/SyncController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/SyncController.cs @@ -24,11 +24,15 @@ public class SyncController : V4AuthenticatedApiControllerbase #region Members and Constructors private readonly IIncidentCommandService _incidentCommandService; private readonly IIncidentResourcesService _incidentResourcesService; + private readonly ISyncService _syncService; + private readonly Model.Services.IAuthorizationService _authorizationService; - public SyncController(IIncidentCommandService incidentCommandService, IIncidentResourcesService incidentResourcesService) + public SyncController(IIncidentCommandService incidentCommandService, IIncidentResourcesService incidentResourcesService, ISyncService syncService, Model.Services.IAuthorizationService authorizationService) { _incidentCommandService = incidentCommandService; _incidentResourcesService = incidentResourcesService; + _syncService = syncService; + _authorizationService = authorizationService; } #endregion Members and Constructors @@ -62,5 +66,64 @@ public async Task> Changes(long since = 0) ResponseHelper.PopulateV4ResponseData(result); return result; } + + /// + /// Shift-start aggregate pull: a render-ready board (incl. computed accountability / PAR) for every ACTIVE + /// incident in the caller's department, plus the active ad-hoc resources and a next-sync cursor — in a single + /// round-trip. Persist Data.ServerTimestampMs and pass it as the next `since`. + /// Unlike (a row-based delta), this returns the computed PAR/accountability state. + /// + [HttpGet("Bundle")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_View)] + public async Task> Bundle(bool includeAccountability = true) + { + var bundle = await _incidentCommandService.GetBundleForDepartmentAsync(DepartmentId, includeAccountability); + + // Ad-hoc resources live in IncidentResourcesService; pull them for ALL active incidents in one batched call + // (the previous per-board loop was an N+1 — each call scanned the department's ad-hoc tables). + var adHoc = await _incidentResourcesService.GetActiveAdHocResourcesForDepartmentAsync(DepartmentId); + bundle.AdHocUnits = adHoc.Units; + bundle.AdHocPersonnel = adHoc.Personnel; + + var result = new SyncBundleResult { Data = bundle }; + result.PageSize = bundle.Boards.Count; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// + /// Shift-start REFERENCE pull: the slowly-changing department configuration + a safe personnel roster needed to + /// start and run an incident offline (call types, command templates, units, personnel, groups, POIs, protocols, + /// accountability config, statuses, feature flags). Pull once per shift / on manual refresh; the live incident + /// state comes from and . + /// + [HttpGet("Reference")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_View)] + public async Task> Reference(bool bypassCache = false) + { + var data = await _syncService.GetReferenceDataAsync(DepartmentId, bypassCache); + + // PII gate: the reference snapshot is cached department-scoped and caller-agnostic, so mobile numbers are + // redacted here per-caller (NOT inside the cached build, which would let the first caller's permission + // decide what every later caller sees). Matches the CanUserViewPIIAsync check the Personnel/Dispatch + // endpoints apply to the same roster — Command_View alone must not expose personnel PII. + if (!await _authorizationService.CanUserViewPIIAsync(UserId, DepartmentId)) + { + foreach (var person in data.Personnel) + person.MobilePhone = null; + } + + var result = new SyncReferenceResult { Data = data }; + result.PageSize = data.CallTypes.Count + data.CallPriorities.Count + data.CommandTemplates.Count + + data.Units.Count + data.UnitTypes.Count + data.Groups.Count + data.Pois.Count + data.PoiTypes.Count + + data.Protocols.Count + data.CheckInTimerConfigs.Count + data.PersonnelStates.Count + data.UnitStates.Count + + data.Personnel.Count + data.Features.Count; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } } } diff --git a/Web/Resgrid.Web.Services/Filters/RequiresIncidentCapabilityAttribute.cs b/Web/Resgrid.Web.Services/Filters/RequiresIncidentCapabilityAttribute.cs new file mode 100644 index 00000000..a3b0a533 --- /dev/null +++ b/Web/Resgrid.Web.Services/Filters/RequiresIncidentCapabilityAttribute.cs @@ -0,0 +1,172 @@ +using System; +using System.Reflection; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Resgrid.Model; +using Resgrid.Model.Services; + +namespace Resgrid.Web.Services.Filters +{ + /// + /// Per-endpoint, incident-scoped capability gate (§3.11). Layered ON TOP of the broad + /// [Authorize(Policy = Command_*)] claims that already protect every IC endpoint, this filter + /// additionally enforces that the calling user's effective for the + /// target incident include . The Incident Commander / Deputy / Unified-Command roles + /// (and the user who established command) get , so they pass every + /// gate — see and + /// . + /// + /// It runs as an action filter (after model binding) so it can read the target Call from the bound request — + /// either an explicit callId route value, an IncidentCommandId on the body/route (resolved to + /// its Call, department-ownership checked), or a CallId on the request body. If the Call cannot be + /// determined the filter does NOT block: the broad Command_* claim and the service-layer department-ownership + /// guards still apply, so this filter only ever ADDS protection, never removes it. + /// + /// Note: the entity-id "second action" verbs (delete/release/complete/acknowledge an existing row) are left on + /// the broad Command_* claim because the target Call isn't on the request — adding capability gating there + /// needs an entity-id → Call lookup and is a deliberate follow-up. + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)] + public sealed class RequiresIncidentCapabilityAttribute : Attribute, IAsyncActionFilter + { + /// The capability the caller must hold for the target incident to proceed. + public IncidentCapabilities Required { get; } + + public RequiresIncidentCapabilityAttribute(IncidentCapabilities required) + { + Required = required; + } + + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + var service = context.HttpContext.RequestServices?.GetService(typeof(IIncidentCommandService)) as IIncidentCommandService; + + // Identity from the same claims the controllers use (PrimarySid = userId, PrimaryGroupSid = departmentId). + var user = context.HttpContext.User; + var userId = user?.FindFirst(ClaimTypes.PrimarySid)?.Value; + int.TryParse(user?.FindFirst(ClaimTypes.PrimaryGroupSid)?.Value, out var departmentId); + + // Without identity or the service we can't evaluate — defer to the broad [Authorize] gate. + if (service == null || departmentId <= 0 || string.IsNullOrWhiteSpace(userId)) + { + await next(); + return; + } + + var callId = await ResolveCallIdAsync(context, service, departmentId); + if (callId == null || callId.Value <= 0) + { + // Couldn't classify the target incident — don't block; the broad Command_* claim still governs. + await next(); + return; + } + + var capabilities = await service.GetCapabilitiesForUserAsync(departmentId, callId.Value, userId); + if ((capabilities & Required) != Required) + { + context.Result = new ObjectResult("Insufficient incident-command capability for this action.") + { + StatusCode = StatusCodes.Status403Forbidden + }; + return; + } + + await next(); + } + + /// + /// Determines the target Call for the request, preferring the most authoritative source: + /// (1) an explicit callId route/argument, (2) an IncidentCommandId (route/arg/body) resolved + /// to its Call and department-ownership checked, then (3) a CallId on a bound request body object. + /// + private static async Task ResolveCallIdAsync(ActionExecutingContext context, IIncidentCommandService service, int departmentId) + { + // (1) Explicit callId (e.g. EvaluateAccountability/{callId}, CloseIncidentChannels/{callId}). + var directCallId = GetInt(context, "callId"); + if (directCallId.HasValue && directCallId.Value > 0) + return directCallId; + + // (2) IncidentCommandId — authoritative; resolve to its Call and confirm department ownership so a + // forged id from another department can't be used to classify the request. + var incidentCommandId = GetString(context, "incidentCommandId") ?? GetStringProperty(context, "IncidentCommandId"); + if (!string.IsNullOrWhiteSpace(incidentCommandId)) + { + var command = await service.GetCommandByIdAsync(incidentCommandId); + if (command != null && command.DepartmentId == departmentId && command.CallId > 0) + return command.CallId; + } + + // (3) CallId on a bound body object (e.g. EstablishCommandInput, CreateIncidentChannelInput, ad-hoc creates, FormUnitInput). + var bodyCallId = GetIntProperty(context, "CallId"); + if (bodyCallId.HasValue && bodyCallId.Value > 0) + return bodyCallId; + + return null; + } + + private static int? GetInt(ActionExecutingContext context, string key) + { + if (context.ActionArguments.TryGetValue(key, out var arg) && arg is int i) + return i; + + if (context.RouteData.Values.TryGetValue(key, out var routeVal) && int.TryParse(routeVal?.ToString(), out var parsed)) + return parsed; + + return null; + } + + private static string GetString(ActionExecutingContext context, string key) + { + if (context.ActionArguments.TryGetValue(key, out var arg) && arg is string s && !string.IsNullOrWhiteSpace(s)) + return s; + + if (context.RouteData.Values.TryGetValue(key, out var routeVal)) + { + var rv = routeVal?.ToString(); + if (!string.IsNullOrWhiteSpace(rv)) + return rv; + } + + return null; + } + + private static string GetStringProperty(ActionExecutingContext context, string propertyName) + { + foreach (var arg in context.ActionArguments.Values) + { + if (arg is null or string) + continue; + + var prop = arg.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance); + if (prop != null && prop.PropertyType == typeof(string)) + { + if (prop.GetValue(arg) is string value && !string.IsNullOrWhiteSpace(value)) + return value; + } + } + + return null; + } + + private static int? GetIntProperty(ActionExecutingContext context, string propertyName) + { + foreach (var arg in context.ActionArguments.Values) + { + if (arg is null or string) + continue; + + var prop = arg.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance); + if (prop != null && prop.PropertyType == typeof(int)) + { + if (prop.GetValue(arg) is int value && value > 0) + return value; + } + } + + return null; + } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/Sync/SyncModels.cs b/Web/Resgrid.Web.Services/Models/v4/Sync/SyncModels.cs index 4d4b85a6..e6ed8317 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Sync/SyncModels.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Sync/SyncModels.cs @@ -10,4 +10,24 @@ public class SyncChangesResult : StandardApiResponseV4Base { public Resgrid.Model.IncidentCommandChanges Data { get; set; } } + + /// + /// Shift-start aggregate pull for offline clients: a render-ready board (incl. computed accountability / PAR) for + /// every active incident in the caller's department, plus active ad-hoc resources and the next-sync cursor, in a + /// single call. The client stores Data.ServerTimestampMs and passes it as the next Changes `since`. + /// + public class SyncBundleResult : StandardApiResponseV4Base + { + public Resgrid.Model.IncidentCommandBundle Data { get; set; } + } + + /// + /// Shift-start REFERENCE payload: the slowly-changing department configuration + a safe personnel roster an IC/Unit + /// app needs to start and run an incident offline. Pull once per shift / on manual refresh; the live incident state + /// comes from /Sync/Bundle (active boards) and /Sync/Changes (deltas). + /// + public class SyncReferenceResult : StandardApiResponseV4Base + { + public Resgrid.Model.SyncReferenceData Data { get; set; } + } } diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index 2695276a..c8252a52 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -1937,6 +1937,22 @@ . + + + Shift-start aggregate pull: a render-ready board (incl. computed accountability / PAR) for every ACTIVE + incident in the caller's department, plus the active ad-hoc resources and a next-sync cursor — in a single + round-trip. Persist Data.ServerTimestampMs and pass it as the next `since`. + Unlike (a row-based delta), this returns the computed PAR/accountability state. + + + + + Shift-start REFERENCE pull: the slowly-changing department configuration + a safe personnel roster needed to + start and run an incident offline (call types, command templates, units, personnel, groups, POIs, protocols, + accountability config, statuses, feature flags). Pull once per shift / on manual refresh; the live incident + state comes from and . + + Templates in the system. Templates can be call Templates, Autofills (i.e. Call Notes) @@ -4559,6 +4575,37 @@ The Timestamp of the status + + + Per-endpoint, incident-scoped capability gate (§3.11). Layered ON TOP of the broad + [Authorize(Policy = Command_*)] claims that already protect every IC endpoint, this filter + additionally enforces that the calling user's effective for the + target incident include . The Incident Commander / Deputy / Unified-Command roles + (and the user who established command) get , so they pass every + gate — see and + . + + It runs as an action filter (after model binding) so it can read the target Call from the bound request — + either an explicit callId route value, an IncidentCommandId on the body/route (resolved to + its Call, department-ownership checked), or a CallId on the request body. If the Call cannot be + determined the filter does NOT block: the broad Command_* claim and the service-layer department-ownership + guards still apply, so this filter only ever ADDS protection, never removes it. + + Note: the entity-id "second action" verbs (delete/release/complete/acknowledge an existing row) are left on + the broad Command_* claim because the target Call isn't on the request — adding capability gating there + needs an entity-id → Call lookup and is a deliberate follow-up. + + + + The capability the caller must hold for the target incident to proceed. + + + + Determines the target Call for the request, preferring the most authoritative source: + (1) an explicit callId route/argument, (2) an IncidentCommandId (route/arg/body) resolved + to its Call and department-ownership checked, then (3) a CallId on a bound request body object. + + Gets or sets the on authentication failed. @@ -9638,6 +9685,20 @@ stores Data.ServerTimestampMs and passes it back as the next `since`. + + + Shift-start aggregate pull for offline clients: a render-ready board (incl. computed accountability / PAR) for + every active incident in the caller's department, plus active ad-hoc resources and the next-sync cursor, in a + single call. The client stores Data.ServerTimestampMs and passes it as the next Changes `since`. + + + + + Shift-start REFERENCE payload: the slowly-changing department configuration + a safe personnel roster an IC/Unit + app needs to start and run an incident offline. Pull once per shift / on manual refresh; the live incident state + comes from /Sync/Bundle (active boards) and /Sync/Changes (deltas). + + Multiple call note template result diff --git a/docs/architecture/offline-first-architecture.md b/docs/architecture/offline-first-architecture.md index cc30385b..fa0672c6 100644 --- a/docs/architecture/offline-first-architecture.md +++ b/docs/architecture/offline-first-architecture.md @@ -2,7 +2,7 @@ **Status:** Design / proposal **Author:** Resgrid IC backend work (RIC-T39 follow-on) -**Last updated:** 2026-06-24 +**Last updated:** 2026-06-27 **Applies to:** Resgrid **IC** app (new, `../IC`), Resgrid **Unit** app (existing, `../Unit`), Resgrid **Core** backend (this repo) > This document is the single source of truth for how the Resgrid mobile apps work @@ -216,8 +216,39 @@ mark the local record `_syncError` and surface it (do **not** silently roll back 3. Record `lastSyncAt` / per-store high-water timestamps; show progress + a "Synced ✓ (time)" state. 4. Pre-warm SignalR so live updates keep the cache hot while still online. -Optional backend optimization: a single `/Sync/Bundle` aggregate endpoint (§9) to cut round-trips. -v1 can fan out existing `GetAll*` calls. +### 6.1 Authoritative shift-start manifest (what the app MUST pull to be field-ready) + +The app is "field-ready" — able to **start and run** an incident fully offline — once it has pulled ALL of +the following. Each row names its delivery endpoint. Three Sync endpoints now exist +(`Web/Resgrid.Web.Services/Controllers/v4/SyncController.cs`); everything else rides existing v4 `GetAll*` +endpoints. This list is the single source of truth — if a dataset isn't here with a delivery mechanism, the +app is not field-complete. + +**A. Reference data (slowly-changing) — `GET /api/v4/Sync/Reference`** (one call → `SyncReferenceData`): +call types, call priorities, command-definition **templates** (needed to establish/seed command), units + +unit types, **personnel roster** (SAFE projection — name / mobile / primary group / current state, NO +credentials), groups, POIs + POI types, dispatch protocols, check-in timer configs, personnel + unit custom +statuses, resolved feature flags. Pull once per shift / on manual refresh; cache aggressively. + +**B. Live incident state — `GET /api/v4/Sync/Bundle`** (one call → `IncidentCommandBundle`): a render-ready +board per ACTIVE incident (lanes, resources, objectives, timers, roles, annotations) **including computed +accountability / PAR**, plus active ad-hoc resources, plus a `ServerTimestampMs` cursor. +`?includeAccountability=false` skips the per-incident PAR computation for departments with very many open +incidents. + +**C. Incremental catch-up — `GET /api/v4/Sync/Changes?since={cursor}`** (→ `IncidentCommandChanges`): the +row-based delta of incident-command rows (incl. tombstones / closed / released) changed since the cursor, for +reconnect reconciliation. `since=0` is a full row pull. + +**D. Delivered by other means (existing v4 endpoints — documented, intentionally not re-bundled):** the Call +list + metadata (Calls API — incidents are CAD-dispatched), mutual-aid assignable resources +(`MutualAidController`, per-call), indoor maps / custom GIS layers (Mapping API), detailed per-user +roles/certs (Personnel API), and Mapbox offline tile packs (§10, client-side download). The app pulls these +alongside A–C at shift start. + +> Delivery split rationale: **A** is static/cacheable, **B** is live and bounded by active-incident count, +> **C** is the reconnect delta. Keeping them separate keeps each call cacheable/paginatable and avoids one +> mega-endpoint that couples every subsystem and is impossible to keep performant. --- @@ -277,11 +308,20 @@ see the IC backend state doc). All are additive and apply to **both** SQL Server 3. **Action idempotency keys.** Check-in / status / location endpoints accept an `IdempotencyKey` (the outbox event id) and dedup on `(DepartmentId, UserId, IdempotencyKey)` within a window — or rely on LWW-by-timestamp where natural. -4. **Delta endpoint(s).** `GET /api/v4/Sync/Changes?since={utcIso}&types=Calls,IncidentCommand,…` - returning, per type, `created/updated` rows and `deleted` ids (from tombstones) with a new - server high-water `syncToken`. v1 may implement per-domain `GetChangedSince` and full-refetch the - small reference sets; build true deltas first for the large sets (messages, call/audit history). -5. **(Optional) `GET /api/v4/Sync/Bundle`** — one aggregate shift-start pull to reduce round-trips. +4. **Delta endpoint — DONE.** `GET /api/v4/Sync/Changes?since={epochMs}` (`SyncController.Changes`) returns + every change-tracked incident-command row (incl. tombstones / closed / released) changed since the cursor, + scoped to the caller's department, with a `ServerTimestampMs` high-water cursor; `since=0` = full row pull. + (Covers the IC command working set; broader-domain deltas remain future work.) +5. **Shift-start aggregates — DONE.** + - `GET /api/v4/Sync/Bundle` (`SyncController.Bundle` → `IIncidentCommandService.GetBundleForDepartmentAsync`): + a render-ready board per ACTIVE incident incl. computed accountability/PAR + active ad-hoc + cursor. + **Performance:** assembled with ONE scan per board table (grouped by CallId in memory) — O(tables), not + O(active incidents × department size) — and READ-ONLY (no PAR write-sweep / SignalR storm). The + per-incident PAR read is skippable with `?includeAccountability=false` for very busy departments. + - `GET /api/v4/Sync/Reference` (`SyncController.Reference` → `ISyncService.GetReferenceDataAsync`): the + reference manifest (§6.1.A). Personnel is a SAFE projection (`ReferencePersonnel`) — never raw + `IdentityUser` / `UserProfile` (which carry password hashes + contact-verification codes + CalendarSyncToken); + groups are projected to `ReferenceGroup` to drop the member `IdentityUser` navs. > CQRS/SignalR already publishes live updates including `IncidentCommandUpdated = 22` > (`ICoreEventService.IncidentCommandUpdatedAsync`). The delta endpoints are the *catch-up* path for