Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
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>();
}
}
90 changes: 90 additions & 0 deletions Core/Resgrid.Model/IncidentCommand/SyncReferenceData.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
using System;
using System.Collections.Generic;

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; }
}
}
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
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);
}
}
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
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
154 changes: 154 additions & 0 deletions Core/Resgrid.Services/SyncService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Resgrid.Model;
using Resgrid.Model.Services;

namespace Resgrid.Services
{
/// <summary>
/// Aggregates the offline shift-start REFERENCE data set (department configuration + a SAFE personnel roster) into
/// one payload. Read-only. Personnel is projected to <see cref="ReferencePersonnel"/> 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.
/// </summary>
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;

public SyncService(
ICallsService callsService,
ICommandsService commandsService,
IUnitsService unitsService,
IDepartmentGroupsService departmentGroupsService,
IMappingService mappingService,
IProtocolsService protocolsService,
ICheckInTimerService checkInTimerService,
ICustomStateService customStateService,
IUserProfileService userProfileService,
IUserStateService userStateService,
IFeatureToggleService featureToggleService)
{
_callsService = callsService;
_commandsService = commandsService;
_unitsService = unitsService;
_departmentGroupsService = departmentGroupsService;
_mappingService = mappingService;
_protocolsService = protocolsService;
_checkInTimerService = checkInTimerService;
_customStateService = customStateService;
_userProfileService = userProfileService;
_userStateService = userStateService;
_featureToggleService = featureToggleService;
}

public async Task<SyncReferenceData> GetReferenceDataAsync(int departmentId)
{
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<CallType>();
data.CallPriorities = await _callsService.GetCallPrioritiesForDepartmentAsync(departmentId) ?? new List<DepartmentCallPriority>();
data.CommandTemplates = await _commandsService.GetAllCommandsForDepartmentAsync(departmentId) ?? new List<CommandDefinition>();
data.Units = await _unitsService.GetUnitsForDepartmentAsync(departmentId) ?? new List<Unit>();
data.UnitTypes = await _unitsService.GetUnitTypesForDepartmentAsync(departmentId) ?? new List<UnitType>();
data.Pois = await _mappingService.GetPOIsForDepartmentAsync(departmentId) ?? new List<Poi>();
data.PoiTypes = await _mappingService.GetPOITypesForDepartmentAsync(departmentId) ?? new List<PoiType>();
data.Protocols = await _protocolsService.GetAllProtocolsForDepartmentAsync(departmentId) ?? new List<DispatchProtocol>();
data.CheckInTimerConfigs = await _checkInTimerService.GetTimerConfigsForDepartmentAsync(departmentId) ?? new List<CheckInTimerConfig>();
data.PersonnelStates = await _customStateService.GetAllActiveCustomStatesForDepartmentAsync(departmentId) ?? new List<CustomState>();
data.UnitStates = await _customStateService.GetAllActiveUnitStatesForDepartmentAsync(departmentId) ?? new List<CustomState>();
data.Features = await _featureToggleService.EvaluateAllForDepartmentAsync(departmentId) ?? new List<FeatureFlagEvaluation>();

// 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<DepartmentGroup>();
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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

/// <summary>
/// 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.
/// </summary>
private async Task<List<ReferencePersonnel>> BuildPersonnelAsync(int departmentId, List<DepartmentGroup> groups)
{
var personnel = new List<ReferencePersonnel>();

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<string, ReferenceGroup>();
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<UserState>();
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;
}
}
}
Loading
Loading