Skip to content

Commit ac093d3

Browse files
committed
RIC-T39 More Backend IC work
1 parent ca9a326 commit ac093d3

19 files changed

Lines changed: 1183 additions & 9 deletions
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
using System.Collections.Generic;
2+
3+
namespace Resgrid.Model
4+
{
5+
/// <summary>
6+
/// Shift-start aggregate for offline IC clients: a render-ready snapshot of every ACTIVE incident command in the
7+
/// caller's department in a single round-trip. Each <see cref="IncidentCommandBoard"/> carries the COMPUTED
8+
/// accountability / PAR status that the row-based <see cref="IncidentCommandChanges"/> delta cannot, plus the
9+
/// active ad-hoc resources. The client stores <see cref="ServerTimestampMs"/> and uses it as the <c>since</c>
10+
/// cursor for subsequent incremental <c>/Sync/Changes</c> pulls. See
11+
/// docs/architecture/offline-first-architecture.md (§6 / §9.5).
12+
/// </summary>
13+
public class IncidentCommandBundle
14+
{
15+
/// <summary>Server clock (Unix epoch ms) captured at the start of the read; seeds the next /Sync/Changes cursor.</summary>
16+
public long ServerTimestampMs { get; set; }
17+
18+
/// <summary>One render-ready board (incl. accountability / PAR) per active incident command in the department.</summary>
19+
public List<IncidentCommandBoard> Boards { get; set; } = new List<IncidentCommandBoard>();
20+
21+
/// <summary>Active ad-hoc units across the department's active incidents (aggregated by the caller).</summary>
22+
public List<IncidentAdHocUnit> AdHocUnits { get; set; } = new List<IncidentAdHocUnit>();
23+
24+
/// <summary>Active ad-hoc personnel across the department's active incidents (aggregated by the caller).</summary>
25+
public List<IncidentAdHocPersonnel> AdHocPersonnel { get; set; } = new List<IncidentAdHocPersonnel>();
26+
}
27+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
using System;
2+
using System.Collections.Generic;
3+
4+
namespace Resgrid.Model
5+
{
6+
/// <summary>
7+
/// Offline shift-start REFERENCE data: the slowly-changing department configuration + roster an IC app needs to
8+
/// START and RUN an incident in the field (call types, command templates, units, personnel, groups, POIs,
9+
/// protocols, accountability config, statuses, feature flags). Pulled once at shift start / on manual refresh;
10+
/// the LIVE per-incident state comes from /Sync/Bundle (active boards) and /Sync/Changes (deltas). See
11+
/// docs/architecture/offline-first-architecture.md. Personnel is a SAFE PROJECTION (<see cref="ReferencePersonnel"/>)
12+
/// — never the raw IdentityUser/UserProfile (which carry credentials + verification codes).
13+
/// </summary>
14+
public class SyncReferenceData
15+
{
16+
/// <summary>Server clock (Unix epoch ms) captured at the start of the read.</summary>
17+
public long ServerTimestampMs { get; set; }
18+
19+
public List<CallType> CallTypes { get; set; } = new List<CallType>();
20+
21+
public List<DepartmentCallPriority> CallPriorities { get; set; } = new List<DepartmentCallPriority>();
22+
23+
/// <summary>Command-definition templates (predefined swimlanes per call type) used to seed a new command.</summary>
24+
public List<CommandDefinition> CommandTemplates { get; set; } = new List<CommandDefinition>();
25+
26+
public List<Unit> Units { get; set; } = new List<Unit>();
27+
28+
public List<UnitType> UnitTypes { get; set; } = new List<UnitType>();
29+
30+
public List<ReferenceGroup> Groups { get; set; } = new List<ReferenceGroup>();
31+
32+
public List<Poi> Pois { get; set; } = new List<Poi>();
33+
34+
public List<PoiType> PoiTypes { get; set; } = new List<PoiType>();
35+
36+
public List<DispatchProtocol> Protocols { get; set; } = new List<DispatchProtocol>();
37+
38+
public List<CheckInTimerConfig> CheckInTimerConfigs { get; set; } = new List<CheckInTimerConfig>();
39+
40+
/// <summary>Department-defined personnel custom statuses.</summary>
41+
public List<CustomState> PersonnelStates { get; set; } = new List<CustomState>();
42+
43+
/// <summary>Department-defined unit custom statuses.</summary>
44+
public List<CustomState> UnitStates { get; set; } = new List<CustomState>();
45+
46+
/// <summary>Safe personnel roster projection (no credentials / contact-verification secrets).</summary>
47+
public List<ReferencePersonnel> Personnel { get; set; } = new List<ReferencePersonnel>();
48+
49+
/// <summary>Resolved feature flags for the department (drives addon/feature gating offline).</summary>
50+
public List<FeatureFlagEvaluation> Features { get; set; } = new List<FeatureFlagEvaluation>();
51+
}
52+
53+
/// <summary>
54+
/// Safe, minimal personnel projection for offline rosters — mirrors the field exposure of the existing v4
55+
/// PersonnelInfoResultData. Deliberately EXCLUDES the IdentityUser nav, password/security fields, and the
56+
/// UserProfile contact-verification codes + CalendarSyncToken.
57+
/// </summary>
58+
public class ReferencePersonnel
59+
{
60+
public string UserId { get; set; }
61+
62+
public string FirstName { get; set; }
63+
64+
public string LastName { get; set; }
65+
66+
public string MobilePhone { get; set; }
67+
68+
/// <summary>Primary group/station membership, if any.</summary>
69+
public int? GroupId { get; set; }
70+
71+
public string GroupName { get; set; }
72+
73+
/// <summary>Current personnel state (UserState.State); 0 when unknown.</summary>
74+
public int StateId { get; set; }
75+
76+
public DateTime? StateTimestamp { get; set; }
77+
}
78+
79+
/// <summary>Safe, minimal department group/station projection — excludes the member IdentityUser navs.</summary>
80+
public class ReferenceGroup
81+
{
82+
public int GroupId { get; set; }
83+
84+
public string Name { get; set; }
85+
86+
public int? Type { get; set; }
87+
88+
public int? ParentGroupId { get; set; }
89+
}
90+
}

Core/Resgrid.Model/Services/IIncidentCommandService.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,16 @@ public interface IIncidentCommandService
2626
/// </summary>
2727
Task<IncidentCommandChanges> GetChangesSinceAsync(int departmentId, System.DateTime sinceUtc);
2828

29+
/// <summary>Returns every ACTIVE incident command for the department (Status == Active).</summary>
30+
Task<List<IncidentCommand>> GetActiveCommandsForDepartmentAsync(int departmentId);
31+
32+
/// <summary>
33+
/// Offline shift-start aggregate: a render-ready board (incl. computed accountability / PAR) for every active
34+
/// incident in the department, plus the next-sync cursor, in one pull — cutting shift-start round-trips vs.
35+
/// fanning out per incident. Ad-hoc resources live in a sibling service and are aggregated by the caller.
36+
/// </summary>
37+
Task<IncidentCommandBundle> GetBundleForDepartmentAsync(int departmentId, bool includeAccountability = true);
38+
2939
/// <summary>
3040
/// Sweeps personnel accountability (PAR) for the call and raises <c>CriticalParDetectedEvent</c> once per
3141
/// member each time they transition into the Critical (overdue) state. Idempotent via a timeline marker —
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
using System.Threading.Tasks;
2+
3+
namespace Resgrid.Model.Services
4+
{
5+
/// <summary>
6+
/// Aggregates the offline shift-start REFERENCE data set (department configuration + a safe personnel roster) into
7+
/// a single payload, so an IC/Unit app can pull everything it needs to start and run an incident in one round-trip.
8+
/// The live per-incident state is delivered separately by IIncidentCommandService (board bundle + change deltas).
9+
/// </summary>
10+
public interface ISyncService
11+
{
12+
Task<SyncReferenceData> GetReferenceDataAsync(int departmentId);
13+
}
14+
}

Core/Resgrid.Services/IncidentCommandService.cs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,15 @@ public async Task<IncidentCommand> GetActiveCommandForCallAsync(int departmentId
157157
return items?.FirstOrDefault(x => x.CallId == callId && x.Status == (int)IncidentCommandStatus.Active);
158158
}
159159

160+
public async Task<List<IncidentCommand>> GetActiveCommandsForDepartmentAsync(int departmentId)
161+
{
162+
var items = await _incidentCommandRepository.GetAllByDepartmentIdAsync(departmentId);
163+
if (items == null)
164+
return new List<IncidentCommand>();
165+
166+
return items.Where(x => x.Status == (int)IncidentCommandStatus.Active).ToList();
167+
}
168+
160169
public async Task<IncidentCommand> GetCommandByIdAsync(string incidentCommandId)
161170
{
162171
return await _incidentCommandRepository.GetByIdAsync(incidentCommandId);
@@ -351,6 +360,61 @@ public async Task<IncidentCommandBoard> GetCommandBoardAsync(int departmentId, i
351360
return board;
352361
}
353362

363+
public async Task<IncidentCommandBundle> GetBundleForDepartmentAsync(int departmentId, bool includeAccountability = true)
364+
{
365+
// Capture the cursor before reading so the client's first incremental /Sync/Changes call doesn't miss a
366+
// row committed during this read (a re-returned row is harmless — the client upserts idempotently).
367+
var bundle = new IncidentCommandBundle { ServerTimestampMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() };
368+
369+
var active = await GetActiveCommandsForDepartmentAsync(departmentId);
370+
if (active.Count == 0)
371+
return bundle;
372+
373+
// Pull each board table ONCE for the whole department and index by CallId, instead of re-scanning every
374+
// table per incident. The per-call getters each do a full GetAllByDepartmentIdAsync, and GetCommandBoardAsync
375+
// additionally fires the write-side PAR sweep — so assembling N boards that way is O(active incidents ×
376+
// department size) plus N marker-writes / SignalR pushes. Doing it here keeps the bundle O(number of tables)
377+
// and side-effect free, which is what hurts departments with many open/active incidents.
378+
var nodes = ToCallLookup(await _commandStructureNodeRepository.GetAllByDepartmentIdAsync(departmentId), x => x.CallId);
379+
var assignments = ToCallLookup(await _resourceAssignmentRepository.GetAllByDepartmentIdAsync(departmentId), x => x.CallId);
380+
var objectives = ToCallLookup(await _tacticalObjectiveRepository.GetAllByDepartmentIdAsync(departmentId), x => x.CallId);
381+
var timers = ToCallLookup(await _incidentTimerRepository.GetAllByDepartmentIdAsync(departmentId), x => x.CallId);
382+
var annotations = ToCallLookup(await _incidentMapAnnotationRepository.GetAllByDepartmentIdAsync(departmentId), x => x.CallId);
383+
var roles = ToCallLookup(await _incidentRoleAssignmentRepository.GetAllByDepartmentIdAsync(departmentId), x => x.CallId);
384+
385+
foreach (var command in active)
386+
{
387+
var callId = command.CallId;
388+
389+
var board = new IncidentCommandBoard
390+
{
391+
Command = command,
392+
// These mirror the per-call getter filters exactly (DeletedOn / ReleasedOn / RemovedOn tombstones +
393+
// the active-timer rule), so the bundled board matches what GetCommandBoardAsync would return.
394+
Nodes = nodes[callId].Where(x => x.DeletedOn == null).OrderBy(x => x.SortOrder).ToList(),
395+
Assignments = assignments[callId].Where(x => x.ReleasedOn == null).ToList(),
396+
Objectives = objectives[callId].OrderBy(x => x.SortOrder).ToList(),
397+
Timers = timers[callId].Where(x => x.Status != (int)IncidentTimerStatus.Stopped).ToList(),
398+
Annotations = annotations[callId].Where(x => x.DeletedOn == null).ToList(),
399+
Roles = roles[callId].Where(x => x.RemovedOn == null).ToList()
400+
};
401+
402+
// Accountability/PAR is the one per-incident read here, and it is READ-ONLY (no marker writes / SignalR
403+
// pushes — unlike GetCommandBoardAsync's sweep). A department with very many open incidents can opt out
404+
// via includeAccountability=false and fetch PAR per incident on demand.
405+
if (includeAccountability)
406+
board.Accountability = await GetAccountabilityForCallAsync(departmentId, callId);
407+
408+
bundle.Boards.Add(board);
409+
}
410+
411+
return bundle;
412+
}
413+
414+
/// <summary>Indexes a department-wide row set by CallId; a missing key yields an empty sequence (no exception).</summary>
415+
private static ILookup<int, T> ToCallLookup<T>(IEnumerable<T> items, Func<T, int> callIdSelector)
416+
=> (items ?? Enumerable.Empty<T>()).ToLookup(callIdSelector);
417+
354418
public async Task<IncidentCommandChanges> GetChangesSinceAsync(int departmentId, DateTime sinceUtc)
355419
{
356420
// Capture the cursor before reading so a row committed during the read is not missed next time (it may be

Core/Resgrid.Services/ServicesModule.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ protected override void Load(ContainerBuilder builder)
2020
builder.RegisterType<IncidentVoiceService>().As<IIncidentVoiceService>().InstancePerLifetimeScope();
2121
builder.RegisterType<MutualAidService>().As<IMutualAidService>().InstancePerLifetimeScope();
2222
builder.RegisterType<IncidentResourcesService>().As<IIncidentResourcesService>().InstancePerLifetimeScope();
23+
builder.RegisterType<SyncService>().As<ISyncService>().InstancePerLifetimeScope();
2324
builder.RegisterType<IncidentReportingService>().As<IIncidentReportingService>().InstancePerLifetimeScope();
2425
builder.RegisterType<WorkflowTemplateContextBuilder>().As<Resgrid.Model.Providers.IWorkflowTemplateContextBuilder>().InstancePerLifetimeScope();
2526
builder.RegisterType<LogService>().As<ILogService>().InstancePerLifetimeScope();
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Threading.Tasks;
5+
using Resgrid.Model;
6+
using Resgrid.Model.Services;
7+
8+
namespace Resgrid.Services
9+
{
10+
/// <summary>
11+
/// Aggregates the offline shift-start REFERENCE data set (department configuration + a SAFE personnel roster) into
12+
/// one payload. Read-only. Personnel is projected to <see cref="ReferencePersonnel"/> so no credentials, security
13+
/// fields, or contact-verification secrets are exposed. The live per-incident state is delivered separately by the
14+
/// incident-command bundle (/Sync/Bundle) and delta (/Sync/Changes) endpoints.
15+
/// </summary>
16+
public class SyncService : ISyncService
17+
{
18+
private readonly ICallsService _callsService;
19+
private readonly ICommandsService _commandsService;
20+
private readonly IUnitsService _unitsService;
21+
private readonly IDepartmentGroupsService _departmentGroupsService;
22+
private readonly IMappingService _mappingService;
23+
private readonly IProtocolsService _protocolsService;
24+
private readonly ICheckInTimerService _checkInTimerService;
25+
private readonly ICustomStateService _customStateService;
26+
private readonly IUserProfileService _userProfileService;
27+
private readonly IUserStateService _userStateService;
28+
private readonly IFeatureToggleService _featureToggleService;
29+
30+
public SyncService(
31+
ICallsService callsService,
32+
ICommandsService commandsService,
33+
IUnitsService unitsService,
34+
IDepartmentGroupsService departmentGroupsService,
35+
IMappingService mappingService,
36+
IProtocolsService protocolsService,
37+
ICheckInTimerService checkInTimerService,
38+
ICustomStateService customStateService,
39+
IUserProfileService userProfileService,
40+
IUserStateService userStateService,
41+
IFeatureToggleService featureToggleService)
42+
{
43+
_callsService = callsService;
44+
_commandsService = commandsService;
45+
_unitsService = unitsService;
46+
_departmentGroupsService = departmentGroupsService;
47+
_mappingService = mappingService;
48+
_protocolsService = protocolsService;
49+
_checkInTimerService = checkInTimerService;
50+
_customStateService = customStateService;
51+
_userProfileService = userProfileService;
52+
_userStateService = userStateService;
53+
_featureToggleService = featureToggleService;
54+
}
55+
56+
public async Task<SyncReferenceData> GetReferenceDataAsync(int departmentId)
57+
{
58+
var data = new SyncReferenceData { ServerTimestampMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() };
59+
60+
// Configuration / reference entities returned as-is (audited: no secret scalar fields, no IdentityUser navs).
61+
data.CallTypes = await _callsService.GetCallTypesForDepartmentAsync(departmentId) ?? new List<CallType>();
62+
data.CallPriorities = await _callsService.GetCallPrioritiesForDepartmentAsync(departmentId) ?? new List<DepartmentCallPriority>();
63+
data.CommandTemplates = await _commandsService.GetAllCommandsForDepartmentAsync(departmentId) ?? new List<CommandDefinition>();
64+
data.Units = await _unitsService.GetUnitsForDepartmentAsync(departmentId) ?? new List<Unit>();
65+
data.UnitTypes = await _unitsService.GetUnitTypesForDepartmentAsync(departmentId) ?? new List<UnitType>();
66+
data.Pois = await _mappingService.GetPOIsForDepartmentAsync(departmentId) ?? new List<Poi>();
67+
data.PoiTypes = await _mappingService.GetPOITypesForDepartmentAsync(departmentId) ?? new List<PoiType>();
68+
data.Protocols = await _protocolsService.GetAllProtocolsForDepartmentAsync(departmentId) ?? new List<DispatchProtocol>();
69+
data.CheckInTimerConfigs = await _checkInTimerService.GetTimerConfigsForDepartmentAsync(departmentId) ?? new List<CheckInTimerConfig>();
70+
data.PersonnelStates = await _customStateService.GetAllActiveCustomStatesForDepartmentAsync(departmentId) ?? new List<CustomState>();
71+
data.UnitStates = await _customStateService.GetAllActiveUnitStatesForDepartmentAsync(departmentId) ?? new List<CustomState>();
72+
data.Features = await _featureToggleService.EvaluateAllForDepartmentAsync(departmentId) ?? new List<FeatureFlagEvaluation>();
73+
74+
// Groups: project to a safe shape. The raw DepartmentGroup.Members carry IdentityUser navs we must not leak,
75+
// and mutating the (possibly cached) entities to strip them would be unsafe.
76+
var groups = await _departmentGroupsService.GetAllGroupsForDepartmentAsync(departmentId) ?? new List<DepartmentGroup>();
77+
data.Groups = groups.Select(g => new ReferenceGroup
78+
{
79+
GroupId = g.DepartmentGroupId,
80+
Name = g.Name,
81+
Type = g.Type,
82+
ParentGroupId = g.ParentDepartmentGroupId
83+
}).ToList();
84+
85+
data.Personnel = await BuildPersonnelAsync(departmentId, groups);
86+
87+
return data;
88+
}
89+
90+
/// <summary>
91+
/// Builds the SAFE personnel roster (name + mobile, primary group, current state) projected from UserProfile +
92+
/// UserState — never exposing the IdentityUser nav, password/security fields, or the UserProfile contact-
93+
/// verification codes / CalendarSyncToken. Mirrors the field exposure of the existing v4 PersonnelInfoResultData.
94+
/// </summary>
95+
private async Task<List<ReferencePersonnel>> BuildPersonnelAsync(int departmentId, List<DepartmentGroup> groups)
96+
{
97+
var personnel = new List<ReferencePersonnel>();
98+
99+
var profiles = await _userProfileService.GetAllProfilesForDepartmentAsync(departmentId);
100+
if (profiles == null || profiles.Count == 0)
101+
return personnel;
102+
103+
// First group membership wins as the member's "primary" group.
104+
var userGroup = new Dictionary<string, ReferenceGroup>();
105+
foreach (var g in groups)
106+
{
107+
if (g.Members == null)
108+
continue;
109+
110+
foreach (var m in g.Members)
111+
{
112+
if (!string.IsNullOrWhiteSpace(m.UserId) && !userGroup.ContainsKey(m.UserId))
113+
userGroup[m.UserId] = new ReferenceGroup { GroupId = g.DepartmentGroupId, Name = g.Name };
114+
}
115+
}
116+
117+
var states = await _userStateService.GetStatesForDepartmentAsync(departmentId) ?? new List<UserState>();
118+
var stateByUser = states
119+
.Where(s => !string.IsNullOrWhiteSpace(s.UserId))
120+
.GroupBy(s => s.UserId)
121+
.ToDictionary(grp => grp.Key, grp => grp.OrderByDescending(s => s.Timestamp).First());
122+
123+
foreach (var profile in profiles.Values)
124+
{
125+
if (profile == null || string.IsNullOrWhiteSpace(profile.UserId))
126+
continue;
127+
128+
var person = new ReferencePersonnel
129+
{
130+
UserId = profile.UserId,
131+
FirstName = profile.FirstName,
132+
LastName = profile.LastName,
133+
MobilePhone = profile.MobileNumber
134+
};
135+
136+
if (userGroup.TryGetValue(profile.UserId, out var grp))
137+
{
138+
person.GroupId = grp.GroupId;
139+
person.GroupName = grp.Name;
140+
}
141+
142+
if (stateByUser.TryGetValue(profile.UserId, out var state))
143+
{
144+
person.StateId = state.State;
145+
person.StateTimestamp = state.Timestamp;
146+
}
147+
148+
personnel.Add(person);
149+
}
150+
151+
return personnel;
152+
}
153+
}
154+
}

0 commit comments

Comments
 (0)