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
-
+