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