Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Core/Resgrid.Model/EventingTypes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ public enum EventingTypes
CallAdded = 5,
CallClosed = 6,
PersonnelLocationUpdated = 7,
UnitLocationUpdated = 8
UnitLocationUpdated = 8,
IncidentCommandUpdated = 9
}
}
11 changes: 11 additions & 0 deletions Core/Resgrid.Model/Events/IncidentCommandEvents.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,4 +93,15 @@ public class CriticalParDetectedEvent
public int CallId { get; set; }
public string UserId { get; set; }
}

/// <summary>
/// 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 <see cref="CallUpdatedEvent"/>.
/// </summary>
public class IncidentCommandUpdatedEvent
{
public int DepartmentId { get; set; }
public int CallId { get; set; }
}
}
27 changes: 27 additions & 0 deletions Core/Resgrid.Model/IncidentCommand/IncidentCommandBundle.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using System.Collections.Generic;

namespace Resgrid.Model
{
/// <summary>
/// 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 <see cref="IncidentCommandBoard"/> carries the COMPUTED
/// accountability / PAR status that the row-based <see cref="IncidentCommandChanges"/> delta cannot, plus the
/// active ad-hoc resources. The client stores <see cref="ServerTimestampMs"/> and uses it as the <c>since</c>
/// cursor for subsequent incremental <c>/Sync/Changes</c> pulls. See
/// docs/architecture/offline-first-architecture.md (§6 / §9.5).
/// </summary>
public class IncidentCommandBundle
{
/// <summary>Server clock (Unix epoch ms) captured at the start of the read; seeds the next /Sync/Changes cursor.</summary>
public long ServerTimestampMs { get; set; }

/// <summary>One render-ready board (incl. accountability / PAR) per active incident command in the department.</summary>
public List<IncidentCommandBoard> Boards { get; set; } = new List<IncidentCommandBoard>();

/// <summary>Active ad-hoc units across the department's active incidents (aggregated by the caller).</summary>
public List<IncidentAdHocUnit> AdHocUnits { get; set; } = new List<IncidentAdHocUnit>();

/// <summary>Active ad-hoc personnel across the department's active incidents (aggregated by the caller).</summary>
public List<IncidentAdHocPersonnel> AdHocPersonnel { get; set; } = new List<IncidentAdHocPersonnel>();
}
}
103 changes: 103 additions & 0 deletions Core/Resgrid.Model/IncidentCommand/SyncReferenceData.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
using System;
using System.Collections.Generic;
using ProtoBuf;

namespace Resgrid.Model
{
/// <summary>
/// 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 (<see cref="ReferencePersonnel"/>)
/// — never the raw IdentityUser/UserProfile (which carry credentials + verification codes).
/// </summary>
public class SyncReferenceData
{
/// <summary>Server clock (Unix epoch ms) captured at the start of the read.</summary>
public long ServerTimestampMs { get; set; }

public List<CallType> CallTypes { get; set; } = new List<CallType>();

public List<DepartmentCallPriority> CallPriorities { get; set; } = new List<DepartmentCallPriority>();

/// <summary>Command-definition templates (predefined swimlanes per call type) used to seed a new command.</summary>
public List<CommandDefinition> CommandTemplates { get; set; } = new List<CommandDefinition>();

public List<Unit> Units { get; set; } = new List<Unit>();

public List<UnitType> UnitTypes { get; set; } = new List<UnitType>();

public List<ReferenceGroup> Groups { get; set; } = new List<ReferenceGroup>();

public List<Poi> Pois { get; set; } = new List<Poi>();

public List<PoiType> PoiTypes { get; set; } = new List<PoiType>();

public List<DispatchProtocol> Protocols { get; set; } = new List<DispatchProtocol>();

public List<CheckInTimerConfig> CheckInTimerConfigs { get; set; } = new List<CheckInTimerConfig>();

/// <summary>Department-defined personnel custom statuses.</summary>
public List<CustomState> PersonnelStates { get; set; } = new List<CustomState>();

/// <summary>Department-defined unit custom statuses.</summary>
public List<CustomState> UnitStates { get; set; } = new List<CustomState>();

/// <summary>Safe personnel roster projection (no credentials / contact-verification secrets).</summary>
public List<ReferencePersonnel> Personnel { get; set; } = new List<ReferencePersonnel>();

/// <summary>Resolved feature flags for the department (drives addon/feature gating offline).</summary>
public List<FeatureFlagEvaluation> Features { get; set; } = new List<FeatureFlagEvaluation>();
}

/// <summary>
/// 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.
/// </summary>
public class ReferencePersonnel
{
public string UserId { get; set; }

public string FirstName { get; set; }

public string LastName { get; set; }

public string MobilePhone { get; set; }

/// <summary>Primary group/station membership, if any.</summary>
public int? GroupId { get; set; }

public string GroupName { get; set; }

/// <summary>Current personnel state (UserState.State); 0 when unknown.</summary>
public int StateId { get; set; }

public DateTime? StateTimestamp { get; set; }
}

/// <summary>Safe, minimal department group/station projection — excludes the member IdentityUser navs.</summary>
public class ReferenceGroup
{
public int GroupId { get; set; }

public string Name { get; set; }

public int? Type { get; set; }

public int? ParentGroupId { get; set; }
}

/// <summary>
/// Protobuf-safe cache envelope for the reference bootstrap. The cache provider serializes via protobuf-net, but
/// most of <see cref="SyncReferenceData"/>'s contained entities are not <c>[ProtoContract]</c>; rather than
/// ProtoContract-tag ~8 shared entities, the reference payload is cached as a JSON snapshot inside this contract.
/// </summary>
[ProtoContract]
public class ReferenceCacheEnvelope
{
[ProtoMember(1)]
public string Json { get; set; }
}
}
3 changes: 2 additions & 1 deletion Core/Resgrid.Model/Providers/IRabbitInboundEventProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ void RegisterForEvents(Func<int, string, Task> personnelStatusChanged,
Func<int, string, Task> callAdded,
Func<int, string, Task> callClosed,
Func<int, PersonnelLocationUpdatedEvent, Task> personnelLocationUpdated,
Func<int, UnitLocationUpdatedEvent, Task> unitLocationUpdated);
Func<int, UnitLocationUpdatedEvent, Task> unitLocationUpdated,
Func<int, string, Task> incidentCommandUpdated);
}
}
5 changes: 3 additions & 2 deletions Core/Resgrid.Model/Services/ICoreEventService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ public interface ICoreEventService
{
/// <summary>
/// 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.
/// </summary>
Task IncidentCommandUpdatedAsync(int departmentId, int callId);
}
Expand Down
10 changes: 10 additions & 0 deletions Core/Resgrid.Model/Services/IIncidentCommandService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,16 @@ public interface IIncidentCommandService
/// </summary>
Task<IncidentCommandChanges> GetChangesSinceAsync(int departmentId, System.DateTime sinceUtc);

/// <summary>Returns every ACTIVE incident command for the department (Status == Active).</summary>
Task<List<IncidentCommand>> GetActiveCommandsForDepartmentAsync(int departmentId);

/// <summary>
/// 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.
/// </summary>
Task<IncidentCommandBundle> GetBundleForDepartmentAsync(int departmentId, bool includeAccountability = true);

/// <summary>
/// Sweeps personnel accountability (PAR) for the call and raises <c>CriticalParDetectedEvent</c> once per
/// member each time they transition into the Critical (overdue) state. Idempotent via a timeline marker —
Expand Down
6 changes: 6 additions & 0 deletions Core/Resgrid.Model/Services/IIncidentResourcesService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,11 @@ public interface IIncidentResourcesService
/// Aggregated into the unified /Sync/Changes payload by SyncController. See offline-first-architecture.md.
/// </summary>
Task<(List<IncidentAdHocUnit> Units, List<IncidentAdHocPersonnel> Personnel)> GetAdHocChangesSinceAsync(int departmentId, System.DateTime sinceUtc);

/// <summary>
/// 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.
/// </summary>
Task<(List<IncidentAdHocUnit> Units, List<IncidentAdHocPersonnel> Personnel)> GetActiveAdHocResourcesForDepartmentAsync(int departmentId);
}
}
14 changes: 14 additions & 0 deletions Core/Resgrid.Model/Services/ISyncService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using System.Threading.Tasks;

namespace Resgrid.Model.Services
{
/// <summary>
/// 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).
/// </summary>
public interface ISyncService
{
Task<SyncReferenceData> GetReferenceDataAsync(int departmentId, bool bypassCache = false);
}
}
20 changes: 10 additions & 10 deletions Core/Resgrid.Services/CoreEventService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +15 to 17

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Keep a durable handoff for IncidentCommandUpdatedAsync.

This now returns before the update is persisted anywhere. SendMessage(...) only dispatches in-process, and the new downstream publisher path is fire-and-forget, so a Rabbit publish failure or process recycle can drop the only sync trigger for connected IC clients.

Either keep the previous CQRS enqueue here or move this event rail to an awaitable/persisted publish path before returning.

Also applies to: 28-39

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Core/Resgrid.Services/CoreEventService.cs` around lines 15 - 17,
`IncidentCommandUpdatedAsync` now relies on an in-process send and
fire-and-forget publish, which can lose the only client sync trigger before
persistence. Update `CoreEventService` so the event path uses a durable, awaited
handoff before returning—either restore the previous CQRS enqueue or switch
`SendMessage`/the downstream publisher path to a persisted publish mechanism and
await completion in `IncidentCommandUpdatedAsync`.

_cqrsProvider = cqrsProvider;

_eventAggregator.AddListener(departmentSettingsUpdateHandler);
}
Expand All @@ -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<IncidentCommandUpdatedEvent>(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;
}
}
}
64 changes: 64 additions & 0 deletions Core/Resgrid.Services/IncidentCommandService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,15 @@ public async Task<IncidentCommand> GetActiveCommandForCallAsync(int departmentId
return items?.FirstOrDefault(x => x.CallId == callId && x.Status == (int)IncidentCommandStatus.Active);
}

public async Task<List<IncidentCommand>> GetActiveCommandsForDepartmentAsync(int departmentId)
{
var items = await _incidentCommandRepository.GetAllByDepartmentIdAsync(departmentId);
if (items == null)
return new List<IncidentCommand>();

return items.Where(x => x.Status == (int)IncidentCommandStatus.Active).ToList();
}

public async Task<IncidentCommand> GetCommandByIdAsync(string incidentCommandId)
{
return await _incidentCommandRepository.GetByIdAsync(incidentCommandId);
Expand Down Expand Up @@ -351,6 +360,61 @@ public async Task<IncidentCommandBoard> GetCommandBoardAsync(int departmentId, i
return board;
}

public async Task<IncidentCommandBundle> 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;
}

/// <summary>Indexes a department-wide row set by CallId; a missing key yields an empty sequence (no exception).</summary>
private static ILookup<int, T> ToCallLookup<T>(IEnumerable<T> items, Func<T, int> callIdSelector)
=> (items ?? Enumerable.Empty<T>()).ToLookup(callIdSelector);

public async Task<IncidentCommandChanges> 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
Expand Down
15 changes: 15 additions & 0 deletions Core/Resgrid.Services/IncidentResourcesService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,21 @@ public async Task<List<IncidentAdHocPersonnel>> GetAdHocPersonnelForCallAsync(in
personnel?.Where(Changed).ToList() ?? new List<IncidentAdHocPersonnel>());
}

public async Task<(List<IncidentAdHocUnit> Units, List<IncidentAdHocPersonnel> 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<IncidentAdHocUnit>(),
personnel?.Where(p => p.ReleasedOn == null && activeCallIds.Contains(p.CallId)).ToList() ?? new List<IncidentAdHocPersonnel>());
}

#endregion Offline sync

#region Private helpers
Expand Down
1 change: 1 addition & 0 deletions Core/Resgrid.Services/ServicesModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ protected override void Load(ContainerBuilder builder)
builder.RegisterType<IncidentVoiceService>().As<IIncidentVoiceService>().InstancePerLifetimeScope();
builder.RegisterType<MutualAidService>().As<IMutualAidService>().InstancePerLifetimeScope();
builder.RegisterType<IncidentResourcesService>().As<IIncidentResourcesService>().InstancePerLifetimeScope();
builder.RegisterType<SyncService>().As<ISyncService>().InstancePerLifetimeScope();
builder.RegisterType<IncidentReportingService>().As<IIncidentReportingService>().InstancePerLifetimeScope();
builder.RegisterType<WorkflowTemplateContextBuilder>().As<Resgrid.Model.Providers.IWorkflowTemplateContextBuilder>().InstancePerLifetimeScope();
builder.RegisterType<LogService>().As<ILogService>().InstancePerLifetimeScope();
Expand Down
Loading
Loading