From de29826c0bb0f99a8c6915fbd4be7ae600cebe34 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Mon, 20 Jul 2026 19:38:32 -0700 Subject: [PATCH] RIC-T39 Backend work for IC updates --- .../Events/IncidentCommandEvents.cs | 52 + .../IncidentCommand/CommandStructureNode.cs | 33 + .../IncidentCommand/IncidentCommand.cs | 6 + .../IncidentCommand/IncidentCommandBoard.cs | 3 + .../IncidentCommand/IncidentCommandChanges.cs | 2 + .../IncidentCommand/IncidentCommandEnums.cs | 31 +- .../IncidentCommand/IncidentNeed.cs | 77 ++ .../IncidentCommand/IncidentTacticals.cs | 12 + .../IncidentCommand/ResourceIncidentView.cs | 92 ++ .../IIncidentCommandRepositories.cs | 4 + .../IIncidentCommandNotificationService.cs | 34 + .../Services/IIncidentCommandService.cs | 26 + .../IncidentCommandNotificationService.cs | 236 ++++ .../IncidentCommandService.cs | 356 +++++- Core/Resgrid.Services/ServicesModule.cs | 1 + ...identNeedsLaneLeadsAndObjectiveProgress.cs | 132 ++ ...entNeedsLaneLeadsAndObjectiveProgressPg.cs | 119 ++ .../IncidentCommandRepositories.cs | 8 + .../Modules/DataModule.cs | 1 + .../IncidentCommandContentServiceTests.cs | 5 +- .../IncidentCommandNeedsAndLeadsTests.cs | 372 ++++++ .../IncidentCommandServiceParTests.cs | 9 +- .../v4/IncidentCommandController.cs | 161 +++ .../IncidentCommand/IncidentCommandModels.cs | 40 + .../Resgrid.Web.Services.xml | 1138 +++++++++-------- 25 files changed, 2393 insertions(+), 557 deletions(-) create mode 100644 Core/Resgrid.Model/IncidentCommand/IncidentNeed.cs create mode 100644 Core/Resgrid.Model/IncidentCommand/ResourceIncidentView.cs create mode 100644 Core/Resgrid.Model/Services/IIncidentCommandNotificationService.cs create mode 100644 Core/Resgrid.Services/IncidentCommandNotificationService.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0093_AddIncidentNeedsLaneLeadsAndObjectiveProgress.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0093_AddIncidentNeedsLaneLeadsAndObjectiveProgressPg.cs create mode 100644 Tests/Resgrid.Tests/Services/IncidentCommandNeedsAndLeadsTests.cs diff --git a/Core/Resgrid.Model/Events/IncidentCommandEvents.cs b/Core/Resgrid.Model/Events/IncidentCommandEvents.cs index 2ff6cfd8..97fd78b2 100644 --- a/Core/Resgrid.Model/Events/IncidentCommandEvents.cs +++ b/Core/Resgrid.Model/Events/IncidentCommandEvents.cs @@ -182,4 +182,56 @@ public class IncidentPublicSharingChangedEvent public bool Enabled { get; set; } public string UpdatedByUserId { get; set; } } + + /// Raised when a resource assignment is moved between lanes. + public class IncidentResourceMovedEvent + { + public int DepartmentId { get; set; } + public int CallId { get; set; } + public string IncidentCommandId { get; set; } + public string ResourceAssignmentId { get; set; } + public int ResourceKind { get; set; } + public string ResourceId { get; set; } + public string FromNodeId { get; set; } + public string ToNodeId { get; set; } + } + + /// Raised when a lane's primary or secondary lead changes. + public class LaneLeadChangedEvent + { + public int DepartmentId { get; set; } + public int CallId { get; set; } + public string IncidentCommandId { get; set; } + public string CommandStructureNodeId { get; set; } + public string LaneName { get; set; } + public bool IsPrimary { get; set; } + /// User id of the lead going off (null when the outgoing lead was external or the slot was empty). + public string PreviousLeadUserId { get; set; } + /// Display name of the lead going off (external leads; null when the slot was empty). + public string PreviousLeadName { get; set; } + /// User id of the lead coming on (null when the incoming lead is external or the slot was cleared). + public string NewLeadUserId { get; set; } + /// Display name of the lead coming on (external leads; null when the slot was cleared). + public string NewLeadName { get; set; } + } + + /// Raised when an incident need is created or its status/fulfillment changes. + public class IncidentNeedChangedEvent + { + public int DepartmentId { get; set; } + public int CallId { get; set; } + public string IncidentCommandId { get; set; } + public string IncidentNeedId { get; set; } + public string Name { get; set; } + public int Status { get; set; } + } + + /// Raised when command details (estimated end / important information) are updated. + public class IncidentCommandDetailsUpdatedEvent + { + public int DepartmentId { get; set; } + public int CallId { get; set; } + public string IncidentCommandId { get; set; } + public string UpdatedByUserId { get; set; } + } } diff --git a/Core/Resgrid.Model/IncidentCommand/CommandStructureNode.cs b/Core/Resgrid.Model/IncidentCommand/CommandStructureNode.cs index b09ebbf5..4ac52c41 100644 --- a/Core/Resgrid.Model/IncidentCommand/CommandStructureNode.cs +++ b/Core/Resgrid.Model/IncidentCommand/CommandStructureNode.cs @@ -60,6 +60,39 @@ public class CommandStructureNode : IEntity, IChangeTracked /// When true, unmet lane requirements block assignment instead of warning. Seeded from the template role. public bool ForceRequirements { get; set; } + /// Optional primary tactical objective this lane is working (FK to TacticalObjectives). + public string PrimaryObjectiveId { get; set; } + + /// Optional secondary tactical objective this lane is working (FK to TacticalObjectives). + public string SecondaryObjectiveId { get; set; } + + /// Optional incident need this lane is fulfilling (FK to IncidentNeeds). + public string LinkedNeedId { get; set; } + + /// Primary lane lead when they are a Resgrid user; null for external leads. + public string PrimaryLeadUserId { get; set; } + + /// Primary lane lead display name (external leads; ignored when PrimaryLeadUserId is set). + public string PrimaryLeadName { get; set; } + + /// Primary lane lead contact phone (external leads). + public string PrimaryLeadPhone { get; set; } + + /// Primary lane lead contact email (external leads). + public string PrimaryLeadEmail { get; set; } + + /// Secondary lane lead when they are a Resgrid user; null for external leads. + public string SecondaryLeadUserId { get; set; } + + /// Secondary lane lead display name (external leads; ignored when SecondaryLeadUserId is set). + public string SecondaryLeadName { get; set; } + + /// Secondary lane lead contact phone (external leads). + public string SecondaryLeadPhone { get; set; } + + /// Secondary lane lead contact email (external leads). + public string SecondaryLeadEmail { get; set; } + /// Soft-delete tombstone so a lane removed offline propagates on delta sync (null = live). public DateTime? DeletedOn { get; set; } diff --git a/Core/Resgrid.Model/IncidentCommand/IncidentCommand.cs b/Core/Resgrid.Model/IncidentCommand/IncidentCommand.cs index d0d858ae..f9c6906a 100644 --- a/Core/Resgrid.Model/IncidentCommand/IncidentCommand.cs +++ b/Core/Resgrid.Model/IncidentCommand/IncidentCommand.cs @@ -35,6 +35,12 @@ public class IncidentCommand : IEntity, IChangeTracked /// NIMS/ICS escalation level for the incident (department defined). public int IcsLevel { get; set; } + /// Optional commander-supplied estimate of when the incident will end. + public DateTime? EstimatedEndOn { get; set; } + + /// Important information every resource on the incident should see (hazards, staging notes, ...). + public string ImportantInformation { get; set; } + /// Maps to . public int Status { get; set; } diff --git a/Core/Resgrid.Model/IncidentCommand/IncidentCommandBoard.cs b/Core/Resgrid.Model/IncidentCommand/IncidentCommandBoard.cs index e02acdfe..8b1e1e5a 100644 --- a/Core/Resgrid.Model/IncidentCommand/IncidentCommandBoard.cs +++ b/Core/Resgrid.Model/IncidentCommand/IncidentCommandBoard.cs @@ -15,6 +15,9 @@ public class IncidentCommandBoard public List Objectives { get; set; } = new List(); + /// Command-level needs (resources/logistics/etc.) tracked to fulfillment. + public List Needs { get; set; } = new List(); + public List Timers { get; set; } = new List(); public List Annotations { get; set; } = new List(); diff --git a/Core/Resgrid.Model/IncidentCommand/IncidentCommandChanges.cs b/Core/Resgrid.Model/IncidentCommand/IncidentCommandChanges.cs index e080b9ea..fed18a71 100644 --- a/Core/Resgrid.Model/IncidentCommand/IncidentCommandChanges.cs +++ b/Core/Resgrid.Model/IncidentCommand/IncidentCommandChanges.cs @@ -23,6 +23,8 @@ public class IncidentCommandChanges public List Objectives { get; set; } = new List(); + public List Needs { get; set; } = new List(); + public List Timers { get; set; } = new List(); public List Annotations { get; set; } = new List(); diff --git a/Core/Resgrid.Model/IncidentCommand/IncidentCommandEnums.cs b/Core/Resgrid.Model/IncidentCommand/IncidentCommandEnums.cs index 51e816df..edf855a1 100644 --- a/Core/Resgrid.Model/IncidentCommand/IncidentCommandEnums.cs +++ b/Core/Resgrid.Model/IncidentCommand/IncidentCommandEnums.cs @@ -43,7 +43,28 @@ public enum TacticalObjectiveType public enum TacticalObjectiveStatus { Pending = 0, - Complete = 1 + Complete = 1, + InProgress = 2 + } + + /// Category of an incident need (resource/logistics request tracked at the command level). + public enum IncidentNeedCategory + { + Resource = 0, + Logistics = 1, + Medical = 2, + Equipment = 3, + Staffing = 4, + Other = 5 + } + + /// Fulfillment state of an incident need. + public enum IncidentNeedStatus + { + Open = 0, + PartiallyMet = 1, + Met = 2, + Cancelled = 3 } /// Type of incident timer (personnel PAR is handled by the Checkin feature, not these). @@ -121,6 +142,12 @@ public enum CommandLogEntryType ActionPlanUpdated = 27, CommandPostUpdated = 28, PublicSharingEnabled = 29, - PublicSharingDisabled = 30 + PublicSharingDisabled = 30, + NeedAdded = 31, + NeedUpdated = 32, + NeedMet = 33, + ObjectiveProgressUpdated = 34, + LaneLeadChanged = 35, + CommandDetailsUpdated = 36 } } diff --git a/Core/Resgrid.Model/IncidentCommand/IncidentNeed.cs b/Core/Resgrid.Model/IncidentCommand/IncidentNeed.cs new file mode 100644 index 00000000..e4fecdd6 --- /dev/null +++ b/Core/Resgrid.Model/IncidentCommand/IncidentNeed.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model +{ + /// + /// A command-level need for an incident (resources, logistics, medical support, ...) tracked to + /// fulfillment. Lanes can optionally be tied to a need via . + /// + public class IncidentNeed : IEntity, IChangeTracked + { + public string IncidentNeedId { get; set; } + + public string IncidentCommandId { get; set; } + + public int DepartmentId { get; set; } + + public int CallId { get; set; } + + public string Name { get; set; } + + /// Optional free-text detail (what exactly is needed, where, by when). + public string Description { get; set; } + + /// Maps to . + public int Category { get; set; } + + /// Maps to . + public int Status { get; set; } + + /// How many of the thing are needed (0 = unquantified). + public int QuantityRequested { get; set; } + + /// How many have been fulfilled so far. + public int QuantityFulfilled { get; set; } + + /// Relative priority for triage/ordering (0 = unset; higher = more urgent). + public int Priority { get; set; } + + public string CreatedByUserId { get; set; } + + public DateTime CreatedOn { get; set; } + + /// Set when the need transitions to Met. + public string MetByUserId { get; set; } + + /// Set when the need transitions to Met. + public DateTime? MetOn { get; set; } + + public int SortOrder { get; set; } + + /// Change cursor for offline delta sync + last-write-wins; stamped on every write. + public DateTime? ModifiedOn { get; set; } + + [NotMapped] + public string TableName => "IncidentNeeds"; + + [NotMapped] + public string IdName => "IncidentNeedId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return IncidentNeedId; } + set { IncidentNeedId = (string)value; } + } + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" }; + } +} diff --git a/Core/Resgrid.Model/IncidentCommand/IncidentTacticals.cs b/Core/Resgrid.Model/IncidentCommand/IncidentTacticals.cs index 6c70f361..72bd77ba 100644 --- a/Core/Resgrid.Model/IncidentCommand/IncidentTacticals.cs +++ b/Core/Resgrid.Model/IncidentCommand/IncidentTacticals.cs @@ -30,6 +30,18 @@ public class TacticalObjective : IEntity, IChangeTracked public DateTime? CompletedOn { get; set; } + /// Optional free-text detail describing the objective / how it will be met. + public string Description { get; set; } + + /// Progress toward completion, 0-100. Complete objectives are implicitly 100. + public int ProgressPercent { get; set; } + + /// Relative priority for triage/ordering (0 = unset; higher = more urgent). + public int Priority { get; set; } + + /// Optional target time this objective should be complete by. + public DateTime? TargetCompleteOn { get; set; } + public int SortOrder { get; set; } /// Change cursor for offline delta sync + last-write-wins; stamped on every write. diff --git a/Core/Resgrid.Model/IncidentCommand/ResourceIncidentView.cs b/Core/Resgrid.Model/IncidentCommand/ResourceIncidentView.cs new file mode 100644 index 00000000..368e0e15 --- /dev/null +++ b/Core/Resgrid.Model/IncidentCommand/ResourceIncidentView.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Model +{ + /// + /// Read-only incident view for a responder (user) or unit assigned to — or dispatched on — an incident: + /// who has command, incident timing, important information, objectives, needs, notes and attachment + /// metadata, plus the caller's own lane assignment (leads, lane objectives) when they have one. + /// Assembled by IIncidentCommandService.GetResourceIncidentViewAsync; notes/attachments are + /// visibility-filtered for callers without command capabilities. + /// + public class ResourceIncidentView + { + public string IncidentCommandId { get; set; } + + public int CallId { get; set; } + + /// Maps to . + public int Status { get; set; } + + public DateTime EstablishedOn { get; set; } + + public DateTime? EstimatedEndOn { get; set; } + + public DateTime? ClosedOn { get; set; } + + /// Important information every resource on the incident should see. + public string ImportantInformation { get; set; } + + public string IncidentActionPlan { get; set; } + + /// The current Incident Commander. + public IncidentContactInfo Commander { get; set; } + + public List Objectives { get; set; } = new List(); + + public List Needs { get; set; } = new List(); + + /// Operational status notes, visibility-filtered for the caller. + public List Notes { get; set; } = new List(); + + /// Attachment metadata (documents/maps/files/images), visibility-filtered; binary content downloads separately. + public List Attachments { get; set; } = new List(); + + /// The caller's active lane assignment, when they have one (null otherwise). + public ResourceLaneAssignmentView MyAssignment { get; set; } + } + + /// Contact card for a person relevant to a resource (commander or lane lead). + public class IncidentContactInfo + { + /// Set when this contact is a Resgrid user; null for external contacts. + public string UserId { get; set; } + + public string Name { get; set; } + + public string Phone { get; set; } + + public string Email { get; set; } + } + + /// A resource's own lane assignment: the lane, its leads, and its linked objectives/need. + public class ResourceLaneAssignmentView + { + public string ResourceAssignmentId { get; set; } + + public string CommandStructureNodeId { get; set; } + + public string LaneName { get; set; } + + /// Maps to . + public int NodeType { get; set; } + + public string Color { get; set; } + + public DateTime AssignedOn { get; set; } + + public IncidentContactInfo PrimaryLead { get; set; } + + public IncidentContactInfo SecondaryLead { get; set; } + + /// The lane's primary linked objective, resolved (null when not set). + public TacticalObjective PrimaryObjective { get; set; } + + /// The lane's secondary linked objective, resolved (null when not set). + public TacticalObjective SecondaryObjective { get; set; } + + /// The need this lane is fulfilling, resolved (null when not set). + public IncidentNeed LinkedNeed { get; set; } + } +} diff --git a/Core/Resgrid.Model/Repositories/IIncidentCommandRepositories.cs b/Core/Resgrid.Model/Repositories/IIncidentCommandRepositories.cs index aa3c6ce7..f09f9f63 100644 --- a/Core/Resgrid.Model/Repositories/IIncidentCommandRepositories.cs +++ b/Core/Resgrid.Model/Repositories/IIncidentCommandRepositories.cs @@ -17,6 +17,10 @@ public interface ITacticalObjectiveRepository : IRepository { } + public interface IIncidentNeedRepository : IRepository + { + } + public interface IIncidentTimerRepository : IRepository { } diff --git a/Core/Resgrid.Model/Services/IIncidentCommandNotificationService.cs b/Core/Resgrid.Model/Services/IIncidentCommandNotificationService.cs new file mode 100644 index 00000000..7e8e1857 --- /dev/null +++ b/Core/Resgrid.Model/Services/IIncidentCommandNotificationService.cs @@ -0,0 +1,34 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// Fans incident-command changes out to the affected Resgrid users and units over their configured + /// channels (push/email/SMS via ICommunicationService for users, push via IPushService + /// for units). Every method is best-effort: failures are logged and never propagate to the caller, + /// so a notification outage can't fail the underlying command mutation. + /// + public interface IIncidentCommandNotificationService + { + /// Notifies the assigned resource (user or unit) it was assigned to a lane (or tracked on the incident when the lane is empty). + Task NotifyResourceAssignedAsync(ResourceAssignment assignment, string laneName, CancellationToken cancellationToken = default(CancellationToken)); + + /// Notifies the moved resource (user or unit) it changed lanes. + Task NotifyResourceMovedAsync(ResourceAssignment assignment, string fromLaneName, string toLaneName, CancellationToken cancellationToken = default(CancellationToken)); + + /// Notifies the released resource (user or unit) it was released from the incident. + Task NotifyResourceReleasedAsync(ResourceAssignment assignment, CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// Notifies every active user and unit on the incident that a lane lead changed: who is going off + /// and who is coming on. Lead user ids resolve to profile names; external leads use the entered name. + /// + Task NotifyLaneLeadChangedAsync(int departmentId, int callId, string laneName, bool isPrimary, + string previousLeadUserId, string previousLeadName, string newLeadUserId, string newLeadName, + CancellationToken cancellationToken = default(CancellationToken)); + + /// Notifies every active user and unit on the incident (plus both commanders) that command was transferred. + Task NotifyCommandTransferredAsync(IncidentCommand command, string fromUserId, string toUserId, CancellationToken cancellationToken = default(CancellationToken)); + } +} diff --git a/Core/Resgrid.Model/Services/IIncidentCommandService.cs b/Core/Resgrid.Model/Services/IIncidentCommandService.cs index ecf2aca7..ec6bf99b 100644 --- a/Core/Resgrid.Model/Services/IIncidentCommandService.cs +++ b/Core/Resgrid.Model/Services/IIncidentCommandService.cs @@ -90,6 +90,32 @@ public interface IIncidentCommandService Task CompleteObjectiveAsync(int departmentId, string tacticalObjectiveId, string userId, CancellationToken cancellationToken = default(CancellationToken)); Task> GetObjectivesForCallAsync(int departmentId, int callId); + /// + /// Sets an objective's progress (0-100, clamped). Progress over 0 moves a Pending objective to + /// InProgress; 100 completes it (stamping CompletedBy/On) exactly as would. + /// + Task UpdateObjectiveProgressAsync(int departmentId, string tacticalObjectiveId, int progressPercent, string userId, CancellationToken cancellationToken = default(CancellationToken)); + + // Command-level needs (resources/logistics/etc.) + Task SaveNeedAsync(IncidentNeed need, string userId, CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// Transitions a need's fulfillment status (optionally updating the fulfilled quantity). Transitioning + /// to Met stamps MetBy/MetOn; leaving Met clears them. + /// + Task SetNeedStatusAsync(int departmentId, string incidentNeedId, IncidentNeedStatus status, int? quantityFulfilled, string userId, CancellationToken cancellationToken = default(CancellationToken)); + Task> GetNeedsForCallAsync(int departmentId, int callId); + + /// Updates command-level details every resource should see: estimated end and important information. + Task UpdateCommandDetailsAsync(int departmentId, string incidentCommandId, System.DateTime? estimatedEndOn, string importantInformation, string userId, CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// Read-only incident view for a responder (userId) or unit (unitId != null): commander contact, timing, + /// important information, objectives, needs, visibility-filtered notes/attachments, and the caller's own + /// lane assignment with leads and lane objectives. Null when the call has no incident command. + /// + Task GetResourceIncidentViewAsync(int departmentId, int callId, string userId, int? unitId, bool includePrivate); + // Timers (scene/benchmark/role) Task StartTimerAsync(IncidentTimer timer, string userId, CancellationToken cancellationToken = default(CancellationToken)); Task AcknowledgeTimerAsync(int departmentId, string incidentTimerId, string userId, CancellationToken cancellationToken = default(CancellationToken)); diff --git a/Core/Resgrid.Services/IncidentCommandNotificationService.cs b/Core/Resgrid.Services/IncidentCommandNotificationService.cs new file mode 100644 index 00000000..e375f14b --- /dev/null +++ b/Core/Resgrid.Services/IncidentCommandNotificationService.cs @@ -0,0 +1,236 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Messages; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + /// + /// Best-effort fan-out of incident-command changes to affected Resgrid users (multi-channel per their + /// profile preferences) and units (push). Mirrors the CallBroadcast recipient pattern but runs inline + /// with the mutation; every send is individually guarded so one bad recipient can't break the rest and + /// a notification outage can't fail the command action itself. + /// + public class IncidentCommandNotificationService : IIncidentCommandNotificationService + { + private readonly ICommunicationService _communicationService; + private readonly IPushService _pushService; + private readonly IDepartmentsService _departmentsService; + private readonly IDepartmentSettingsService _departmentSettingsService; + private readonly IUserProfileService _userProfileService; + private readonly IResourceAssignmentRepository _resourceAssignmentRepository; + + public IncidentCommandNotificationService( + ICommunicationService communicationService, + IPushService pushService, + IDepartmentsService departmentsService, + IDepartmentSettingsService departmentSettingsService, + IUserProfileService userProfileService, + IResourceAssignmentRepository resourceAssignmentRepository) + { + _communicationService = communicationService; + _pushService = pushService; + _departmentsService = departmentsService; + _departmentSettingsService = departmentSettingsService; + _userProfileService = userProfileService; + _resourceAssignmentRepository = resourceAssignmentRepository; + } + + public async Task NotifyResourceAssignedAsync(ResourceAssignment assignment, string laneName, CancellationToken cancellationToken = default(CancellationToken)) + { + if (assignment == null) + return; + + var body = string.IsNullOrWhiteSpace(laneName) + ? $"You have been added to incident #{assignment.CallId}." + : $"You have been assigned to '{laneName}' on incident #{assignment.CallId}."; + + await NotifyResourceAsync(assignment, "Incident Assignment", body, cancellationToken); + } + + public async Task NotifyResourceMovedAsync(ResourceAssignment assignment, string fromLaneName, string toLaneName, CancellationToken cancellationToken = default(CancellationToken)) + { + if (assignment == null) + return; + + var body = string.IsNullOrWhiteSpace(fromLaneName) + ? $"Your assignment on incident #{assignment.CallId} is now '{toLaneName}'." + : $"Your assignment on incident #{assignment.CallId} moved from '{fromLaneName}' to '{toLaneName}'."; + + await NotifyResourceAsync(assignment, "Assignment Changed", body, cancellationToken); + } + + public async Task NotifyResourceReleasedAsync(ResourceAssignment assignment, CancellationToken cancellationToken = default(CancellationToken)) + { + if (assignment == null) + return; + + await NotifyResourceAsync(assignment, "Released from Incident", + $"You have been released from incident #{assignment.CallId}.", cancellationToken); + } + + public async Task NotifyLaneLeadChangedAsync(int departmentId, int callId, string laneName, bool isPrimary, + string previousLeadUserId, string previousLeadName, string newLeadUserId, string newLeadName, + CancellationToken cancellationToken = default(CancellationToken)) + { + var slot = isPrimary ? "primary" : "secondary"; + var goingOff = await ResolveDisplayNameAsync(previousLeadUserId, previousLeadName); + var comingOn = await ResolveDisplayNameAsync(newLeadUserId, newLeadName); + + string body; + if (comingOn != null && goingOff != null) + body = $"'{laneName}' {slot} lead changed on incident #{callId}: {comingOn} is coming on, {goingOff} is going off."; + else if (comingOn != null) + body = $"{comingOn} is now the '{laneName}' {slot} lead on incident #{callId}."; + else if (goingOff != null) + body = $"{goingOff} is no longer the '{laneName}' {slot} lead on incident #{callId}."; + else + return; + + var extraUsers = new List(); + if (!string.IsNullOrWhiteSpace(newLeadUserId)) + extraUsers.Add(newLeadUserId); + if (!string.IsNullOrWhiteSpace(previousLeadUserId)) + extraUsers.Add(previousLeadUserId); + + await BroadcastToIncidentAsync(departmentId, callId, "Lane Lead Changed", body, extraUsers, cancellationToken); + } + + public async Task NotifyCommandTransferredAsync(IncidentCommand command, string fromUserId, string toUserId, CancellationToken cancellationToken = default(CancellationToken)) + { + if (command == null) + return; + + var from = await ResolveDisplayNameAsync(fromUserId, null) ?? "the previous commander"; + var to = await ResolveDisplayNameAsync(toUserId, null) ?? "a new commander"; + var body = $"Command of incident #{command.CallId} has been transferred from {from} to {to}."; + + await BroadcastToIncidentAsync(command.DepartmentId, command.CallId, "Command Transferred", body, + new List { fromUserId, toUserId }, cancellationToken); + } + + /// Routes one message to the resource behind an assignment: own-department users and units only. + private async Task NotifyResourceAsync(ResourceAssignment assignment, string title, string body, CancellationToken cancellationToken) + { + try + { + if (assignment.ResourceKind == (int)ResourceAssignmentKind.RealPersonnel && !string.IsNullOrWhiteSpace(assignment.ResourceId)) + { + await SendToUserAsync(assignment.ResourceId, assignment.DepartmentId, title, body); + } + else if (assignment.ResourceKind == (int)ResourceAssignmentKind.RealUnit && int.TryParse(assignment.ResourceId, out var unitId)) + { + await SendToUnitAsync(unitId, assignment.DepartmentId, assignment.CallId, title, body); + } + } + catch (Exception ex) + { + Resgrid.Framework.Logging.LogException(ex); + } + } + + /// + /// Notifies every active own-department user and unit assigned to the incident (deduped), plus any + /// explicitly-passed extra users (e.g. the incoming/outgoing lead or commander). + /// + private async Task BroadcastToIncidentAsync(int departmentId, int callId, string title, string body, List extraUserIds, CancellationToken cancellationToken) + { + try + { + var assignments = (await _resourceAssignmentRepository.GetAllByDepartmentIdAsync(departmentId)) ?? Enumerable.Empty(); + var active = assignments.Where(a => a.CallId == callId && a.ReleasedOn == null).ToList(); + + var userIds = active + .Where(a => a.ResourceKind == (int)ResourceAssignmentKind.RealPersonnel && !string.IsNullOrWhiteSpace(a.ResourceId)) + .Select(a => a.ResourceId) + .Concat(extraUserIds?.Where(u => !string.IsNullOrWhiteSpace(u)) ?? Enumerable.Empty()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + var unitIds = active + .Where(a => a.ResourceKind == (int)ResourceAssignmentKind.RealUnit) + .Select(a => int.TryParse(a.ResourceId, out var id) ? id : 0) + .Where(id => id > 0) + .Distinct() + .ToList(); + + foreach (var userId in userIds) + { + try + { + await SendToUserAsync(userId, departmentId, title, body); + } + catch (Exception ex) + { + Resgrid.Framework.Logging.LogException(ex); + } + } + + foreach (var unitId in unitIds) + { + try + { + await SendToUnitAsync(unitId, departmentId, callId, title, body); + } + catch (Exception ex) + { + Resgrid.Framework.Logging.LogException(ex); + } + } + } + catch (Exception ex) + { + Resgrid.Framework.Logging.LogException(ex); + } + } + + private async Task SendToUserAsync(string userId, int departmentId, string title, string body) + { + var department = await _departmentsService.GetDepartmentByIdAsync(departmentId, false); + var departmentNumber = await _departmentSettingsService.GetTextToCallNumberForDepartmentAsync(departmentId); + var profile = await _userProfileService.GetProfileByUserIdAsync(userId); + + await _communicationService.SendNotificationAsync(userId, departmentId, body, departmentNumber, department, title, profile); + } + + private async Task SendToUnitAsync(int unitId, int departmentId, int callId, string title, string body) + { + var department = await _departmentsService.GetDepartmentByIdAsync(departmentId, false); + + await _pushService.PushCallUnit(new StandardPushCall + { + CallId = callId, + Title = title, + SubTitle = body, + DepartmentId = departmentId, + DepartmentCode = department?.Code + }, unitId); + } + + /// A lead's display name: profile name for Resgrid users, the entered name for external leads. + private async Task ResolveDisplayNameAsync(string userId, string fallbackName) + { + if (!string.IsNullOrWhiteSpace(userId)) + { + try + { + var profile = await _userProfileService.GetProfileByUserIdAsync(userId); + if (profile != null) + return profile.FullName.AsFirstNameLastName; + } + catch (Exception ex) + { + Resgrid.Framework.Logging.LogException(ex); + } + return userId; + } + + return string.IsNullOrWhiteSpace(fallbackName) ? null : fallbackName; + } + } +} diff --git a/Core/Resgrid.Services/IncidentCommandService.cs b/Core/Resgrid.Services/IncidentCommandService.cs index 04c3a4e0..5f8d116a 100644 --- a/Core/Resgrid.Services/IncidentCommandService.cs +++ b/Core/Resgrid.Services/IncidentCommandService.cs @@ -42,6 +42,9 @@ public class IncidentCommandService : IIncidentCommandService private readonly IIncidentNoteRepository _incidentNoteRepository; private readonly IIncidentAttachmentRepository _incidentAttachmentRepository; private readonly IIncidentWeatherProvider _incidentWeatherProvider; + private readonly IIncidentNeedRepository _incidentNeedRepository; + private readonly IUserProfileService _userProfileService; + private readonly IIncidentCommandNotificationService _incidentCommandNotificationService; public IncidentCommandService( IIncidentCommandRepository incidentCommandRepository, @@ -63,7 +66,10 @@ public IncidentCommandService( IPersonnelRolesService personnelRolesService, IIncidentNoteRepository incidentNoteRepository, IIncidentAttachmentRepository incidentAttachmentRepository, - IIncidentWeatherProvider incidentWeatherProvider) + IIncidentWeatherProvider incidentWeatherProvider, + IIncidentNeedRepository incidentNeedRepository, + IUserProfileService userProfileService, + IIncidentCommandNotificationService incidentCommandNotificationService) { _incidentCommandRepository = incidentCommandRepository; _commandStructureNodeRepository = commandStructureNodeRepository; @@ -85,6 +91,9 @@ public IncidentCommandService( _incidentNoteRepository = incidentNoteRepository; _incidentAttachmentRepository = incidentAttachmentRepository; _incidentWeatherProvider = incidentWeatherProvider; + _incidentNeedRepository = incidentNeedRepository; + _userProfileService = userProfileService; + _incidentCommandNotificationService = incidentCommandNotificationService; } #region Command lifecycle @@ -419,6 +428,7 @@ public async Task GetCommandBoardAsync(int departmentId, i Nodes = await GetNodesForCallAsync(departmentId, callId), Assignments = (await GetAssignmentsForCallAsync(departmentId, callId)).Where(a => a.ReleasedOn == null).ToList(), Objectives = await GetObjectivesForCallAsync(departmentId, callId), + Needs = await GetNeedsForCallAsync(departmentId, callId), Timers = await GetActiveTimersForCallAsync(departmentId, callId), Annotations = await GetAnnotationsForCallAsync(departmentId, callId), Accountability = await GetAccountabilityForCallAsync(departmentId, callId), @@ -448,6 +458,7 @@ public async Task GetBundleForDepartmentAsync(int departm 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 needs = ToCallLookup(await _incidentNeedRepository.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); @@ -466,6 +477,7 @@ public async Task GetBundleForDepartmentAsync(int departm 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(), + Needs = needs[callId].Where(x => x.Status != (int)IncidentNeedStatus.Cancelled).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(), @@ -519,6 +531,10 @@ public async Task GetChangesSinceAsync(int departmentId, if (objectives != null) changes.Objectives = objectives.Where(Changed).ToList(); + var needs = await _incidentNeedRepository.GetAllByDepartmentIdAsync(departmentId); + if (needs != null) + changes.Needs = needs.Where(Changed).ToList(); + var timers = await _incidentTimerRepository.GetAllByDepartmentIdAsync(departmentId); if (timers != null) changes.Timers = timers.Where(Changed).ToList(); @@ -591,6 +607,8 @@ public async Task GetChangesSinceAsync(int departmentId, await WriteLogAsync(command.IncidentCommandId, command.DepartmentId, command.CallId, CommandLogEntryType.CommandTransferred, "Command transferred", fromUserId, cancellationToken); _eventAggregator.SendMessage(new CommandTransferredEvent { DepartmentId = command.DepartmentId, CallId = command.CallId, IncidentCommandId = incidentCommandId, FromUserId = fromUserId, ToUserId = toUserId }); + + await _incidentCommandNotificationService.NotifyCommandTransferredAsync(command, fromUserId, toUserId, cancellationToken); return transfer; } @@ -641,6 +659,176 @@ public async Task GetChangesSinceAsync(int departmentId, return command; } + public async Task UpdateCommandDetailsAsync(int departmentId, string incidentCommandId, DateTime? estimatedEndOn, string importantInformation, string userId, CancellationToken cancellationToken = default(CancellationToken)) + { + var command = await GetOwnedCommandAsync(incidentCommandId, departmentId); + if (command == null) + return null; + + command.EstimatedEndOn = estimatedEndOn; + command.ImportantInformation = TrimToLength(importantInformation, 8000); + command = await _incidentCommandRepository.SaveOrUpdateAsync(Touch(command), cancellationToken); + + await WriteLogAsync(command.IncidentCommandId, command.DepartmentId, command.CallId, CommandLogEntryType.CommandDetailsUpdated, "Command details updated", userId, cancellationToken); + _eventAggregator.SendMessage(new IncidentCommandDetailsUpdatedEvent + { + DepartmentId = command.DepartmentId, + CallId = command.CallId, + IncidentCommandId = command.IncidentCommandId, + UpdatedByUserId = userId + }); + return command; + } + + public async Task GetResourceIncidentViewAsync(int departmentId, int callId, string userId, int? unitId, bool includePrivate) + { + var command = await GetCommandForCallAsync(departmentId, callId); + if (command == null) + return null; + + var view = new ResourceIncidentView + { + IncidentCommandId = command.IncidentCommandId, + CallId = command.CallId, + Status = command.Status, + EstablishedOn = command.EstablishedOn, + EstimatedEndOn = command.EstimatedEndOn, + ClosedOn = command.ClosedOn, + ImportantInformation = command.ImportantInformation, + IncidentActionPlan = command.IncidentActionPlan, + Commander = await BuildUserContactAsync(command.CurrentCommanderUserId), + Objectives = await GetObjectivesForCallAsync(departmentId, callId), + Needs = await GetNeedsForCallAsync(departmentId, callId), + Notes = await GetNotesForCallAsync(departmentId, callId, publicOnly: !includePrivate), + Attachments = await GetAttachmentsForCallAsync(departmentId, callId, publicOnly: !includePrivate) + }; + + // The caller's own active lane assignment: a unit client resolves by unit id, a responder by user id. + var assignments = await GetAssignmentsForCallAsync(departmentId, callId); + var mine = unitId.HasValue + ? assignments.FirstOrDefault(a => a.ReleasedOn == null && a.ResourceKind == (int)Model.ResourceAssignmentKind.RealUnit && a.ResourceId == unitId.Value.ToString()) + : assignments.FirstOrDefault(a => a.ReleasedOn == null && a.ResourceKind == (int)Model.ResourceAssignmentKind.RealPersonnel && a.ResourceId == userId); + + if (mine != null && !string.IsNullOrWhiteSpace(mine.CommandStructureNodeId)) + { + var node = await _commandStructureNodeRepository.GetByIdAsync(mine.CommandStructureNodeId); + if (node != null && node.DepartmentId == departmentId && node.DeletedOn == null) + { + view.MyAssignment = new ResourceLaneAssignmentView + { + ResourceAssignmentId = mine.ResourceAssignmentId, + CommandStructureNodeId = node.CommandStructureNodeId, + LaneName = node.Name, + NodeType = node.NodeType, + Color = node.Color, + AssignedOn = mine.AssignedOn, + PrimaryLead = await BuildLeadContactAsync(node.PrimaryLeadUserId, node.PrimaryLeadName, node.PrimaryLeadPhone, node.PrimaryLeadEmail), + SecondaryLead = await BuildLeadContactAsync(node.SecondaryLeadUserId, node.SecondaryLeadName, node.SecondaryLeadPhone, node.SecondaryLeadEmail), + PrimaryObjective = view.Objectives.FirstOrDefault(o => o.TacticalObjectiveId == node.PrimaryObjectiveId), + SecondaryObjective = view.Objectives.FirstOrDefault(o => o.TacticalObjectiveId == node.SecondaryObjectiveId), + LinkedNeed = view.Needs.FirstOrDefault(n => n.IncidentNeedId == node.LinkedNeedId) + }; + } + } + + return view; + } + + /// Contact card for a Resgrid user (name from the profile; phone/email as the profile exposes them). + private async Task BuildUserContactAsync(string userId) + { + if (string.IsNullOrWhiteSpace(userId)) + return null; + + var contact = new IncidentContactInfo { UserId = userId, Name = userId }; + try + { + var profile = await _userProfileService.GetProfileByUserIdAsync(userId); + if (profile != null) + { + contact.Name = profile.FullName.AsFirstNameLastName; + contact.Phone = profile.MobileNumber; + contact.Email = profile.MembershipEmail; + } + } + catch (Exception ex) + { + Resgrid.Framework.Logging.LogException(ex); + } + return contact; + } + + /// Contact card for a lane lead: a Resgrid user (resolved) or an external contact (as entered). + private async Task BuildLeadContactAsync(string leadUserId, string leadName, string leadPhone, string leadEmail) + { + if (!string.IsNullOrWhiteSpace(leadUserId)) + return await BuildUserContactAsync(leadUserId); + + if (string.IsNullOrWhiteSpace(leadName) && string.IsNullOrWhiteSpace(leadPhone) && string.IsNullOrWhiteSpace(leadEmail)) + return null; + + return new IncidentContactInfo { Name = leadName, Phone = leadPhone, Email = leadEmail }; + } + + /// + /// Detects primary/secondary lead changes on a lane save and raises one + /// per changed slot (also logged to the timeline). New lanes only announce leads that were set at creation. + /// + private async Task PublishLeadChangesAsync(CommandStructureNode stored, CommandStructureNode saved, bool isNew, string userId, CancellationToken cancellationToken) + { + var slots = new[] + { + new + { + IsPrimary = true, + PrevUserId = isNew ? null : stored?.PrimaryLeadUserId, + PrevName = isNew ? null : stored?.PrimaryLeadName, + NewUserId = saved.PrimaryLeadUserId, + NewName = saved.PrimaryLeadName + }, + new + { + IsPrimary = false, + PrevUserId = isNew ? null : stored?.SecondaryLeadUserId, + PrevName = isNew ? null : stored?.SecondaryLeadName, + NewUserId = saved.SecondaryLeadUserId, + NewName = saved.SecondaryLeadName + } + }; + + foreach (var slot in slots) + { + var changed = !string.Equals(slot.PrevUserId ?? string.Empty, slot.NewUserId ?? string.Empty, StringComparison.OrdinalIgnoreCase) + || !string.Equals(slot.PrevName ?? string.Empty, slot.NewName ?? string.Empty, StringComparison.OrdinalIgnoreCase); + if (!changed) + continue; + + // A brand-new lane with empty lead slots has nothing to announce. + if (isNew && string.IsNullOrWhiteSpace(slot.NewUserId) && string.IsNullOrWhiteSpace(slot.NewName)) + continue; + + await WriteLogAsync(saved.IncidentCommandId, saved.DepartmentId, saved.CallId, CommandLogEntryType.LaneLeadChanged, + $"Lane '{saved.Name}' {(slot.IsPrimary ? "primary" : "secondary")} lead changed", userId, cancellationToken); + + _eventAggregator.SendMessage(new LaneLeadChangedEvent + { + DepartmentId = saved.DepartmentId, + CallId = saved.CallId, + IncidentCommandId = saved.IncidentCommandId, + CommandStructureNodeId = saved.CommandStructureNodeId, + LaneName = saved.Name, + IsPrimary = slot.IsPrimary, + PreviousLeadUserId = slot.PrevUserId, + PreviousLeadName = slot.PrevName, + NewLeadUserId = slot.NewUserId, + NewLeadName = slot.NewName + }); + + await _incidentCommandNotificationService.NotifyLaneLeadChangedAsync(saved.DepartmentId, saved.CallId, saved.Name, slot.IsPrimary, + slot.PrevUserId, slot.PrevName, slot.NewUserId, slot.NewName, cancellationToken); + } + } + public async Task AddNoteAsync(IncidentNote note, string userId, CancellationToken cancellationToken = default(CancellationToken)) { if (note == null || string.IsNullOrWhiteSpace(note.IncidentCommandId) || string.IsNullOrWhiteSpace(note.Body)) @@ -957,8 +1145,15 @@ public async Task GetPublicAttachmentAsync(string publicShar return null; node.CallId = command.CallId; + // Capture the stored lead slots inside the preserve callback so a lead change can be detected + // and broadcast after the upsert (assigned resources are notified who's coming on / going off). + CommandStructureNode storedLeads = null; var (saved, isNew, rejected) = await UpsertOwnedAsync(_commandStructureNodeRepository, node, node.DepartmentId, - e => e.DepartmentId, (stored, incoming) => incoming.DeletedOn = stored.DeletedOn, cancellationToken); + e => e.DepartmentId, (stored, incoming) => + { + incoming.DeletedOn = stored.DeletedOn; + storedLeads = stored; + }, cancellationToken); if (rejected) return null; node = saved; @@ -966,6 +1161,8 @@ public async Task GetPublicAttachmentAsync(string publicShar await WriteLogAsync(node.IncidentCommandId, node.DepartmentId, node.CallId, isNew ? CommandLogEntryType.NodeAdded : CommandLogEntryType.NodeUpdated, $"Lane '{node.Name}' {(isNew ? "added" : "updated")}", userId, cancellationToken); + + await PublishLeadChangesAsync(storedLeads, node, isNew, userId, cancellationToken); return node; } @@ -1011,6 +1208,7 @@ public async Task> GetNodesForCallAsync(int departmen // the assignment (RequirementsWarning) for the IC app to render, never blocking the assignment. assignment.RequirementsWarning = false; assignment.RequirementsWarningMessage = null; + string assignedLaneName = null; if (!string.IsNullOrWhiteSpace(assignment.CommandStructureNodeId)) { // The lane must live on the SAME incident (department + call) as this assignment; a lane from @@ -1018,6 +1216,7 @@ public async Task> GetNodesForCallAsync(int departmen var node = await _commandStructureNodeRepository.GetByIdAsync(assignment.CommandStructureNodeId); if (node != null && node.DepartmentId == assignment.DepartmentId && node.CallId == assignment.CallId) { + assignedLaneName = node.Name; var (violation, enforced) = await EvaluateNodeRequirementsAsync(node, assignment.DepartmentId, assignment.ResourceKind, assignment.ResourceId); if (violation != null && enforced) throw new CommandRequirementsNotMetException(violation); @@ -1045,6 +1244,8 @@ public async Task> GetNodesForCallAsync(int departmen await WriteLogAsync(assignment.IncidentCommandId, assignment.DepartmentId, assignment.CallId, CommandLogEntryType.ResourceAssigned, "Resource assigned", userId, cancellationToken); _eventAggregator.SendMessage(new IncidentResourceAssignedEvent { DepartmentId = assignment.DepartmentId, CallId = assignment.CallId, IncidentCommandId = assignment.IncidentCommandId, ResourceKind = assignment.ResourceKind, ResourceId = assignment.ResourceId }); + + await _incidentCommandNotificationService.NotifyResourceAssignedAsync(assignment, assignedLaneName, cancellationToken); return assignment; } @@ -1086,10 +1287,28 @@ public async Task> GetNodesForCallAsync(int departmen } } + var fromNodeId = assignment.CommandStructureNodeId; assignment.CommandStructureNodeId = targetNodeId; assignment = await _resourceAssignmentRepository.SaveOrUpdateAsync(Touch(assignment), cancellationToken); await WriteLogAsync(assignment.IncidentCommandId, assignment.DepartmentId, assignment.CallId, CommandLogEntryType.ResourceMoved, "Resource moved", userId, cancellationToken); + + _eventAggregator.SendMessage(new IncidentResourceMovedEvent + { + DepartmentId = assignment.DepartmentId, + CallId = assignment.CallId, + IncidentCommandId = assignment.IncidentCommandId, + ResourceAssignmentId = assignment.ResourceAssignmentId, + ResourceKind = assignment.ResourceKind, + ResourceId = assignment.ResourceId, + FromNodeId = fromNodeId, + ToNodeId = targetNodeId + }); + + string fromLaneName = null; + if (!string.IsNullOrWhiteSpace(fromNodeId) && fromNodeId != targetNodeId) + fromLaneName = (await _commandStructureNodeRepository.GetByIdAsync(fromNodeId))?.Name; + await _incidentCommandNotificationService.NotifyResourceMovedAsync(assignment, fromLaneName, targetNode.Name, cancellationToken); return assignment; } @@ -1105,6 +1324,8 @@ public async Task> GetNodesForCallAsync(int departmen await WriteLogAsync(assignment.IncidentCommandId, assignment.DepartmentId, assignment.CallId, CommandLogEntryType.ResourceReleased, "Resource released", userId, cancellationToken); _eventAggregator.SendMessage(new IncidentResourceReleasedEvent { DepartmentId = assignment.DepartmentId, CallId = assignment.CallId, ResourceAssignmentId = assignment.ResourceAssignmentId }); + + await _incidentCommandNotificationService.NotifyResourceReleasedAsync(assignment, cancellationToken); return true; } @@ -1235,10 +1456,12 @@ public async Task> GetAssignmentsForCallAsync(int depar var (saved, isNew, rejected) = await UpsertOwnedAsync(_tacticalObjectiveRepository, objective, objective.DepartmentId, e => e.DepartmentId, (stored, incoming) => { - // Completion is owned by CompleteObjectiveAsync; a Save (edit/replay) must not reset it. + // Completion and progress are owned by CompleteObjectiveAsync / UpdateObjectiveProgressAsync; + // a Save (edit/replay) must not reset them. incoming.Status = stored.Status; incoming.CompletedByUserId = stored.CompletedByUserId; incoming.CompletedOn = stored.CompletedOn; + incoming.ProgressPercent = stored.ProgressPercent; }, cancellationToken); if (rejected) return null; @@ -1257,6 +1480,7 @@ public async Task> GetAssignmentsForCallAsync(int depar return null; objective.Status = (int)TacticalObjectiveStatus.Complete; + objective.ProgressPercent = 100; objective.CompletedByUserId = userId; objective.CompletedOn = DateTime.UtcNow; objective = await _tacticalObjectiveRepository.SaveOrUpdateAsync(Touch(objective), cancellationToken); @@ -1276,8 +1500,134 @@ public async Task> GetObjectivesForCallAsync(int departm return items.Where(x => x.CallId == callId).OrderBy(x => x.SortOrder).ToList(); } + public async Task UpdateObjectiveProgressAsync(int departmentId, string tacticalObjectiveId, int progressPercent, string userId, CancellationToken cancellationToken = default(CancellationToken)) + { + var objective = await _tacticalObjectiveRepository.GetByIdAsync(tacticalObjectiveId); + if (objective == null || objective.DepartmentId != departmentId) + return null; + + progressPercent = Math.Max(0, Math.Min(100, progressPercent)); + + // 100% IS completion — route through the completion stamping so the timeline, events, and + // CompletedBy/On behave exactly as an explicit CompleteObjective would. + if (progressPercent == 100) + return await CompleteObjectiveAsync(departmentId, tacticalObjectiveId, userId, cancellationToken); + + objective.ProgressPercent = progressPercent; + if (objective.Status == (int)TacticalObjectiveStatus.Pending && progressPercent > 0) + objective.Status = (int)TacticalObjectiveStatus.InProgress; + else if (objective.Status == (int)TacticalObjectiveStatus.InProgress && progressPercent == 0) + objective.Status = (int)TacticalObjectiveStatus.Pending; + + objective = await _tacticalObjectiveRepository.SaveOrUpdateAsync(Touch(objective), cancellationToken); + + await WriteLogAsync(objective.IncidentCommandId, objective.DepartmentId, objective.CallId, CommandLogEntryType.ObjectiveProgressUpdated, + $"Objective '{objective.Name}' progress {objective.ProgressPercent}%", userId, cancellationToken); + + return objective; + } + #endregion Objectives + #region Needs + + public async Task SaveNeedAsync(IncidentNeed need, string userId, CancellationToken cancellationToken = default(CancellationToken)) + { + // The parent incident command must belong to the caller's department; stamp the authoritative + // CallId from it so this row can't be filed under a different call than its parent command. + var command = await GetOwnedCommandAsync(need.IncidentCommandId, need.DepartmentId); + if (command == null) + return null; + need.CallId = command.CallId; + + if (need.CreatedOn == default(DateTime)) + need.CreatedOn = DateTime.UtcNow; + if (string.IsNullOrWhiteSpace(need.CreatedByUserId)) + need.CreatedByUserId = userId; + + var (saved, isNew, rejected) = await UpsertOwnedAsync(_incidentNeedRepository, need, need.DepartmentId, + e => e.DepartmentId, (stored, incoming) => + { + // Fulfillment is owned by SetNeedStatusAsync; a Save (edit/replay) must not reset it. + incoming.Status = stored.Status; + incoming.QuantityFulfilled = stored.QuantityFulfilled; + incoming.MetByUserId = stored.MetByUserId; + incoming.MetOn = stored.MetOn; + incoming.CreatedByUserId = stored.CreatedByUserId; + incoming.CreatedOn = stored.CreatedOn; + }, cancellationToken); + if (rejected) + return null; + need = saved; + + await WriteLogAsync(need.IncidentCommandId, need.DepartmentId, need.CallId, + isNew ? CommandLogEntryType.NeedAdded : CommandLogEntryType.NeedUpdated, + $"Need '{need.Name}' {(isNew ? "added" : "updated")}", userId, cancellationToken); + + _eventAggregator.SendMessage(new IncidentNeedChangedEvent + { + DepartmentId = need.DepartmentId, + CallId = need.CallId, + IncidentCommandId = need.IncidentCommandId, + IncidentNeedId = need.IncidentNeedId, + Name = need.Name, + Status = need.Status + }); + return need; + } + + public async Task SetNeedStatusAsync(int departmentId, string incidentNeedId, IncidentNeedStatus status, int? quantityFulfilled, string userId, CancellationToken cancellationToken = default(CancellationToken)) + { + var need = await _incidentNeedRepository.GetByIdAsync(incidentNeedId); + if (need == null || need.DepartmentId != departmentId) + return null; + + need.Status = (int)status; + if (quantityFulfilled.HasValue) + need.QuantityFulfilled = Math.Max(0, quantityFulfilled.Value); + + if (status == IncidentNeedStatus.Met) + { + need.MetByUserId = userId; + need.MetOn = DateTime.UtcNow; + if (need.QuantityRequested > 0 && !quantityFulfilled.HasValue) + need.QuantityFulfilled = need.QuantityRequested; + } + else + { + need.MetByUserId = null; + need.MetOn = null; + } + + need = await _incidentNeedRepository.SaveOrUpdateAsync(Touch(need), cancellationToken); + + await WriteLogAsync(need.IncidentCommandId, need.DepartmentId, need.CallId, + status == IncidentNeedStatus.Met ? CommandLogEntryType.NeedMet : CommandLogEntryType.NeedUpdated, + $"Need '{need.Name}' {(status == IncidentNeedStatus.Met ? "met" : $"status changed to {status}")}", userId, cancellationToken); + + _eventAggregator.SendMessage(new IncidentNeedChangedEvent + { + DepartmentId = need.DepartmentId, + CallId = need.CallId, + IncidentCommandId = need.IncidentCommandId, + IncidentNeedId = need.IncidentNeedId, + Name = need.Name, + Status = need.Status + }); + return need; + } + + public async Task> GetNeedsForCallAsync(int departmentId, int callId) + { + var items = await _incidentNeedRepository.GetAllByDepartmentIdAsync(departmentId); + if (items == null) + return new List(); + + return items.Where(x => x.CallId == callId).OrderBy(x => x.SortOrder).ThenBy(x => x.CreatedOn).ToList(); + } + + #endregion Needs + #region Timers public async Task StartTimerAsync(IncidentTimer timer, string userId, CancellationToken cancellationToken = default(CancellationToken)) diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs index 229892c6..1f20f7da 100644 --- a/Core/Resgrid.Services/ServicesModule.cs +++ b/Core/Resgrid.Services/ServicesModule.cs @@ -17,6 +17,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/Providers/Resgrid.Providers.Migrations/Migrations/M0093_AddIncidentNeedsLaneLeadsAndObjectiveProgress.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0093_AddIncidentNeedsLaneLeadsAndObjectiveProgress.cs new file mode 100644 index 00000000..adcce971 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0093_AddIncidentNeedsLaneLeadsAndObjectiveProgress.cs @@ -0,0 +1,132 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Command-level incident needs (tracked to fulfillment), objective progress/priority/description, + /// lane links to objectives/needs, optional primary/secondary lane leads (Resgrid user or external + /// contact), and command-level estimated end / important information. + /// + [Migration(93)] + public class M0093_AddIncidentNeedsLaneLeadsAndObjectiveProgress : Migration + { + public override void Up() + { + if (Schema.Table("IncidentCommands").Exists()) + { + if (!Schema.Table("IncidentCommands").Column("EstimatedEndOn").Exists()) + Alter.Table("IncidentCommands").AddColumn("EstimatedEndOn").AsDateTime2().Nullable(); + + if (!Schema.Table("IncidentCommands").Column("ImportantInformation").Exists()) + Alter.Table("IncidentCommands").AddColumn("ImportantInformation").AsString(int.MaxValue).Nullable(); + } + + if (Schema.Table("TacticalObjectives").Exists()) + { + if (!Schema.Table("TacticalObjectives").Column("Description").Exists()) + Alter.Table("TacticalObjectives").AddColumn("Description").AsString(2000).Nullable(); + + if (!Schema.Table("TacticalObjectives").Column("ProgressPercent").Exists()) + Alter.Table("TacticalObjectives").AddColumn("ProgressPercent").AsInt32().NotNullable().WithDefaultValue(0); + + if (!Schema.Table("TacticalObjectives").Column("Priority").Exists()) + Alter.Table("TacticalObjectives").AddColumn("Priority").AsInt32().NotNullable().WithDefaultValue(0); + + if (!Schema.Table("TacticalObjectives").Column("TargetCompleteOn").Exists()) + Alter.Table("TacticalObjectives").AddColumn("TargetCompleteOn").AsDateTime2().Nullable(); + } + + if (Schema.Table("CommandStructureNodes").Exists()) + { + foreach (var column in new[] { "PrimaryObjectiveId", "SecondaryObjectiveId", "LinkedNeedId" }) + { + if (!Schema.Table("CommandStructureNodes").Column(column).Exists()) + Alter.Table("CommandStructureNodes").AddColumn(column).AsString(128).Nullable(); + } + + foreach (var column in new[] { "PrimaryLeadUserId", "SecondaryLeadUserId" }) + { + if (!Schema.Table("CommandStructureNodes").Column(column).Exists()) + Alter.Table("CommandStructureNodes").AddColumn(column).AsString(450).Nullable(); + } + + foreach (var column in new[] { "PrimaryLeadName", "PrimaryLeadEmail", "SecondaryLeadName", "SecondaryLeadEmail" }) + { + if (!Schema.Table("CommandStructureNodes").Column(column).Exists()) + Alter.Table("CommandStructureNodes").AddColumn(column).AsString(256).Nullable(); + } + + foreach (var column in new[] { "PrimaryLeadPhone", "SecondaryLeadPhone" }) + { + if (!Schema.Table("CommandStructureNodes").Column(column).Exists()) + Alter.Table("CommandStructureNodes").AddColumn(column).AsString(64).Nullable(); + } + } + + if (!Schema.Table("IncidentNeeds").Exists()) + { + Create.Table("IncidentNeeds") + .WithColumn("IncidentNeedId").AsString(128).NotNullable().PrimaryKey() + .WithColumn("IncidentCommandId").AsString(128).NotNullable() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("CallId").AsInt32().NotNullable() + .WithColumn("Name").AsString(500).NotNullable() + .WithColumn("Description").AsString(2000).Nullable() + .WithColumn("Category").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("Status").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("QuantityRequested").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("QuantityFulfilled").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("Priority").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("CreatedByUserId").AsString(450).Nullable() + .WithColumn("CreatedOn").AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime) + .WithColumn("MetByUserId").AsString(450).Nullable() + .WithColumn("MetOn").AsDateTime2().Nullable() + .WithColumn("SortOrder").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("ModifiedOn").AsDateTime2().Nullable(); + + Create.Index("IX_IncidentNeeds_Department_Call") + .OnTable("IncidentNeeds") + .OnColumn("DepartmentId").Ascending() + .OnColumn("CallId").Ascending(); + } + } + + public override void Down() + { + if (Schema.Table("IncidentNeeds").Exists()) + Delete.Table("IncidentNeeds"); + + if (Schema.Table("IncidentCommands").Exists()) + { + foreach (var column in new[] { "EstimatedEndOn", "ImportantInformation" }) + { + if (Schema.Table("IncidentCommands").Column(column).Exists()) + Delete.Column(column).FromTable("IncidentCommands"); + } + } + + if (Schema.Table("TacticalObjectives").Exists()) + { + foreach (var column in new[] { "Description", "ProgressPercent", "Priority", "TargetCompleteOn" }) + { + if (Schema.Table("TacticalObjectives").Column(column).Exists()) + Delete.Column(column).FromTable("TacticalObjectives"); + } + } + + if (Schema.Table("CommandStructureNodes").Exists()) + { + foreach (var column in new[] + { + "PrimaryObjectiveId", "SecondaryObjectiveId", "LinkedNeedId", + "PrimaryLeadUserId", "PrimaryLeadName", "PrimaryLeadPhone", "PrimaryLeadEmail", + "SecondaryLeadUserId", "SecondaryLeadName", "SecondaryLeadPhone", "SecondaryLeadEmail" + }) + { + if (Schema.Table("CommandStructureNodes").Column(column).Exists()) + Delete.Column(column).FromTable("CommandStructureNodes"); + } + } + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0093_AddIncidentNeedsLaneLeadsAndObjectiveProgressPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0093_AddIncidentNeedsLaneLeadsAndObjectiveProgressPg.cs new file mode 100644 index 00000000..6e6e89c1 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0093_AddIncidentNeedsLaneLeadsAndObjectiveProgressPg.cs @@ -0,0 +1,119 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Command-level incident needs (tracked to fulfillment), objective progress/priority/description, + /// lane links to objectives/needs, optional primary/secondary lane leads (Resgrid user or external + /// contact), and command-level estimated end / important information. + /// + [Migration(93)] + public class M0093_AddIncidentNeedsLaneLeadsAndObjectiveProgressPg : Migration + { + public override void Up() + { + if (Schema.Table("incidentcommands").Exists()) + { + if (!Schema.Table("incidentcommands").Column("estimatedendon").Exists()) + Alter.Table("incidentcommands").AddColumn("estimatedendon").AsDateTime2().Nullable(); + + if (!Schema.Table("incidentcommands").Column("importantinformation").Exists()) + Alter.Table("incidentcommands").AddColumn("importantinformation").AsCustom("citext").Nullable(); + } + + if (Schema.Table("tacticalobjectives").Exists()) + { + if (!Schema.Table("tacticalobjectives").Column("description").Exists()) + Alter.Table("tacticalobjectives").AddColumn("description").AsCustom("citext").Nullable(); + + if (!Schema.Table("tacticalobjectives").Column("progresspercent").Exists()) + Alter.Table("tacticalobjectives").AddColumn("progresspercent").AsInt32().NotNullable().WithDefaultValue(0); + + if (!Schema.Table("tacticalobjectives").Column("priority").Exists()) + Alter.Table("tacticalobjectives").AddColumn("priority").AsInt32().NotNullable().WithDefaultValue(0); + + if (!Schema.Table("tacticalobjectives").Column("targetcompleteon").Exists()) + Alter.Table("tacticalobjectives").AddColumn("targetcompleteon").AsDateTime2().Nullable(); + } + + if (Schema.Table("commandstructurenodes").Exists()) + { + foreach (var column in new[] + { + "primaryobjectiveid", "secondaryobjectiveid", "linkedneedid", + "primaryleaduserid", "primaryleadname", "primaryleadphone", "primaryleademail", + "secondaryleaduserid", "secondaryleadname", "secondaryleadphone", "secondaryleademail" + }) + { + if (!Schema.Table("commandstructurenodes").Column(column).Exists()) + Alter.Table("commandstructurenodes").AddColumn(column).AsCustom("citext").Nullable(); + } + } + + if (!Schema.Table("incidentneeds").Exists()) + { + Create.Table("incidentneeds") + .WithColumn("incidentneedid").AsCustom("citext").NotNullable().PrimaryKey() + .WithColumn("incidentcommandid").AsCustom("citext").NotNullable() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("callid").AsInt32().NotNullable() + .WithColumn("name").AsCustom("citext").NotNullable() + .WithColumn("description").AsCustom("citext").Nullable() + .WithColumn("category").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("status").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("quantityrequested").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("quantityfulfilled").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("priority").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("createdbyuserid").AsCustom("citext").Nullable() + .WithColumn("createdon").AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime) + .WithColumn("metbyuserid").AsCustom("citext").Nullable() + .WithColumn("meton").AsDateTime2().Nullable() + .WithColumn("sortorder").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("modifiedon").AsDateTime2().Nullable(); + + Create.Index("ix_incidentneeds_department_call") + .OnTable("incidentneeds") + .OnColumn("departmentid").Ascending() + .OnColumn("callid").Ascending(); + } + } + + public override void Down() + { + if (Schema.Table("incidentneeds").Exists()) + Delete.Table("incidentneeds"); + + if (Schema.Table("incidentcommands").Exists()) + { + foreach (var column in new[] { "estimatedendon", "importantinformation" }) + { + if (Schema.Table("incidentcommands").Column(column).Exists()) + Delete.Column(column).FromTable("incidentcommands"); + } + } + + if (Schema.Table("tacticalobjectives").Exists()) + { + foreach (var column in new[] { "description", "progresspercent", "priority", "targetcompleteon" }) + { + if (Schema.Table("tacticalobjectives").Column(column).Exists()) + Delete.Column(column).FromTable("tacticalobjectives"); + } + } + + if (Schema.Table("commandstructurenodes").Exists()) + { + foreach (var column in new[] + { + "primaryobjectiveid", "secondaryobjectiveid", "linkedneedid", + "primaryleaduserid", "primaryleadname", "primaryleadphone", "primaryleademail", + "secondaryleaduserid", "secondaryleadname", "secondaryleadphone", "secondaryleademail" + }) + { + if (Schema.Table("commandstructurenodes").Column(column).Exists()) + Delete.Column(column).FromTable("commandstructurenodes"); + } + } + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/IncidentCommandRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/IncidentCommandRepositories.cs index 827d0778..a0172b31 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/IncidentCommandRepositories.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/IncidentCommandRepositories.cs @@ -118,6 +118,14 @@ public CommandTransferRepository(IConnectionProvider connectionProvider, SqlConf } } + public class IncidentNeedRepository : RepositoryBase, IIncidentNeedRepository + { + public IncidentNeedRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + { + } + } + public class IncidentNoteRepository : RepositoryBase, IIncidentNoteRepository { public IncidentNoteRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs index 53172b8a..20e548d8 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs @@ -113,6 +113,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/Tests/Resgrid.Tests/Services/IncidentCommandContentServiceTests.cs b/Tests/Resgrid.Tests/Services/IncidentCommandContentServiceTests.cs index 7344a306..c070d47a 100644 --- a/Tests/Resgrid.Tests/Services/IncidentCommandContentServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/IncidentCommandContentServiceTests.cs @@ -68,7 +68,10 @@ public void SetUp() new Mock().Object, _noteRepository.Object, _attachmentRepository.Object, - _weatherProvider.Object); + _weatherProvider.Object, + new Mock().Object, + new Mock().Object, + new Mock().Object); } [Test] diff --git a/Tests/Resgrid.Tests/Services/IncidentCommandNeedsAndLeadsTests.cs b/Tests/Resgrid.Tests/Services/IncidentCommandNeedsAndLeadsTests.cs new file mode 100644 index 00000000..baf08b85 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/IncidentCommandNeedsAndLeadsTests.cs @@ -0,0 +1,372 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + /// + /// Incident needs, objective progress, lane leads (change detection + notification fan-out), command + /// details, and the resource-facing incident view. + /// + [TestFixture] + public class IncidentCommandNeedsAndLeadsTests + { + private const int Dept = 44; + private const int CallId = 900; + private const string CommandId = "ic-1"; + + private Mock _commandRepo; + private Mock _nodeRepo; + private Mock _assignmentRepo; + private Mock _objectiveRepo; + private Mock _logRepo; + private Mock _needRepo; + private Mock _noteRepo; + private Mock _attachmentRepo; + private Mock _userProfileService; + private Mock _notificationService; + private Mock _eventAggregator; + private IncidentCommandService _service; + + [SetUp] + public void SetUp() + { + _commandRepo = new Mock(); + _nodeRepo = new Mock(); + _assignmentRepo = new Mock(); + _objectiveRepo = new Mock(); + _logRepo = new Mock(); + _needRepo = new Mock(); + _noteRepo = new Mock(); + _attachmentRepo = new Mock(); + _userProfileService = new Mock(); + _notificationService = new Mock(); + _eventAggregator = new Mock(); + + _logRepo.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((CommandLogEntry e, CancellationToken ct, bool b) => e); + _needRepo.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((IncidentNeed e, CancellationToken ct, bool b) => e); + _needRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((IncidentNeed e, CancellationToken ct, bool b) => e); + _nodeRepo.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((CommandStructureNode e, CancellationToken ct, bool b) => e); + _nodeRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((CommandStructureNode e, CancellationToken ct, bool b) => e); + _objectiveRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((TacticalObjective e, CancellationToken ct, bool b) => e); + + _commandRepo.Setup(x => x.GetByIdAsync(CommandId)) + .ReturnsAsync(OwnedCommand()); + _commandRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((IncidentCommand e, CancellationToken ct, bool b) => e); + + _service = new IncidentCommandService(_commandRepo.Object, _nodeRepo.Object, _assignmentRepo.Object, + _objectiveRepo.Object, new Mock().Object, new Mock().Object, + _logRepo.Object, new Mock().Object, + new Mock().Object, new Mock().Object, new Mock().Object, + new Mock().Object, new Mock().Object, + _eventAggregator.Object, new Mock().Object, + new Mock().Object, new Mock().Object, _noteRepo.Object, + _attachmentRepo.Object, new Mock().Object, + _needRepo.Object, _userProfileService.Object, _notificationService.Object); + } + + private static IncidentCommand OwnedCommand() => new IncidentCommand + { + IncidentCommandId = CommandId, + DepartmentId = Dept, + CallId = CallId, + Status = (int)IncidentCommandStatus.Active, + EstablishedOn = DateTime.UtcNow.AddHours(-1), + CurrentCommanderUserId = "cmdr-1", + EstimatedEndOn = DateTime.UtcNow.AddHours(3), + ImportantInformation = "Watch the north wall" + }; + + [Test] + public async Task SaveNeed_New_StampsOwnershipWritesLogAndRaisesEvent() + { + var need = new IncidentNeed { IncidentCommandId = CommandId, DepartmentId = Dept, Name = "Fuel truck", QuantityRequested = 2 }; + + var saved = await _service.SaveNeedAsync(need, "user-1"); + + saved.Should().NotBeNull(); + saved.CallId.Should().Be(CallId, "the authoritative CallId comes from the parent command"); + saved.CreatedByUserId.Should().Be("user-1"); + saved.IncidentNeedId.Should().NotBeNullOrEmpty(); + saved.ModifiedOn.Should().NotBeNull(); + + _logRepo.Verify(x => x.InsertAsync(It.Is(e => e.EntryType == (int)CommandLogEntryType.NeedAdded), It.IsAny(), It.IsAny()), Times.Once); + _eventAggregator.Verify(x => x.SendMessage(It.IsAny()), Times.Once); + } + + [Test] + public async Task SetNeedStatus_Met_StampsMetByAndDefaultsFulfilledQuantity() + { + _needRepo.Setup(x => x.GetByIdAsync("need-1")).ReturnsAsync(new IncidentNeed + { + IncidentNeedId = "need-1", + IncidentCommandId = CommandId, + DepartmentId = Dept, + CallId = CallId, + Name = "Fuel truck", + QuantityRequested = 2, + Status = (int)IncidentNeedStatus.Open + }); + + var met = await _service.SetNeedStatusAsync(Dept, "need-1", IncidentNeedStatus.Met, null, "user-2"); + + met.Status.Should().Be((int)IncidentNeedStatus.Met); + met.MetByUserId.Should().Be("user-2"); + met.MetOn.Should().NotBeNull(); + met.QuantityFulfilled.Should().Be(2, "meeting an unquantified transition fills the requested quantity"); + + _logRepo.Verify(x => x.InsertAsync(It.Is(e => e.EntryType == (int)CommandLogEntryType.NeedMet), It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task SetNeedStatus_ForeignDepartment_ReturnsNull() + { + _needRepo.Setup(x => x.GetByIdAsync("need-1")).ReturnsAsync(new IncidentNeed { IncidentNeedId = "need-1", DepartmentId = Dept + 1 }); + + var result = await _service.SetNeedStatusAsync(Dept, "need-1", IncidentNeedStatus.Met, null, "user-2"); + + result.Should().BeNull(); + } + + [Test] + public async Task UpdateObjectiveProgress_PartialProgress_MovesPendingToInProgress() + { + _objectiveRepo.Setup(x => x.GetByIdAsync("obj-1")).ReturnsAsync(new TacticalObjective + { + TacticalObjectiveId = "obj-1", + IncidentCommandId = CommandId, + DepartmentId = Dept, + CallId = CallId, + Name = "Primary search", + Status = (int)TacticalObjectiveStatus.Pending + }); + + var updated = await _service.UpdateObjectiveProgressAsync(Dept, "obj-1", 40, "user-1"); + + updated.ProgressPercent.Should().Be(40); + updated.Status.Should().Be((int)TacticalObjectiveStatus.InProgress); + _logRepo.Verify(x => x.InsertAsync(It.Is(e => e.EntryType == (int)CommandLogEntryType.ObjectiveProgressUpdated), It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task UpdateObjectiveProgress_OneHundredPercent_CompletesTheObjective() + { + _objectiveRepo.Setup(x => x.GetByIdAsync("obj-1")).ReturnsAsync(new TacticalObjective + { + TacticalObjectiveId = "obj-1", + IncidentCommandId = CommandId, + DepartmentId = Dept, + CallId = CallId, + Name = "Primary search", + Status = (int)TacticalObjectiveStatus.InProgress, + ProgressPercent = 80 + }); + + var updated = await _service.UpdateObjectiveProgressAsync(Dept, "obj-1", 150, "user-1"); + + updated.Status.Should().Be((int)TacticalObjectiveStatus.Complete, "progress is clamped to 100 which completes"); + updated.ProgressPercent.Should().Be(100); + updated.CompletedByUserId.Should().Be("user-1"); + updated.CompletedOn.Should().NotBeNull(); + } + + [Test] + public async Task SaveNode_LeadChange_NotifiesIncidentAndWritesTimeline() + { + var stored = new CommandStructureNode + { + CommandStructureNodeId = "node-1", + IncidentCommandId = CommandId, + DepartmentId = Dept, + CallId = CallId, + Name = "Medical", + PrimaryLeadUserId = "old-lead" + }; + _nodeRepo.Setup(x => x.GetByIdAsync("node-1")).ReturnsAsync(stored); + + var incoming = new CommandStructureNode + { + CommandStructureNodeId = "node-1", + IncidentCommandId = CommandId, + DepartmentId = Dept, + CallId = CallId, + Name = "Medical", + PrimaryLeadUserId = "new-lead" + }; + + var saved = await _service.SaveNodeAsync(incoming, "user-1"); + + saved.Should().NotBeNull(); + _notificationService.Verify(x => x.NotifyLaneLeadChangedAsync(Dept, CallId, "Medical", true, + "old-lead", null, "new-lead", null, It.IsAny()), Times.Once); + _notificationService.Verify(x => x.NotifyLaneLeadChangedAsync(It.IsAny(), It.IsAny(), It.IsAny(), false, + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _logRepo.Verify(x => x.InsertAsync(It.Is(e => e.EntryType == (int)CommandLogEntryType.LaneLeadChanged), It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task SaveNode_NoLeadChange_DoesNotNotify() + { + var stored = new CommandStructureNode + { + CommandStructureNodeId = "node-1", + IncidentCommandId = CommandId, + DepartmentId = Dept, + CallId = CallId, + Name = "Medical", + PrimaryLeadUserId = "lead-1", + SecondaryLeadName = "Jane External" + }; + _nodeRepo.Setup(x => x.GetByIdAsync("node-1")).ReturnsAsync(stored); + + var incoming = new CommandStructureNode + { + CommandStructureNodeId = "node-1", + IncidentCommandId = CommandId, + DepartmentId = Dept, + CallId = CallId, + Name = "Medical", + PrimaryLeadUserId = "lead-1", + SecondaryLeadName = "Jane External" + }; + + await _service.SaveNodeAsync(incoming, "user-1"); + + _notificationService.Verify(x => x.NotifyLaneLeadChangedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task GetResourceIncidentView_ForAssignedUser_ResolvesLaneLeadsAndObjectives() + { + var objective = new TacticalObjective { TacticalObjectiveId = "obj-1", DepartmentId = Dept, CallId = CallId, Name = "Primary search", ProgressPercent = 25 }; + var need = new IncidentNeed { IncidentNeedId = "need-1", DepartmentId = Dept, CallId = CallId, Name = "Fuel truck" }; + var node = new CommandStructureNode + { + CommandStructureNodeId = "node-1", + IncidentCommandId = CommandId, + DepartmentId = Dept, + CallId = CallId, + Name = "Medical", + NodeType = (int)CommandNodeType.Group, + Color = "#3498db", + PrimaryObjectiveId = "obj-1", + LinkedNeedId = "need-1", + PrimaryLeadUserId = "lead-1", + SecondaryLeadName = "Jane External", + SecondaryLeadPhone = "555-0100" + }; + + _commandRepo.Setup(x => x.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List { OwnedCommand() }); + _objectiveRepo.Setup(x => x.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List { objective }); + _needRepo.Setup(x => x.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List { need }); + _noteRepo.Setup(x => x.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List()); + _attachmentRepo.Setup(x => x.GetAllMetadataByDepartmentIdAsync(Dept)).ReturnsAsync(new List()); + _assignmentRepo.Setup(x => x.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List + { + new ResourceAssignment + { + ResourceAssignmentId = "ra-1", + IncidentCommandId = CommandId, + DepartmentId = Dept, + CallId = CallId, + CommandStructureNodeId = "node-1", + ResourceKind = (int)ResourceAssignmentKind.RealPersonnel, + ResourceId = "user-9", + AssignedOn = DateTime.UtcNow.AddMinutes(-10) + } + }); + _nodeRepo.Setup(x => x.GetByIdAsync("node-1")).ReturnsAsync(node); + _userProfileService.Setup(x => x.GetProfileByUserIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((string userId, bool bypass) => new UserProfile { UserId = userId, FirstName = "First", LastName = userId, MobileNumber = "555-0199" }); + + var view = await _service.GetResourceIncidentViewAsync(Dept, CallId, "user-9", null, includePrivate: false); + + view.Should().NotBeNull(); + view.Commander.Should().NotBeNull(); + view.Commander.UserId.Should().Be("cmdr-1"); + view.ImportantInformation.Should().Be("Watch the north wall"); + view.EstimatedEndOn.Should().NotBeNull(); + view.Objectives.Should().ContainSingle(o => o.TacticalObjectiveId == "obj-1"); + view.Needs.Should().ContainSingle(n => n.IncidentNeedId == "need-1"); + + view.MyAssignment.Should().NotBeNull(); + view.MyAssignment.LaneName.Should().Be("Medical"); + view.MyAssignment.PrimaryLead.Should().NotBeNull(); + view.MyAssignment.PrimaryLead.UserId.Should().Be("lead-1"); + view.MyAssignment.SecondaryLead.Should().NotBeNull(); + view.MyAssignment.SecondaryLead.Name.Should().Be("Jane External"); + view.MyAssignment.SecondaryLead.Phone.Should().Be("555-0100"); + view.MyAssignment.PrimaryObjective.Should().NotBeNull(); + view.MyAssignment.PrimaryObjective.TacticalObjectiveId.Should().Be("obj-1"); + view.MyAssignment.LinkedNeed.Should().NotBeNull(); + view.MyAssignment.LinkedNeed.IncidentNeedId.Should().Be("need-1"); + } + + [Test] + public async Task GetResourceIncidentView_ForUnit_ResolvesUnitAssignment() + { + _commandRepo.Setup(x => x.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List { OwnedCommand() }); + _objectiveRepo.Setup(x => x.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List()); + _needRepo.Setup(x => x.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List()); + _noteRepo.Setup(x => x.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List()); + _attachmentRepo.Setup(x => x.GetAllMetadataByDepartmentIdAsync(Dept)).ReturnsAsync(new List()); + _assignmentRepo.Setup(x => x.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List + { + new ResourceAssignment + { + ResourceAssignmentId = "ra-2", + IncidentCommandId = CommandId, + DepartmentId = Dept, + CallId = CallId, + CommandStructureNodeId = "node-1", + ResourceKind = (int)ResourceAssignmentKind.RealUnit, + ResourceId = "77", + AssignedOn = DateTime.UtcNow + } + }); + _nodeRepo.Setup(x => x.GetByIdAsync("node-1")).ReturnsAsync(new CommandStructureNode + { + CommandStructureNodeId = "node-1", + IncidentCommandId = CommandId, + DepartmentId = Dept, + CallId = CallId, + Name = "Staging" + }); + + var view = await _service.GetResourceIncidentViewAsync(Dept, CallId, "someone-else", 77, includePrivate: false); + + view.MyAssignment.Should().NotBeNull(); + view.MyAssignment.LaneName.Should().Be("Staging"); + } + + [Test] + public async Task UpdateCommandDetails_StampsFieldsAndWritesTimeline() + { + var estimatedEnd = DateTime.UtcNow.AddHours(6); + + var updated = await _service.UpdateCommandDetailsAsync(Dept, CommandId, estimatedEnd, "Stage on the east side", "user-1"); + + updated.Should().NotBeNull(); + updated.EstimatedEndOn.Should().Be(estimatedEnd); + updated.ImportantInformation.Should().Be("Stage on the east side"); + _logRepo.Verify(x => x.InsertAsync(It.Is(e => e.EntryType == (int)CommandLogEntryType.CommandDetailsUpdated), It.IsAny(), It.IsAny()), Times.Once); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/IncidentCommandServiceParTests.cs b/Tests/Resgrid.Tests/Services/IncidentCommandServiceParTests.cs index f0df8f12..70252b59 100644 --- a/Tests/Resgrid.Tests/Services/IncidentCommandServiceParTests.cs +++ b/Tests/Resgrid.Tests/Services/IncidentCommandServiceParTests.cs @@ -46,6 +46,9 @@ public class IncidentCommandServiceParTests private Mock _noteRepo; private Mock _attachmentRepo; private Mock _weatherProvider; + private Mock _needRepo; + private Mock _userProfileService; + private Mock _commandNotificationService; private IncidentCommandService _service; [SetUp] @@ -71,6 +74,9 @@ public void SetUp() _noteRepo = new Mock(); _attachmentRepo = new Mock(); _weatherProvider = new Mock(); + _needRepo = new Mock(); + _userProfileService = new Mock(); + _commandNotificationService = new Mock(); // Timeline entries are append-only inserts; echo back the entry so WriteLogAsync resolves a non-null result. _logRepo.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) @@ -80,7 +86,8 @@ public void SetUp() _objectiveRepo.Object, _timerRepo.Object, _annotationRepo.Object, _logRepo.Object, _transferRepo.Object, _commandsService.Object, _callsService.Object, _checkInTimerService.Object, _voiceService.Object, _roleRepo.Object, _eventAggregator.Object, _coreEventService.Object, - _unitsService.Object, _personnelRolesService.Object, _noteRepo.Object, _attachmentRepo.Object, _weatherProvider.Object); + _unitsService.Object, _personnelRolesService.Object, _noteRepo.Object, _attachmentRepo.Object, _weatherProvider.Object, + _needRepo.Object, _userProfileService.Object, _commandNotificationService.Object); } private void ArrangeCall(bool checkInTimersEnabled = true, int departmentId = Dept) diff --git a/Web/Resgrid.Web.Services/Controllers/v4/IncidentCommandController.cs b/Web/Resgrid.Web.Services/Controllers/v4/IncidentCommandController.cs index 69c558eb..7a4caba5 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/IncidentCommandController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/IncidentCommandController.cs @@ -195,6 +195,67 @@ public IncidentCommandController(IIncidentCommandService incidentCommandService) } } + /// Updates command-level details every resource should see: estimated end and important information. + [HttpPut("UpdateCommandDetails")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_Update)] + [RequiresIncidentCapability(IncidentCapabilities.ManageCommand)] + public async Task> UpdateCommandDetails([FromBody] ICModels.UpdateCommandDetailsInput input) + { + if (input == null || string.IsNullOrWhiteSpace(input.IncidentCommandId)) + return BadRequest(); + + var command = await _incidentCommandService.UpdateCommandDetailsAsync(DepartmentId, input.IncidentCommandId, input.EstimatedEndOn, input.ImportantInformation, UserId, CancellationToken.None); + var result = new ICModels.IncidentCommandResult + { + Data = command, + PageSize = command == null ? 0 : 1, + Status = command == null ? ResponseHelper.NotFound : ResponseHelper.Success + }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// + /// Read-only incident view for the calling responder (or, with unitId, a unit client): commander + /// contact, timing, important information, objectives, needs, notes and attachments (visibility- + /// filtered), plus the caller's own lane assignment with leads and lane objectives. Gated by the + /// Call resource claim — every dispatched responder/unit can read it, not only command staff. + /// + [HttpGet("GetResourceIncidentView/{callId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Call_View)] + public async Task> GetResourceIncidentView(int callId, [FromQuery] int? unitId = null) + { + var result = new ICModels.ResourceIncidentViewResult(); + + // Command staff (any incident capability) also see command-only notes/attachments here. + var includePrivate = false; + try + { + includePrivate = await _incidentCommandService.GetCapabilitiesForUserAsync(DepartmentId, callId, UserId) != IncidentCapabilities.None; + } + catch (Exception ex) + { + Resgrid.Framework.Logging.LogException(ex); + } + + var view = await _incidentCommandService.GetResourceIncidentViewAsync(DepartmentId, callId, UserId, unitId, includePrivate); + + if (view == null) + { + result.Status = ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + result.Data = view; + result.PageSize = 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + #region Notes and documents /// Adds an internal or public operational status note to the incident. @@ -599,6 +660,32 @@ public async Task DownloadAttachment(string incidentAttachmentId) return result; } + /// Sets a tactical objective's progress (0-100; 100 completes it). + [HttpPost("UpdateObjectiveProgress")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_Update)] + public async Task> UpdateObjectiveProgress([FromBody] ICModels.UpdateObjectiveProgressInput input) + { + if (input == null || string.IsNullOrWhiteSpace(input.TacticalObjectiveId)) + return BadRequest(); + + var result = new ICModels.TacticalObjectiveResult(); + var objective = await _incidentCommandService.UpdateObjectiveProgressAsync(DepartmentId, input.TacticalObjectiveId, input.ProgressPercent, UserId, CancellationToken.None); + + if (objective == null) + { + result.Status = ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + result.Data = objective; + result.PageSize = 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + /// Marks a tactical objective complete. [HttpPost("CompleteObjective/{tacticalObjectiveId}")] [ProducesResponseType(StatusCodes.Status200OK)] @@ -624,6 +711,80 @@ public async Task DownloadAttachment(string incidentAttachmentId) #endregion Objectives + #region Needs + + /// Creates or updates a command-level incident need (resources/logistics/etc.). + [HttpPost("SaveNeed")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_Update)] + [RequiresIncidentCapability(IncidentCapabilities.ManageObjectives)] + public async Task> SaveNeed([FromBody] IncidentNeed need) + { + if (need == null || string.IsNullOrWhiteSpace(need.IncidentCommandId) || string.IsNullOrWhiteSpace(need.Name)) + return BadRequest(); + + need.DepartmentId = DepartmentId; + + var result = new ICModels.IncidentNeedResult(); + var saved = await _incidentCommandService.SaveNeedAsync(need, UserId, CancellationToken.None); + + if (saved == null) + { + // Parent incident command not found / not owned by the caller's department. + result.Status = ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + result.Data = saved; + result.PageSize = 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// Transitions an incident need's fulfillment status (Open/PartiallyMet/Met/Cancelled). + [HttpPost("SetNeedStatus")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_Update)] + public async Task> SetNeedStatus([FromBody] ICModels.SetNeedStatusInput input) + { + if (input == null || string.IsNullOrWhiteSpace(input.IncidentNeedId) || !Enum.IsDefined(typeof(IncidentNeedStatus), input.Status)) + return BadRequest(); + + var result = new ICModels.IncidentNeedResult(); + var need = await _incidentCommandService.SetNeedStatusAsync(DepartmentId, input.IncidentNeedId, (IncidentNeedStatus)input.Status, input.QuantityFulfilled, UserId, CancellationToken.None); + + if (need == null) + { + result.Status = ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + result.Data = need; + result.PageSize = 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// Gets the command-level needs for a call. + [HttpGet("GetNeeds/{callId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_View)] + public async Task> GetNeeds(int callId) + { + var result = new ICModels.IncidentNeedsResult(); + result.Data = await _incidentCommandService.GetNeedsForCallAsync(DepartmentId, callId); + result.PageSize = result.Data.Count; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + #endregion Needs + #region Timers /// Starts a scene/benchmark/role timer. diff --git a/Web/Resgrid.Web.Services/Models/v4/IncidentCommand/IncidentCommandModels.cs b/Web/Resgrid.Web.Services/Models/v4/IncidentCommand/IncidentCommandModels.cs index 4494cd99..b28ac1ac 100644 --- a/Web/Resgrid.Web.Services/Models/v4/IncidentCommand/IncidentCommandModels.cs +++ b/Web/Resgrid.Web.Services/Models/v4/IncidentCommand/IncidentCommandModels.cs @@ -57,6 +57,30 @@ public class MoveResourceInput public string TargetNodeId { get; set; } } + /// Input to update command-level details every resource should see. + public class UpdateCommandDetailsInput + { + public string IncidentCommandId { get; set; } + public System.DateTime? EstimatedEndOn { get; set; } + public string ImportantInformation { get; set; } + } + + /// Input to set an objective's progress percentage (0-100; 100 completes it). + public class UpdateObjectiveProgressInput + { + public string TacticalObjectiveId { get; set; } + public int ProgressPercent { get; set; } + } + + /// Input to transition an incident need's fulfillment status. + public class SetNeedStatusInput + { + public string IncidentNeedId { get; set; } + /// Maps to Resgrid.Model.IncidentNeedStatus. + public int Status { get; set; } + public int? QuantityFulfilled { get; set; } + } + public class IncidentCommandResult : StandardApiResponseV4Base { public Resgrid.Model.IncidentCommand Data { get; set; } @@ -94,6 +118,22 @@ public class TacticalObjectiveResult : StandardApiResponseV4Base public Resgrid.Model.TacticalObjective Data { get; set; } } + public class IncidentNeedResult : StandardApiResponseV4Base + { + public Resgrid.Model.IncidentNeed Data { get; set; } + } + + public class IncidentNeedsResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + } + + /// Read-only incident view for a responder or unit: commander, timing, objectives, needs, notes, attachments, own lane assignment. + public class ResourceIncidentViewResult : StandardApiResponseV4Base + { + public Resgrid.Model.ResourceIncidentView Data { get; set; } + } + public class IncidentTimerResult : StandardApiResponseV4Base { public Resgrid.Model.IncidentTimer Data { get; set; } diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index dcfc1809..7af086cd 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -1031,6 +1031,17 @@ Updates the command-post location used by maps and incident weather. + + Updates command-level details every resource should see: estimated end and important information. + + + + Read-only incident view for the calling responder (or, with unitId, a unit client): commander + contact, timing, important information, objectives, needs, notes and attachments (visibility- + filtered), plus the caller's own lane assignment with leads and lane objectives. Gated by the + Call resource claim — every dispatched responder/unit can read it, not only command staff. + + Adds an internal or public operational status note to the incident. @@ -1065,9 +1076,21 @@ Creates or updates a tactical objective / benchmark. + + Sets a tactical objective's progress (0-100; 100 completes it). + Marks a tactical objective complete. + + Creates or updates a command-level incident need (resources/logistics/etc.). + + + Transitions an incident need's fulfillment status (Open/PartiallyMet/Met/Cancelled). + + + Gets the command-level needs for a call. + Starts a scene/benchmark/role timer. @@ -3951,6 +3974,52 @@ Is the user a group admin + + + UserId (GUID/UUID) of the User to set. This field will be ignored if the input is used on a + function that is setting status for the current user. + + + + + The state/staffing level of the user to set for the user. + + + + + Note for the staffing level + + + + + The result object for a state/staffing level request. + + + + + The UserId GUID/UUID for the user state/staffing level being return + + + + + The full name of the user for the state/staffing level being returned + + + + + The current staffing level (state) type for the user + + + + + The timestamp of the last state/staffing level. This is converted UTC to the departments, or users, TimeZone. + + + + + Staffing note for the User's staffing + + Input data to add a staffing schedule in the Resgrid system @@ -4056,52 +4125,6 @@ Note for this staffing schedule - - - UserId (GUID/UUID) of the User to set. This field will be ignored if the input is used on a - function that is setting status for the current user. - - - - - The state/staffing level of the user to set for the user. - - - - - Note for the staffing level - - - - - The result object for a state/staffing level request. - - - - - The UserId GUID/UUID for the user state/staffing level being return - - - - - The full name of the user for the state/staffing level being returned - - - - - The current staffing level (state) type for the user - - - - - The timestamp of the last state/staffing level. This is converted UTC to the departments, or users, TimeZone. - - - - - Staffing note for the User's staffing - - A resrouce in the system this could be a user or unit @@ -7531,6 +7554,18 @@ Input to move a resource assignment to a different node. + + Input to update command-level details every resource should see. + + + Input to set an objective's progress percentage (0-100; 100 completes it). + + + Input to transition an incident need's fulfillment status. + + + Maps to Resgrid.Model.IncidentNeedStatus. + Human-readable requirements notice. On Status=failure: why the assignment was rejected (forced @@ -7538,6 +7573,9 @@ meet the lane's non-forced requirements (also stamped on Data.RequirementsWarning/-Message). + + Read-only incident view for a responder or unit: commander, timing, objectives, needs, notes, attachments, own lane assignment. + Input logging one completed PTT transmission on an incident channel. @@ -8087,194 +8125,364 @@ Identifier of the new npte - + - A GPS location for a point in time of a specificed person + The result of getting all personnel filters for the system - + - PersonId of the person that the location is for + The Id value of the filter - + - The timestamp of the location in UTC + The type of the filter - + - GPS Latitude of the Person + The filters name - + - GPS Longitude of the Person + Result containing all the data required to populate the New Call form - + - GPS Latitude\Longitude Accuracy of the Person + Response Data - + - GPS Altitude of the Person + Result that contains all the options available to filter personnel against compatible Resgrid APIs - + - GPS Altitude Accuracy of the Person + Response Data - + - GPS Speed of the Person + Result containing all the data required to populate the New Call form - + - GPS Heading of the Person + Response Data - + - A unit location in the Resgrid system + Information about a User - + - Response Data + The UserId GUID/UUID for the user - + - The information about a specific unit's location + DepartmentId of the deparment the user belongs to - + - Id of the Person + Department specificed ID number for this user - + - The Timestamp for the location in UTC + The Users First Name - + - GPS Latitude of the Person + The Users Last Name - + - GPS Longitude of the Person + The Users Email Address - + - GPS Latitude\Longitude Accuracy of the Person + The Users Mobile Telephone Number - + - GPS Altitude of the Person + GroupId the user is assigned to (0 for no group) - + - GPS Altitude Accuracy of the Person + Name of the group the user is assigned to - + - GPS Speed of the Person + Enumeration/List of roles the user currently holds - + - GPS Heading of the Person + The current action/status type for the user - + - The result of getting the current staffing for a user + The current action/status string for the user - + - Response Data + The current action/status color hex string for the user - + - Information about a User staffing + The timestamp of the last action. This is converted UTC to the departments, or users, TimeZone. - + - The UserId GUID/UUID for the user status being return + The current action/status destination id for the user - + - DepartmentId of the deparment the user belongs to + The current action/status destination name for the user - + - The current staffing type for the user + The current staffing level (state) type for the user - + - The timestamp of the last staffing. This is converted UTC version of the timestamp. + The current staffing level (state) string for the user - + - The timestamp of the last staffing. This is converted UTC to the departments, or users, TimeZone. + The current staffing level (state) color hex string for the user - + - Note for this staffing + The timestamp of the last state/staffing level. This is converted UTC to the departments, or users, TimeZone. - + - Saves (sets) and Personnel Staffing in the system, for a single user + Users last known location - + - UnitId of the apparatus that the state is being set for + Sorting weight for the user - + - The UnitStateType of the Unit + User Defined Field values for this personnel record - + - The timestamp of the status event in UTC + A GPS location for a point in time of a specificed person - + - The timestamp of the status event in the local time of the device + PersonId of the person that the location is for - + - User provided note for this event + The timestamp of the location in UTC - + - The event id used for queuing on mobile applications + GPS Latitude of the Person + + + + + GPS Longitude of the Person + + + + + GPS Latitude\Longitude Accuracy of the Person + + + + + GPS Altitude of the Person + + + + + GPS Altitude Accuracy of the Person + + + + + GPS Speed of the Person + + + + + GPS Heading of the Person + + + + + A unit location in the Resgrid system + + + + + Response Data + + + + + The information about a specific unit's location + + + + + Id of the Person + + + + + The Timestamp for the location in UTC + + + + + GPS Latitude of the Person + + + + + GPS Longitude of the Person + + + + + GPS Latitude\Longitude Accuracy of the Person + + + + + GPS Altitude of the Person + + + + + GPS Altitude Accuracy of the Person + + + + + GPS Speed of the Person + + + + + GPS Heading of the Person + + + + + The result of getting the current staffing for a user + + + + + Response Data + + + + + Information about a User staffing + + + + + The UserId GUID/UUID for the user status being return + + + + + DepartmentId of the deparment the user belongs to + + + + + The current staffing type for the user + + + + + The timestamp of the last staffing. This is converted UTC version of the timestamp. + + + + + The timestamp of the last staffing. This is converted UTC to the departments, or users, TimeZone. + + + + + Note for this staffing + + + + + Saves (sets) and Personnel Staffing in the system, for a single user + + + + + UnitId of the apparatus that the state is being set for + + + + + The UnitStateType of the Unit + + + + + The timestamp of the status event in UTC + + + + + The timestamp of the status event in the local time of the device + + + + + User provided note for this event + + + + + The event id used for queuing on mobile applications @@ -8590,279 +8798,109 @@ Response Data - + - The result of getting all personnel filters for the system + Result containing all the data required to populate the New Call form - + - The Id value of the filter + Response Data - + - The type of the filter + Details of a protocol - + - The filters name + Protocol id - + - Result containing all the data required to populate the New Call form + Department id - + - Response Data + Name of the Protocol - + - Result that contains all the options available to filter personnel against compatible Resgrid APIs + Protocol code - + - Response Data + This this protocol disabled - + - Result containing all the data required to populate the New Call form + Protocol description - + - Response Data + Text of the protocol - + - Information about a User + UTC date and time when the Protocol was created - + - The UserId GUID/UUID for the user + UserId of the user who created the protocol - + - DepartmentId of the deparment the user belongs to + UTC timestamp of when the Protocol was updated - + - Department specificed ID number for this user + Minimum triggering Weight of the Protocol - + - The Users First Name + UserId that last updated the Protocol - + - The Users Last Name + Triggers used to activate this Protocol - + - The Users Email Address + Attachments for this Protocol - + - The Users Mobile Telephone Number + Questions used to determine if this Protocol needs to be used or not - + - GroupId the user is assigned to (0 for no group) + State type - + - Name of the group the user is assigned to + Result containing all the data required to populate the New Call form - + - Enumeration/List of roles the user currently holds - - - - - The current action/status type for the user - - - - - The current action/status string for the user - - - - - The current action/status color hex string for the user - - - - - The timestamp of the last action. This is converted UTC to the departments, or users, TimeZone. - - - - - The current action/status destination id for the user - - - - - The current action/status destination name for the user - - - - - The current staffing level (state) type for the user - - - - - The current staffing level (state) string for the user - - - - - The current staffing level (state) color hex string for the user - - - - - The timestamp of the last state/staffing level. This is converted UTC to the departments, or users, TimeZone. - - - - - Users last known location - - - - - Sorting weight for the user - - - - - User Defined Field values for this personnel record - - - - - Result containing all the data required to populate the New Call form - - - - - Response Data - - - - - Details of a protocol - - - - - Protocol id - - - - - Department id - - - - - Name of the Protocol - - - - - Protocol code - - - - - This this protocol disabled - - - - - Protocol description - - - - - Text of the protocol - - - - - UTC date and time when the Protocol was created - - - - - UserId of the user who created the protocol - - - - - UTC timestamp of when the Protocol was updated - - - - - Minimum triggering Weight of the Protocol - - - - - UserId that last updated the Protocol - - - - - Triggers used to activate this Protocol - - - - - Attachments for this Protocol - - - - - Questions used to determine if this Protocol needs to be used or not - - - - - State type - - - - - Result containing all the data required to populate the New Call form - - - - - Response Data + Response Data @@ -10131,545 +10169,545 @@ Default constructor - + - Depicts a result after saving a unit status + Result that contains all the options available to filter units against compatible Resgrid APIs - + Response Data - + - Object inputs for setting a users Status/Action. If this object is used in an operation that sets - a status for the current user the UserId value in this object will be ignored. + A unit in the Resgrid system - + - UnitId of the apparatus that the state is being set for + Response Data - + - The UnitStateType of the Unit + The information about a specific unit - + - The Call/Station the unit is responding to + Id of the Unit - + - Destination type for RespondingTo (Station = 1, Call = 2, POI = 3). + The Id of the department the unit is under - + - The timestamp of the status event in UTC + Name of the Unit - + - The timestamp of the status event in the local time of the device + Department assigned type for the unit - + - User provided note for this event + Department assigned type id for the unit - + - GPS Latitude of the Unit + Custom Statuses Set Id - + - GPS Longitude of the Unit + Station Id of the station housing the unit (0 means no station) - + - GPS Latitude\Longitude Accuracy of the Unit + Name of the station the unit is under - + - GPS Altitude of the Unit + Vehicle Identification Number for the unit - + - GPS Altitude Accuracy of the Unit + Plate Number for the Unit - + - GPS Speed of the Unit + Is the unit 4-Wheel drive - + - GPS Heading of the Unit + Does the unit require a special permit to drive - + - The event id used for queuing on mobile applications + Id number of the units current destionation (0 means no destination) - + - The accountability roles filed for this event + The current status/state of the Unit - + - Role filled by a User on a Unit for an event + The Timestamp of the status - + - Id of the locally stored event + The units current Latitude - + - Local Event Id + The units current Longitude - + - UserId of the user filling the role + Current user provide status note - + - RoleId of the role being filled + User Defined Field values for this unit - + - The name of the Role + Unit role information for roles on a unit - + - Depicts a unit status in the Resgrid system. + Unit Role Id - + - Response Data + User Id of the user in the role (could be null) - + - Depicts a unit's status + Name of the Role - + - Unit Id + Name of the user in the role (could be null) - + - Units Name + Multiple Unit infos Result - + - The Type of the Unit + Response Data - + - Units current Status (State) + Default constructor - + - CSS for status (for display) + The information about a specific unit - + - CSS Style for status (for display) + Id of the Unit - + - Timestamp of this Unit State + The Id of the department the unit is under - + - Timestamp in Utc of this Unit State + Name of the Unit - + - Destination Id (Station or Call) + Department assigned type for the unit - + - Destination type (Station, Call, or POI). + Department assigned type id for the unit - + - Name of the Desination (Call or Station) + Custom Statuses Set Id - + - Destination address. + Station Id of the station housing the unit (0 means no station) - + - Localized display label for the destination type (e.g. "Station", "Call", "POI"). Not - suitable for programmatic branching; use as the - machine-readable discriminator instead. + Name of the station the unit is under - + - Note for the State + Vehicle Identification Number for the unit - + - Latitude + Plate Number for the Unit - + - Longitude + Is the unit 4-Wheel drive - + - Name of the Group the Unit is in + Does the unit require a special permit to drive - + - Id of the Group the Unit is in + Id number of the units current destination (0 means no destination) - + - Unit statuses (states) + Name of the units current destination (0 means no destination) - + - Response Data + The current status/state of the Unit - + - Default constructor + The current status/state of the Unit as a name - + - Result that contains all the options available to filter units against compatible Resgrid APIs + The current status/state of the Unit color - + - Response Data + The Timestamp of the status - + - A unit in the Resgrid system + The Timestamp of the status in UTC/GMT - + - Response Data + The units current Latitude - + - The information about a specific unit + The units current Longitude - + - Id of the Unit + Current user provide status note - + - The Id of the department the unit is under + Units Roles - + - Name of the Unit + Multiple Units Result - + - Department assigned type for the unit + Response Data - + - Department assigned type id for the unit + Default constructor - + - Custom Statuses Set Id + Depicts a result after saving a unit status - + - Station Id of the station housing the unit (0 means no station) + Response Data - + - Name of the station the unit is under + Object inputs for setting a users Status/Action. If this object is used in an operation that sets + a status for the current user the UserId value in this object will be ignored. - + - Vehicle Identification Number for the unit + UnitId of the apparatus that the state is being set for - + - Plate Number for the Unit + The UnitStateType of the Unit - + - Is the unit 4-Wheel drive + The Call/Station the unit is responding to - + - Does the unit require a special permit to drive + Destination type for RespondingTo (Station = 1, Call = 2, POI = 3). - + - Id number of the units current destionation (0 means no destination) + The timestamp of the status event in UTC - + - The current status/state of the Unit + The timestamp of the status event in the local time of the device - + - The Timestamp of the status + User provided note for this event - + - The units current Latitude + GPS Latitude of the Unit - + - The units current Longitude + GPS Longitude of the Unit - + - Current user provide status note + GPS Latitude\Longitude Accuracy of the Unit - + - User Defined Field values for this unit + GPS Altitude of the Unit - + - Unit role information for roles on a unit + GPS Altitude Accuracy of the Unit - + - Unit Role Id + GPS Speed of the Unit - + - User Id of the user in the role (could be null) + GPS Heading of the Unit - + - Name of the Role + The event id used for queuing on mobile applications - + - Name of the user in the role (could be null) + The accountability roles filed for this event - + - Multiple Unit infos Result + Role filled by a User on a Unit for an event - + - Response Data + Id of the locally stored event - + - Default constructor + Local Event Id - + - The information about a specific unit + UserId of the user filling the role - + - Id of the Unit + RoleId of the role being filled - + - The Id of the department the unit is under + The name of the Role - + - Name of the Unit + Depicts a unit status in the Resgrid system. - + - Department assigned type for the unit + Response Data - + - Department assigned type id for the unit + Depicts a unit's status - + - Custom Statuses Set Id + Unit Id - + - Station Id of the station housing the unit (0 means no station) + Units Name - + - Name of the station the unit is under + The Type of the Unit - + - Vehicle Identification Number for the unit + Units current Status (State) - + - Plate Number for the Unit + CSS for status (for display) - + - Is the unit 4-Wheel drive + CSS Style for status (for display) - + - Does the unit require a special permit to drive + Timestamp of this Unit State - + - Id number of the units current destination (0 means no destination) + Timestamp in Utc of this Unit State - + - Name of the units current destination (0 means no destination) + Destination Id (Station or Call) - + - The current status/state of the Unit + Destination type (Station, Call, or POI). - + - The current status/state of the Unit as a name + Name of the Desination (Call or Station) - + - The current status/state of the Unit color + Destination address. - + - The Timestamp of the status + Localized display label for the destination type (e.g. "Station", "Call", "POI"). Not + suitable for programmatic branching; use as the + machine-readable discriminator instead. - + - The Timestamp of the status in UTC/GMT + Note for the State - + - The units current Latitude + Latitude - + - The units current Longitude + Longitude - + - Current user provide status note + Name of the Group the Unit is in - + - Units Roles + Id of the Group the Unit is in - + - Multiple Units Result + Unit statuses (states) - + Response Data - + Default constructor