Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
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; }
}
}
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);
}
}
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