From c3c1feddb393c3db12e91a0a7c0dd1a0077a0865 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Wed, 22 Jul 2026 13:36:46 -0700 Subject: [PATCH 1/2] RIC-T39 More IC work --- .../Events/IncidentCommandEvents.cs | 10 + .../IncidentCommand/CommandStructureNode.cs | 3 + .../IncidentCommand/IncidentCommand.cs | 29 + .../IncidentCommand/IncidentCommandBoard.cs | 3 + .../IncidentCommand/IncidentCommandChanges.cs | 2 + .../IncidentCommand/IncidentCommandEnums.cs | 34 +- .../IncidentCommand/IncidentCommandSummary.cs | 90 ++ .../IncidentCommand/IncidentMap.cs | 72 ++ .../IncidentCommand/IncidentNeed.cs | 58 + .../IncidentCommand/IncidentNeedEntity.cs | 61 + .../IncidentCommand/IncidentTacticals.cs | 9 + .../IIncidentCommandRepositories.cs | 12 + .../Services/IIncidentCommandService.cs | 86 +- .../IncidentCommandService.cs | 1046 ++++++++++++++++- ...0094_AddIncidentCommandNameAndLocations.cs | 54 + .../M0095_AddIncidentNeedUpdates.cs | 47 + .../Migrations/M0096_AddObjectiveOutcome.cs | 36 + .../Migrations/M0097_AddIncidentMapView.cs | 36 + .../Migrations/M0098_AddIncidentMaps.cs | 60 + .../M0099_AddIncidentNeedEntities.cs | 46 + ...94_AddIncidentCommandNameAndLocationsPg.cs | 47 + .../M0095_AddIncidentNeedUpdatesPg.cs | 47 + .../Migrations/M0096_AddObjectiveOutcomePg.cs | 36 + .../Migrations/M0097_AddIncidentMapViewPg.cs | 36 + .../Migrations/M0098_AddIncidentMapsPg.cs | 60 + .../M0099_AddIncidentNeedEntitiesPg.cs | 46 + .../IncidentCommandRepositories.cs | 24 + .../Modules/DataModule.cs | 3 + .../IncidentCommandContentServiceTests.cs | 9 +- .../IncidentCommandInfoAndReopenTests.cs | 214 ++++ .../IncidentCommandNeedsAndLeadsTests.cs | 327 +++++- .../IncidentCommandServiceParTests.cs | 17 +- .../v4/IncidentCommandController.cs | 312 ++++- .../v4/PersonnelStatusesController.cs | 21 +- .../Controllers/v4/UnitStatusController.cs | 20 +- .../IncidentCommand/IncidentCommandModels.cs | 100 ++ .../Resgrid.Web.Services.xml | 117 +- 37 files changed, 3175 insertions(+), 55 deletions(-) create mode 100644 Core/Resgrid.Model/IncidentCommand/IncidentCommandSummary.cs create mode 100644 Core/Resgrid.Model/IncidentCommand/IncidentMap.cs create mode 100644 Core/Resgrid.Model/IncidentCommand/IncidentNeedEntity.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0094_AddIncidentCommandNameAndLocations.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0095_AddIncidentNeedUpdates.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0096_AddObjectiveOutcome.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0097_AddIncidentMapView.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0098_AddIncidentMaps.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0099_AddIncidentNeedEntities.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0094_AddIncidentCommandNameAndLocationsPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0095_AddIncidentNeedUpdatesPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0096_AddObjectiveOutcomePg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0097_AddIncidentMapViewPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0098_AddIncidentMapsPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0099_AddIncidentNeedEntitiesPg.cs create mode 100644 Tests/Resgrid.Tests/Services/IncidentCommandInfoAndReopenTests.cs diff --git a/Core/Resgrid.Model/Events/IncidentCommandEvents.cs b/Core/Resgrid.Model/Events/IncidentCommandEvents.cs index 97fd78b27..657d52efb 100644 --- a/Core/Resgrid.Model/Events/IncidentCommandEvents.cs +++ b/Core/Resgrid.Model/Events/IncidentCommandEvents.cs @@ -37,6 +37,16 @@ public class IncidentClosedEvent public string IncidentCommandId { get; set; } } + /// A previously closed incident command was reopened. + public class IncidentReopenedEvent + { + public int DepartmentId { get; set; } + public int CallId { get; set; } + public string IncidentCommandId { get; set; } + public string Reason { get; set; } + public string ReopenedByUserId { get; set; } + } + /// Raised when a resource is assigned to a command structure node. public class IncidentResourceAssignedEvent { diff --git a/Core/Resgrid.Model/IncidentCommand/CommandStructureNode.cs b/Core/Resgrid.Model/IncidentCommand/CommandStructureNode.cs index 4ac52c418..30034d4c3 100644 --- a/Core/Resgrid.Model/IncidentCommand/CommandStructureNode.cs +++ b/Core/Resgrid.Model/IncidentCommand/CommandStructureNode.cs @@ -69,6 +69,9 @@ public class CommandStructureNode : IEntity, IChangeTracked /// Optional incident need this lane is fulfilling (FK to IncidentNeeds). public string LinkedNeedId { get; set; } + /// Optional named incident map attached to this lane (FK to IncidentMaps) — e.g. the area it is working. + public string LinkedMapId { get; set; } + /// Primary lane lead when they are a Resgrid user; null for external leads. public string PrimaryLeadUserId { get; set; } diff --git a/Core/Resgrid.Model/IncidentCommand/IncidentCommand.cs b/Core/Resgrid.Model/IncidentCommand/IncidentCommand.cs index f9c6906ad..0cbe86c67 100644 --- a/Core/Resgrid.Model/IncidentCommand/IncidentCommand.cs +++ b/Core/Resgrid.Model/IncidentCommand/IncidentCommand.cs @@ -26,10 +26,39 @@ public class IncidentCommand : IEntity, IChangeTracked public string CurrentCommanderUserId { get; set; } + /// Optional commander-supplied display name for the incident (falls back to the call name in UIs). + public string Name { get; set; } + + /// Free-form description of where the ICP/HQ (command post) is ("Front lobby of 123 Main St"). + public string CommandPostLocationText { get; set; } + public string CommandPostLatitude { get; set; } public string CommandPostLongitude { get; set; } + /// Free-form description of where Staging is located. + public string StagingLocationText { get; set; } + + public string StagingLatitude { get; set; } + + public string StagingLongitude { get; set; } + + /// Free-form description of where Rehab is located. + public string RehabLocationText { get; set; } + + public string RehabLatitude { get; set; } + + public string RehabLongitude { get; set; } + + /// Saved incident-map view: center latitude (set once the IC pins the map's framing). + public string MapCenterLatitude { get; set; } + + /// Saved incident-map view: center longitude. + public string MapCenterLongitude { get; set; } + + /// Saved incident-map view: zoom level (0-22); null until the incident map is created. + public string MapZoomLevel { get; set; } + public string IncidentActionPlan { get; set; } /// NIMS/ICS escalation level for the incident (department defined). diff --git a/Core/Resgrid.Model/IncidentCommand/IncidentCommandBoard.cs b/Core/Resgrid.Model/IncidentCommand/IncidentCommandBoard.cs index 8b1e1e5ad..7938ea58b 100644 --- a/Core/Resgrid.Model/IncidentCommand/IncidentCommandBoard.cs +++ b/Core/Resgrid.Model/IncidentCommand/IncidentCommandBoard.cs @@ -22,6 +22,9 @@ public class IncidentCommandBoard public List Annotations { get; set; } = new List(); + /// Named tactical maps for the incident (the main map lives on the Command itself). + public List Maps { get; set; } = new List(); + /// Personnel accountability / PAR status (from the Checkin feature) for the incident. public List Accountability { get; set; } = new List(); diff --git a/Core/Resgrid.Model/IncidentCommand/IncidentCommandChanges.cs b/Core/Resgrid.Model/IncidentCommand/IncidentCommandChanges.cs index fed18a71b..a4495e83c 100644 --- a/Core/Resgrid.Model/IncidentCommand/IncidentCommandChanges.cs +++ b/Core/Resgrid.Model/IncidentCommand/IncidentCommandChanges.cs @@ -29,6 +29,8 @@ public class IncidentCommandChanges public List Annotations { get; set; } = new List(); + public List Maps { get; set; } = new List(); + public List Roles { get; set; } = new List(); public List AdHocUnits { get; set; } = new List(); diff --git a/Core/Resgrid.Model/IncidentCommand/IncidentCommandEnums.cs b/Core/Resgrid.Model/IncidentCommand/IncidentCommandEnums.cs index edf855a16..4fb062239 100644 --- a/Core/Resgrid.Model/IncidentCommand/IncidentCommandEnums.cs +++ b/Core/Resgrid.Model/IncidentCommand/IncidentCommandEnums.cs @@ -47,6 +47,15 @@ public enum TacticalObjectiveStatus InProgress = 2 } + /// How a completed tactical objective turned out (recorded at close-out). + public enum TacticalObjectiveOutcome + { + NotSet = 0, + Successful = 1, + Partial = 2, + Unsuccessful = 3 + } + /// Category of an incident need (resource/logistics request tracked at the command level). public enum IncidentNeedCategory { @@ -55,7 +64,22 @@ public enum IncidentNeedCategory Medical = 2, Equipment = 3, Staffing = 4, - Other = 5 + Other = 5, + + /// + /// A request for SPECIFIC Resgrid entities (units/users/roles/groups) that get added to the call + /// and dispatched individually as "requested by command". See . + /// + Entity = 6 + } + + /// What kind of Resgrid entity an requests. + public enum NeedEntityKind + { + Unit = 0, + User = 1, + Role = 2, + Group = 3 } /// Fulfillment state of an incident need. @@ -148,6 +172,12 @@ public enum CommandLogEntryType NeedMet = 33, ObjectiveProgressUpdated = 34, LaneLeadChanged = 35, - CommandDetailsUpdated = 36 + CommandDetailsUpdated = 36, + + /// A previously closed command was reopened (the reopen reason is embedded in the description). + CommandReopened = 37, + + /// The incident map's saved view (center/zoom) was created or changed. + MapViewUpdated = 38 } } diff --git a/Core/Resgrid.Model/IncidentCommand/IncidentCommandSummary.cs b/Core/Resgrid.Model/IncidentCommand/IncidentCommandSummary.cs new file mode 100644 index 000000000..ee2a557c7 --- /dev/null +++ b/Core/Resgrid.Model/IncidentCommand/IncidentCommandSummary.cs @@ -0,0 +1,90 @@ +using System; + +namespace Resgrid.Model +{ + /// + /// List-card projection of an incident command for the IC app's incident list: identity, lifecycle timing, + /// resolved commander name, locations, and active resource counts. Not persisted (composed by the service). + /// + public class IncidentCommandSummary + { + public string IncidentCommandId { get; set; } + + public int DepartmentId { get; set; } + + public int CallId { get; set; } + + /// Commander-supplied incident name; null when unnamed (UIs fall back to the call name). + public string Name { get; set; } + + public string CallName { get; set; } + + public string CallNumber { get; set; } + + public string CallAddress { get; set; } + + /// Maps to . + public int Status { get; set; } + + public DateTime EstablishedOn { get; set; } + + public DateTime? ClosedOn { get; set; } + + public string CommanderUserId { get; set; } + + /// Resolved commander full name (falls back to the user id when the profile is unavailable). + public string CommanderName { get; set; } + + public string CommandPostLocationText { get; set; } + + public string CommandPostLatitude { get; set; } + + public string CommandPostLongitude { get; set; } + + /// Active (unreleased) personnel assignments placed in a lane or staging. + public int AssignedPersonnelCount { get; set; } + + /// Active (unreleased) unit assignments placed in a lane or staging. + public int AssignedUnitCount { get; set; } + } + + /// + /// Field bag for IIncidentCommandService.UpdateCommandInfoAsync. Null members are left unchanged; + /// an empty string clears the stored value. Any location whose text is set while its coordinates are + /// blank is geocoded server-side on save. + /// + public class IncidentCommandInfoUpdate + { + public string Name { get; set; } + + /// Corrected incident start time (UTC); null leaves the original EstablishedOn. + public DateTime? EstablishedOn { get; set; } + + public DateTime? EstimatedEndOn { get; set; } + + /// When true, a null clears the stored value instead of leaving it. + public bool ClearEstimatedEndOn { get; set; } + + public string ImportantInformation { get; set; } + + public int? IcsLevel { get; set; } + + public string CommandPostLocationText { get; set; } + + public string CommandPostLatitude { get; set; } + + public string CommandPostLongitude { get; set; } + + public string StagingLocationText { get; set; } + + public string StagingLatitude { get; set; } + + public string StagingLongitude { get; set; } + + public string RehabLocationText { get; set; } + + public string RehabLatitude { get; set; } + + public string RehabLongitude { get; set; } + } +} diff --git a/Core/Resgrid.Model/IncidentCommand/IncidentMap.cs b/Core/Resgrid.Model/IncidentCommand/IncidentMap.cs new file mode 100644 index 000000000..ee017134b --- /dev/null +++ b/Core/Resgrid.Model/IncidentCommand/IncidentMap.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model +{ + /// + /// A named tactical map for an incident — its own saved framing (center + zoom) and markup + /// (annotations reference it via ), with an optional + /// expiry. Lanes can attach one via . The incident's + /// MAIN map lives on itself (MapCenter*/MapZoomLevel, null map id). + /// + public class IncidentMap : IEntity, IChangeTracked + { + public string IncidentMapId { get; set; } + + public string IncidentCommandId { get; set; } + + public int DepartmentId { get; set; } + + public int CallId { get; set; } + + public string Name { get; set; } + + public string Description { get; set; } + + public string CenterLatitude { get; set; } + + public string CenterLongitude { get; set; } + + /// Zoom level (0-22); null until the framing has been pinned. + public string ZoomLevel { get; set; } + + /// Optional expiry after which the map is stale (kept, but flagged in UIs). + public DateTime? ExpiresOn { get; set; } + + public string CreatedByUserId { get; set; } + + public DateTime CreatedOn { get; set; } + + public string UpdatedByUserId { get; set; } + + public DateTime? UpdatedOn { get; set; } + + /// Soft-delete tombstone so removals propagate to offline clients. + public DateTime? DeletedOn { get; set; } + + /// Change cursor for offline delta sync + last-write-wins; stamped on every write. + public DateTime? ModifiedOn { get; set; } + + [NotMapped] + public string TableName => "IncidentMaps"; + + [NotMapped] + public string IdName => "IncidentMapId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return IncidentMapId; } + set { IncidentMapId = (string)value; } + } + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" }; + } +} diff --git a/Core/Resgrid.Model/IncidentCommand/IncidentNeed.cs b/Core/Resgrid.Model/IncidentCommand/IncidentNeed.cs index e4fecdd6f..92bebe5b5 100644 --- a/Core/Resgrid.Model/IncidentCommand/IncidentNeed.cs +++ b/Core/Resgrid.Model/IncidentCommand/IncidentNeed.cs @@ -74,4 +74,62 @@ public object IdValue [NotMapped] public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" }; } + + /// + /// Append-only audit row for one fulfillment change on an : status and/or + /// fill-quantity transition, the caller's note ("Engine 1 from mutual aid"), who made it, and when. + /// + public class IncidentNeedUpdate : IEntity + { + public string IncidentNeedUpdateId { get; set; } + + public string IncidentNeedId { get; set; } + + public string IncidentCommandId { get; set; } + + public int DepartmentId { get; set; } + + public int CallId { get; set; } + + /// Maps to . + public int PreviousStatus { get; set; } + + /// Maps to . + public int NewStatus { get; set; } + + public int PreviousQuantityFulfilled { get; set; } + + public int NewQuantityFulfilled { get; set; } + + /// Caller-supplied context for the change (which resource filled it, why it was called off, ...). + public string Note { get; set; } + + public string CreatedByUserId { get; set; } + + /// Resolved display name for CreatedByUserId; filled at read time, never persisted. + [NotMapped] + public string CreatedByUserName { get; set; } + + public DateTime CreatedOn { get; set; } + + [NotMapped] + public string TableName => "IncidentNeedUpdates"; + + [NotMapped] + public string IdName => "IncidentNeedUpdateId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return IncidentNeedUpdateId; } + set { IncidentNeedUpdateId = (string)value; } + } + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName", "CreatedByUserName" }; + } } diff --git a/Core/Resgrid.Model/IncidentCommand/IncidentNeedEntity.cs b/Core/Resgrid.Model/IncidentCommand/IncidentNeedEntity.cs new file mode 100644 index 000000000..4907454a9 --- /dev/null +++ b/Core/Resgrid.Model/IncidentCommand/IncidentNeedEntity.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model +{ + /// + /// One requested Resgrid entity under an Entity-category : a unit, user, + /// role, or group command asked for. The entity is added to the call and dispatched individually + /// ("requested by command") when the need is created. + /// + public class IncidentNeedEntity : IEntity + { + public string IncidentNeedEntityId { get; set; } + + public string IncidentNeedId { get; set; } + + public string IncidentCommandId { get; set; } + + public int DepartmentId { get; set; } + + public int CallId { get; set; } + + /// Maps to . + public int EntityKind { get; set; } + + /// UnitId / UserId / PersonnelRoleId / DepartmentGroupId depending on the kind. + public string EntityId { get; set; } + + /// Display-name snapshot at request time (unit name, user full name, role/group name). + public string EntityName { get; set; } + + /// When the individual dispatch for this entity was queued; null when dispatch failed. + public DateTime? DispatchedOn { get; set; } + + public string CreatedByUserId { get; set; } + + public DateTime CreatedOn { get; set; } + + [NotMapped] + public string TableName => "IncidentNeedEntities"; + + [NotMapped] + public string IdName => "IncidentNeedEntityId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return IncidentNeedEntityId; } + set { IncidentNeedEntityId = (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 72bd77ba3..29deb5504 100644 --- a/Core/Resgrid.Model/IncidentCommand/IncidentTacticals.cs +++ b/Core/Resgrid.Model/IncidentCommand/IncidentTacticals.cs @@ -30,6 +30,12 @@ public class TacticalObjective : IEntity, IChangeTracked public DateTime? CompletedOn { get; set; } + /// How the objective turned out at close-out. Maps to . + public int Outcome { get; set; } + + /// Optional close-out note recorded when the objective was completed. + public string CompletionNote { get; set; } + /// Optional free-text detail describing the objective / how it will be met. public string Description { get; set; } @@ -142,6 +148,9 @@ public class IncidentMapAnnotation : IEntity, IChangeTracked /// Maps to . public int AnnotationType { get; set; } + /// The named this markup belongs to; null = the incident's main map. + public string IncidentMapId { get; set; } + /// The annotation geometry as a GeoJSON feature. public string GeoJson { get; set; } diff --git a/Core/Resgrid.Model/Repositories/IIncidentCommandRepositories.cs b/Core/Resgrid.Model/Repositories/IIncidentCommandRepositories.cs index f09f9f63a..f9d8e408e 100644 --- a/Core/Resgrid.Model/Repositories/IIncidentCommandRepositories.cs +++ b/Core/Resgrid.Model/Repositories/IIncidentCommandRepositories.cs @@ -37,6 +37,18 @@ public interface ICommandTransferRepository : IRepository { } + public interface IIncidentNeedUpdateRepository : IRepository + { + } + + public interface IIncidentMapRepository : IRepository + { + } + + public interface IIncidentNeedEntityRepository : IRepository + { + } + public interface IIncidentNoteRepository : IRepository { } diff --git a/Core/Resgrid.Model/Services/IIncidentCommandService.cs b/Core/Resgrid.Model/Services/IIncidentCommandService.cs index ec6bf99bf..0098966cf 100644 --- a/Core/Resgrid.Model/Services/IIncidentCommandService.cs +++ b/Core/Resgrid.Model/Services/IIncidentCommandService.cs @@ -50,6 +50,13 @@ public interface IIncidentCommandService Task> GetIncidentRolesAsync(int departmentId, int callId); Task GetCapabilitiesForUserAsync(int departmentId, int callId, string userId); Task CloseCommandAsync(int departmentId, string incidentCommandId, string userId, CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// Reopens a previously closed command (Status back to Active, ClosedOn cleared), recording the caller's + /// reason on the timeline. Null when the command doesn't exist/isn't the caller's; throws + /// when another command is already active on the call. + /// + Task ReopenCommandAsync(int departmentId, string incidentCommandId, string reason, string userId, CancellationToken cancellationToken = default(CancellationToken)); Task TransferCommandAsync(int departmentId, string incidentCommandId, string fromUserId, string toUserId, string notes, CancellationToken cancellationToken = default(CancellationToken)); Task UpdateActionPlanAsync(int departmentId, string incidentCommandId, string actionPlan, string userId, CancellationToken cancellationToken = default(CancellationToken)); Task UpdateCommandPostAsync(int departmentId, string incidentCommandId, string latitude, string longitude, string userId, CancellationToken cancellationToken = default(CancellationToken)); @@ -87,7 +94,12 @@ public interface IIncidentCommandService // Objectives / benchmarks Task SaveObjectiveAsync(TacticalObjective objective, string userId, CancellationToken cancellationToken = default(CancellationToken)); - Task CompleteObjectiveAsync(int departmentId, string tacticalObjectiveId, string userId, CancellationToken cancellationToken = default(CancellationToken)); + /// + /// Completes (closes out) an objective, recording how it turned out + /// (), an optional close-out note, and who/when. The outcome, + /// author, and note are written to the incident log. + /// + Task CompleteObjectiveAsync(int departmentId, string tacticalObjectiveId, string userId, TacticalObjectiveOutcome outcome = TacticalObjectiveOutcome.NotSet, string note = null, CancellationToken cancellationToken = default(CancellationToken)); Task> GetObjectivesForCallAsync(int departmentId, int callId); /// @@ -100,15 +112,60 @@ public interface IIncidentCommandService 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. + /// Transitions a need's fulfillment status (optionally updating the fulfilled quantity — up OR down). + /// Transitioning to Met stamps MetBy/MetOn; leaving Met clears them. Every call writes an + /// audit row carrying the optional + /// ("Engine 1 from mutual aid", "called off", ...) with author and timestamp. /// - Task SetNeedStatusAsync(int departmentId, string incidentNeedId, IncidentNeedStatus status, int? quantityFulfilled, string userId, CancellationToken cancellationToken = default(CancellationToken)); + Task SetNeedStatusAsync(int departmentId, string incidentNeedId, IncidentNeedStatus status, int? quantityFulfilled, string userId, string note = null, CancellationToken cancellationToken = default(CancellationToken)); Task> GetNeedsForCallAsync(int departmentId, int callId); + // Entity needs — requests for SPECIFIC units/users/roles/groups, dispatched individually + + /// + /// Creates an Entity-category need requesting specific units/users/roles/groups. The entities are + /// added to the call's dispatch lists and dispatched INDIVIDUALLY (no full re-dispatch), tagged as + /// requested by Incident Command; the request is written to the incident log with author + entities. + /// + Task RequestNeedEntitiesAsync(int departmentId, string incidentCommandId, string name, string description, List entities, string userId, CancellationToken cancellationToken = default(CancellationToken)); + + /// The requested entities under one Entity-category need. + Task> GetNeedEntitiesAsync(int departmentId, string incidentNeedId); + + /// + /// Called by the status-save paths: when a unit/user that was requested via an entity need (directly, + /// or through a requested role/group) responds to the call, an incident-log entry records it. + /// Never throws — a logging failure must not break the status save. + /// + Task RecordNeedEntityStatusAsync(int departmentId, int callId, NeedEntityKind entityKind, string entityId, string statusText, string savedByUserId, CancellationToken cancellationToken = default(CancellationToken)); + + /// Audit trail for one need (newest first), with CreatedByUserName resolved for display. + Task> GetNeedUpdatesAsync(int departmentId, string incidentNeedId); + /// 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)); + /// + /// Updates core incident metadata (name, corrected start time, estimated end, important information, ICS + /// level) and the ICP/HQ, Staging, and Rehab locations. Null fields in are left + /// unchanged; empty strings clear. A location whose text is set while its coordinates are blank is + /// geocoded from the text before saving. + /// + Task UpdateCommandInfoAsync(int departmentId, string incidentCommandId, IncidentCommandInfoUpdate update, string userId, CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// List-card summaries (duration, resolved commander name, locations, active unit/personnel counts) for + /// the department's commands — active only by default, or every command including closed ones. + /// + Task> GetCommandSummariesForDepartmentAsync(int departmentId, bool includeClosed = false); + + /// + /// Board snapshot for one SPECIFIC command instance (active or closed) — unlike + /// , child rows are filtered to this command so a closed command's + /// board isn't polluted by a newer command on the same call. Read-only: no PAR sweep side effects. + /// + Task GetCommandBoardByIdAsync(int departmentId, string incidentCommandId); + /// /// 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 @@ -126,6 +183,27 @@ public interface IIncidentCommandService Task DeleteAnnotationAsync(int departmentId, string incidentMapAnnotationId, string userId, CancellationToken cancellationToken = default(CancellationToken)); Task> GetAnnotationsForCallAsync(int departmentId, int callId); + /// + /// Creates or updates the incident map's saved view (center + zoom) so the tactical map opens with a + /// consistent framing for everyone. Logged to the incident timeline with the author's name and any + /// command/ICS standing they hold. + /// + Task UpdateMapViewAsync(int departmentId, string incidentCommandId, string centerLatitude, string centerLongitude, string zoomLevel, string userId, CancellationToken cancellationToken = default(CancellationToken)); + + // Named incident maps (additional tactical maps beyond the incident's main map) + + /// + /// Creates or updates a NAMED incident map (name, description, framing, optional expiry). Audit + /// fields are stamped server-side (CreatedBy/On on create, UpdatedBy/On on update) and the change is + /// logged to the incident timeline with the author's name and ICS standing. + /// + Task SaveIncidentMapAsync(IncidentMap map, string userId, CancellationToken cancellationToken = default(CancellationToken)); + + /// Soft-deletes a named incident map (its markup rows keep their linkage for history). + Task DeleteIncidentMapAsync(int departmentId, string incidentMapId, string userId, CancellationToken cancellationToken = default(CancellationToken)); + + Task> GetIncidentMapsForCallAsync(int departmentId, int callId); + // Timeline Task> GetTimelineForCallAsync(int departmentId, int callId); Task AddLogEntryAsync(string incidentCommandId, int departmentId, int callId, CommandLogEntryType type, string description, string userId, CancellationToken cancellationToken = default(CancellationToken)); diff --git a/Core/Resgrid.Services/IncidentCommandService.cs b/Core/Resgrid.Services/IncidentCommandService.cs index 5f8d116ae..dc0943ce0 100644 --- a/Core/Resgrid.Services/IncidentCommandService.cs +++ b/Core/Resgrid.Services/IncidentCommandService.cs @@ -9,6 +9,7 @@ using Resgrid.Model; using Resgrid.Model.Events; using Resgrid.Model.Providers; +using Resgrid.Model.Queue; using Resgrid.Model.Repositories; using Resgrid.Model.Services; @@ -45,6 +46,15 @@ public class IncidentCommandService : IIncidentCommandService private readonly IIncidentNeedRepository _incidentNeedRepository; private readonly IUserProfileService _userProfileService; private readonly IIncidentCommandNotificationService _incidentCommandNotificationService; + private readonly IGeoLocationProvider _geoLocationProvider; + private readonly IDepartmentGroupsService _departmentGroupsService; + private readonly IIncidentAdHocUnitRepository _incidentAdHocUnitRepository; + private readonly IIncidentAdHocPersonnelRepository _incidentAdHocPersonnelRepository; + private readonly IIncidentNeedUpdateRepository _incidentNeedUpdateRepository; + private readonly IIncidentMapRepository _incidentMapRepository; + private readonly IIncidentNeedEntityRepository _incidentNeedEntityRepository; + private readonly ICallDispatchStatusService _callDispatchStatusService; + private readonly IQueueService _queueService; public IncidentCommandService( IIncidentCommandRepository incidentCommandRepository, @@ -69,7 +79,16 @@ public IncidentCommandService( IIncidentWeatherProvider incidentWeatherProvider, IIncidentNeedRepository incidentNeedRepository, IUserProfileService userProfileService, - IIncidentCommandNotificationService incidentCommandNotificationService) + IIncidentCommandNotificationService incidentCommandNotificationService, + IGeoLocationProvider geoLocationProvider, + IDepartmentGroupsService departmentGroupsService, + IIncidentAdHocUnitRepository incidentAdHocUnitRepository, + IIncidentAdHocPersonnelRepository incidentAdHocPersonnelRepository, + IIncidentNeedUpdateRepository incidentNeedUpdateRepository, + IIncidentMapRepository incidentMapRepository, + IIncidentNeedEntityRepository incidentNeedEntityRepository, + ICallDispatchStatusService callDispatchStatusService, + IQueueService queueService) { _incidentCommandRepository = incidentCommandRepository; _commandStructureNodeRepository = commandStructureNodeRepository; @@ -94,6 +113,15 @@ public IncidentCommandService( _incidentNeedRepository = incidentNeedRepository; _userProfileService = userProfileService; _incidentCommandNotificationService = incidentCommandNotificationService; + _geoLocationProvider = geoLocationProvider; + _departmentGroupsService = departmentGroupsService; + _incidentAdHocUnitRepository = incidentAdHocUnitRepository; + _incidentAdHocPersonnelRepository = incidentAdHocPersonnelRepository; + _incidentNeedUpdateRepository = incidentNeedUpdateRepository; + _incidentMapRepository = incidentMapRepository; + _incidentNeedEntityRepository = incidentNeedEntityRepository; + _callDispatchStatusService = callDispatchStatusService; + _queueService = queueService; } #region Command lifecycle @@ -261,7 +289,40 @@ public async Task> GetAccountabilityForCallAsyn return new List(); var statuses = await _checkInTimerService.GetCallPersonnelCheckInStatusesAsync(call); - return statuses ?? new List(); + return await EnrichCheckInStatusNamesAsync(statuses) ?? new List(); + } + + /// + /// The check-in timer service intentionally leaves FullName null; resolve it here so PAR rows (board, + /// bundle, log text) carry a member's name instead of their user id GUID. + /// + private async Task> EnrichCheckInStatusNamesAsync(List statuses) + { + if (statuses == null || statuses.Count == 0) + return statuses; + + var missing = statuses.Where(s => string.IsNullOrWhiteSpace(s.FullName) && !string.IsNullOrWhiteSpace(s.UserId)).ToList(); + if (missing.Count == 0) + return statuses; + + try + { + var profiles = await _userProfileService.GetSelectedUserProfilesAsync(missing.Select(s => s.UserId).Distinct().ToList()); + var profilesById = (profiles ?? new List()).Where(p => p != null && !string.IsNullOrWhiteSpace(p.UserId)) + .GroupBy(p => p.UserId).ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + + foreach (var status in missing) + { + if (profilesById.TryGetValue(status.UserId, out var profile)) + status.FullName = profile.FullName.AsFirstNameLastName; + } + } + catch (Exception ex) + { + Resgrid.Framework.Logging.LogException(ex); + } + + return statuses; } public async Task> EvaluateCriticalParAsync(int departmentId, int callId, CancellationToken cancellationToken = default(CancellationToken)) @@ -302,7 +363,8 @@ public async Task> GetAccountabilityForCallAsyn continue; var overdueBy = Math.Abs(Math.Round(status.MinutesRemaining, 1)); - var who = string.IsNullOrWhiteSpace(status.FullName) ? status.UserId : status.FullName; + // Always resolve through the label helper so the log carries "Full Name (Group)" — never a user GUID. + var who = await ResolveUserLabelAsync(departmentId, status.UserId, status.FullName); // The marker (UserId = the SUBJECT member) is both the dedup record and — via WriteLogAsync — // the real-time IncidentCommandUpdated push that refreshes the board/PAR view. @@ -362,7 +424,8 @@ private async Task EnableAccountabilityIfConfiguredAsync(int departmentId, int c return null; assignment = saved; - await WriteLogAsync(assignment.IncidentCommandId, assignment.DepartmentId, assignment.CallId, CommandLogEntryType.RoleAssigned, $"Role {(IncidentRoleType)assignment.RoleType} assigned", userId, cancellationToken); + await WriteLogAsync(assignment.IncidentCommandId, assignment.DepartmentId, assignment.CallId, CommandLogEntryType.RoleAssigned, + $"Role {(IncidentRoleType)assignment.RoleType} assigned to {await ResolveUserLabelAsync(assignment.DepartmentId, assignment.UserId)}", userId, cancellationToken); _eventAggregator.SendMessage(new IncidentRoleAssignedEvent { DepartmentId = assignment.DepartmentId, CallId = assignment.CallId, UserId = assignment.UserId, RoleType = assignment.RoleType }); return assignment; @@ -371,13 +434,14 @@ private async Task EnableAccountabilityIfConfiguredAsync(int departmentId, int c public async Task RemoveIncidentRoleAsync(int departmentId, string incidentRoleAssignmentId, string userId, CancellationToken cancellationToken = default(CancellationToken)) { var assignment = await _incidentRoleAssignmentRepository.GetByIdAsync(incidentRoleAssignmentId); - if (assignment == null || assignment.DepartmentId != departmentId) + if (assignment == null || assignment.DepartmentId != departmentId || !await IsCommandActiveAsync(assignment.IncidentCommandId)) return false; assignment.RemovedOn = DateTime.UtcNow; await _incidentRoleAssignmentRepository.SaveOrUpdateAsync(Touch(assignment), cancellationToken); - await WriteLogAsync(assignment.IncidentCommandId, assignment.DepartmentId, assignment.CallId, CommandLogEntryType.RoleRemoved, $"Role {(IncidentRoleType)assignment.RoleType} removed", userId, cancellationToken); + await WriteLogAsync(assignment.IncidentCommandId, assignment.DepartmentId, assignment.CallId, CommandLogEntryType.RoleRemoved, + $"Role {(IncidentRoleType)assignment.RoleType} removed from {await ResolveUserLabelAsync(assignment.DepartmentId, assignment.UserId)}", userId, cancellationToken); return true; } @@ -434,7 +498,8 @@ public async Task GetCommandBoardAsync(int departmentId, i Accountability = await GetAccountabilityForCallAsync(departmentId, callId), Roles = await GetIncidentRolesAsync(departmentId, callId), Notes = await GetNotesForCallAsync(departmentId, callId), - Attachments = await GetAttachmentsForCallAsync(departmentId, callId) + Attachments = await GetAttachmentsForCallAsync(departmentId, callId), + Maps = await GetIncidentMapsForCallAsync(departmentId, callId) }; return board; @@ -464,6 +529,7 @@ public async Task GetBundleForDepartmentAsync(int departm var roles = ToCallLookup(await _incidentRoleAssignmentRepository.GetAllByDepartmentIdAsync(departmentId), x => x.CallId); var notes = ToCallLookup(await _incidentNoteRepository.GetAllByDepartmentIdAsync(departmentId), x => x.CallId); var attachments = ToCallLookup(await _incidentAttachmentRepository.GetAllMetadataByDepartmentIdAsync(departmentId), x => x.CallId); + var maps = ToCallLookup(await _incidentMapRepository.GetAllByDepartmentIdAsync(departmentId), x => x.CallId); foreach (var command in active) { @@ -482,7 +548,8 @@ public async Task GetBundleForDepartmentAsync(int departm Annotations = annotations[callId].Where(x => x.DeletedOn == null).ToList(), Roles = roles[callId].Where(x => x.RemovedOn == null).ToList(), Notes = notes[callId].Where(x => x.DeletedOn == null).OrderBy(x => x.CreatedOn).ToList(), - Attachments = attachments[callId].Where(x => x.DeletedOn == null).OrderBy(x => x.UploadedOn).ToList() + Attachments = attachments[callId].Where(x => x.DeletedOn == null).OrderBy(x => x.UploadedOn).ToList(), + Maps = maps[callId].Where(x => x.DeletedOn == null).OrderBy(x => x.CreatedOn).ToList() }; // Accountability/PAR is the one per-incident read here, and it is READ-ONLY (no marker writes / SignalR @@ -555,6 +622,10 @@ public async Task GetChangesSinceAsync(int departmentId, if (attachments != null) changes.Attachments = attachments.Where(Changed).ToList(); + var incidentMaps = await _incidentMapRepository.GetAllByDepartmentIdAsync(departmentId); + if (incidentMaps != null) + changes.Maps = incidentMaps.Where(Changed).ToList(); + // The timeline is append-only (no ModifiedOn); its natural cursor is OccurredOn. var timeline = await _commandLogEntryRepository.GetAllByDepartmentIdAsync(departmentId); if (timeline != null) @@ -582,12 +653,229 @@ public async Task GetChangesSinceAsync(int departmentId, return command; } - public async Task TransferCommandAsync(int departmentId, string incidentCommandId, string fromUserId, string toUserId, string notes, CancellationToken cancellationToken = default(CancellationToken)) + public async Task ReopenCommandAsync(int departmentId, string incidentCommandId, string reason, string userId, CancellationToken cancellationToken = default(CancellationToken)) { var command = await _incidentCommandRepository.GetByIdAsync(incidentCommandId); if (command == null || command.DepartmentId != departmentId) return null; + // Idempotent: reopening an already-open command (offline replay / double tap) is a no-op. + if (command.Status == (int)IncidentCommandStatus.Active) + return command; + + var active = await GetActiveCommandForCallAsync(departmentId, command.CallId); + if (active != null) + throw new InvalidOperationException("Another command is already active on this call; close it before reopening this one."); + + command.Status = (int)IncidentCommandStatus.Active; + command.ClosedOn = null; + try + { + command = await _incidentCommandRepository.SaveOrUpdateAsync(Touch(command), cancellationToken); + } + catch (Exception) + { + // Check-then-update races under concurrency; the partial unique index + // (UX_IncidentCommands_Department_Call_Active) is the real guard. + if (await GetActiveCommandForCallAsync(departmentId, command.CallId) != null) + throw new InvalidOperationException("Another command is already active on this call; close it before reopening this one."); + throw; + } + + var reopenedBy = await ResolveUserLabelAsync(departmentId, userId); + var description = string.IsNullOrWhiteSpace(reason) + ? $"Command reopened by {reopenedBy}" + : $"Command reopened by {reopenedBy}: {TrimToLength(reason, 2000)}"; + await WriteLogAsync(command.IncidentCommandId, command.DepartmentId, command.CallId, CommandLogEntryType.CommandReopened, description, userId, cancellationToken); + + _eventAggregator.SendMessage(new IncidentReopenedEvent + { + DepartmentId = command.DepartmentId, + CallId = command.CallId, + IncidentCommandId = command.IncidentCommandId, + Reason = reason, + ReopenedByUserId = userId + }); + return command; + } + + public async Task UpdateCommandInfoAsync(int departmentId, string incidentCommandId, IncidentCommandInfoUpdate update, string userId, CancellationToken cancellationToken = default(CancellationToken)) + { + if (update == null) + throw new ArgumentNullException(nameof(update)); + + var command = await GetOwnedCommandAsync(incidentCommandId, departmentId); + if (command == null) + return null; + + if (update.Name != null) + command.Name = TrimToLength(update.Name, 500); + if (update.EstablishedOn.HasValue) + command.EstablishedOn = update.EstablishedOn.Value; + if (update.EstimatedEndOn.HasValue) + command.EstimatedEndOn = update.EstimatedEndOn; + else if (update.ClearEstimatedEndOn) + command.EstimatedEndOn = null; + if (update.ImportantInformation != null) + command.ImportantInformation = TrimToLength(update.ImportantInformation, 8000); + if (update.IcsLevel.HasValue) + command.IcsLevel = update.IcsLevel.Value; + + var commandPost = await ApplyLocationEditAsync(command.CommandPostLocationText, command.CommandPostLatitude, command.CommandPostLongitude, + update.CommandPostLocationText, update.CommandPostLatitude, update.CommandPostLongitude); + command.CommandPostLocationText = commandPost.text; + command.CommandPostLatitude = commandPost.latitude; + command.CommandPostLongitude = commandPost.longitude; + + var staging = await ApplyLocationEditAsync(command.StagingLocationText, command.StagingLatitude, command.StagingLongitude, + update.StagingLocationText, update.StagingLatitude, update.StagingLongitude); + command.StagingLocationText = staging.text; + command.StagingLatitude = staging.latitude; + command.StagingLongitude = staging.longitude; + + var rehab = await ApplyLocationEditAsync(command.RehabLocationText, command.RehabLatitude, command.RehabLongitude, + update.RehabLocationText, update.RehabLatitude, update.RehabLongitude); + command.RehabLocationText = rehab.text; + command.RehabLatitude = rehab.latitude; + command.RehabLongitude = rehab.longitude; + + command = await _incidentCommandRepository.SaveOrUpdateAsync(Touch(command), cancellationToken); + + await WriteLogAsync(command.IncidentCommandId, command.DepartmentId, command.CallId, CommandLogEntryType.CommandDetailsUpdated, "Incident information updated", userId, cancellationToken); + + if (commandPost.changed) + { + _eventAggregator.SendMessage(new IncidentCommandPostUpdatedEvent + { + DepartmentId = command.DepartmentId, + CallId = command.CallId, + IncidentCommandId = command.IncidentCommandId, + Latitude = command.CommandPostLatitude, + Longitude = command.CommandPostLongitude, + UpdatedByUserId = userId + }); + } + + _eventAggregator.SendMessage(new IncidentCommandDetailsUpdatedEvent + { + DepartmentId = command.DepartmentId, + CallId = command.CallId, + IncidentCommandId = command.IncidentCommandId, + UpdatedByUserId = userId + }); + return command; + } + + public async Task> GetCommandSummariesForDepartmentAsync(int departmentId, bool includeClosed = false) + { + var commands = await _incidentCommandRepository.GetAllByDepartmentIdAsync(departmentId); + var selected = (commands ?? Enumerable.Empty()) + .Where(x => includeClosed || x.Status == (int)IncidentCommandStatus.Active) + .OrderByDescending(x => x.EstablishedOn) + .ToList(); + if (selected.Count == 0) + return new List(); + + // "Assigned" = an unreleased assignment placed in a lane or staging (staging is itself a node type). + var assignments = await _resourceAssignmentRepository.GetAllByDepartmentIdAsync(departmentId); + var assignmentsByCommand = (assignments ?? Enumerable.Empty()) + .Where(a => a.ReleasedOn == null && !string.IsNullOrWhiteSpace(a.CommandStructureNodeId)) + .ToLookup(a => a.IncidentCommandId); + + var profilesById = new Dictionary(StringComparer.OrdinalIgnoreCase); + try + { + var commanderIds = selected.Select(x => x.CurrentCommanderUserId).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct().ToList(); + var profiles = commanderIds.Count > 0 ? await _userProfileService.GetSelectedUserProfilesAsync(commanderIds) : null; + foreach (var profile in (profiles ?? new List()).Where(p => p != null && !string.IsNullOrWhiteSpace(p.UserId))) + profilesById[profile.UserId] = profile; + } + catch (Exception ex) + { + Resgrid.Framework.Logging.LogException(ex); + } + + static bool IsUnitKind(int kind) => kind == (int)ResourceAssignmentKind.RealUnit || + kind == (int)ResourceAssignmentKind.LinkedDeptUnit || kind == (int)ResourceAssignmentKind.AdHocUnit; + + var summaries = new List(); + foreach (var command in selected) + { + Call call = null; + try + { + call = await _callsService.GetCallByIdAsync(command.CallId); + } + catch (Exception ex) + { + Resgrid.Framework.Logging.LogException(ex); + } + + var activeAssignments = assignmentsByCommand[command.IncidentCommandId].ToList(); + profilesById.TryGetValue(command.CurrentCommanderUserId ?? string.Empty, out var commanderProfile); + + summaries.Add(new IncidentCommandSummary + { + IncidentCommandId = command.IncidentCommandId, + DepartmentId = command.DepartmentId, + CallId = command.CallId, + Name = command.Name, + CallName = call?.Name, + CallNumber = call?.Number, + CallAddress = call?.Address, + Status = command.Status, + EstablishedOn = command.EstablishedOn, + ClosedOn = command.ClosedOn, + CommanderUserId = command.CurrentCommanderUserId, + CommanderName = commanderProfile != null ? commanderProfile.FullName.AsFirstNameLastName : command.CurrentCommanderUserId, + CommandPostLocationText = command.CommandPostLocationText, + CommandPostLatitude = command.CommandPostLatitude, + CommandPostLongitude = command.CommandPostLongitude, + AssignedUnitCount = activeAssignments.Count(a => IsUnitKind(a.ResourceKind)), + AssignedPersonnelCount = activeAssignments.Count(a => !IsUnitKind(a.ResourceKind)) + }); + } + + return summaries; + } + + public async Task GetCommandBoardByIdAsync(int departmentId, string incidentCommandId) + { + var command = await GetOwnedCommandAsync(incidentCommandId, departmentId, requireActive: false); + if (command == null) + return null; + + var callId = command.CallId; + + // A call can carry several commands over its life (closed ones plus at most one active), and the + // per-call getters return rows across all of them — filter every child set to THIS command. + var board = new IncidentCommandBoard + { + Command = command, + Nodes = (await GetNodesForCallAsync(departmentId, callId)).Where(x => x.IncidentCommandId == incidentCommandId).ToList(), + Assignments = (await GetAssignmentsForCallAsync(departmentId, callId)).Where(x => x.IncidentCommandId == incidentCommandId && x.ReleasedOn == null).ToList(), + Objectives = (await GetObjectivesForCallAsync(departmentId, callId)).Where(x => x.IncidentCommandId == incidentCommandId).ToList(), + Needs = (await GetNeedsForCallAsync(departmentId, callId)).Where(x => x.IncidentCommandId == incidentCommandId).ToList(), + Timers = (await GetActiveTimersForCallAsync(departmentId, callId)).Where(x => x.IncidentCommandId == incidentCommandId).ToList(), + Annotations = (await GetAnnotationsForCallAsync(departmentId, callId)).Where(x => x.IncidentCommandId == incidentCommandId).ToList(), + Accountability = command.Status == (int)IncidentCommandStatus.Active + ? await GetAccountabilityForCallAsync(departmentId, callId) + : new List(), + Roles = (await GetIncidentRolesAsync(departmentId, callId)).Where(x => x.IncidentCommandId == incidentCommandId).ToList(), + Notes = (await GetNotesForCallAsync(departmentId, callId)).Where(x => x.IncidentCommandId == incidentCommandId).ToList(), + Attachments = (await GetAttachmentsForCallAsync(departmentId, callId)).Where(x => x.IncidentCommandId == incidentCommandId).ToList(), + Maps = (await GetIncidentMapsForCallAsync(departmentId, callId)).Where(x => x.IncidentCommandId == incidentCommandId).ToList() + }; + + return board; + } + + public async Task TransferCommandAsync(int departmentId, string incidentCommandId, string fromUserId, string toUserId, string notes, CancellationToken cancellationToken = default(CancellationToken)) + { + var command = await _incidentCommandRepository.GetByIdAsync(incidentCommandId); + if (command == null || command.DepartmentId != departmentId || command.Status != (int)IncidentCommandStatus.Active) + return null; + command.CurrentCommanderUserId = toUserId; await _incidentCommandRepository.SaveOrUpdateAsync(Touch(command), cancellationToken); @@ -604,7 +892,8 @@ public async Task GetChangesSinceAsync(int departmentId, }; transfer = await _commandTransferRepository.InsertAsync(transfer, cancellationToken); - await WriteLogAsync(command.IncidentCommandId, command.DepartmentId, command.CallId, CommandLogEntryType.CommandTransferred, "Command transferred", fromUserId, cancellationToken); + await WriteLogAsync(command.IncidentCommandId, command.DepartmentId, command.CallId, CommandLogEntryType.CommandTransferred, + $"Command transferred from {await ResolveUserLabelAsync(departmentId, fromUserId)} to {await ResolveUserLabelAsync(departmentId, toUserId)}", fromUserId, cancellationToken); _eventAggregator.SendMessage(new CommandTransferredEvent { DepartmentId = command.DepartmentId, CallId = command.CallId, IncidentCommandId = incidentCommandId, FromUserId = fromUserId, ToUserId = toUserId }); @@ -615,7 +904,7 @@ public async Task GetChangesSinceAsync(int departmentId, public async Task UpdateActionPlanAsync(int departmentId, string incidentCommandId, string actionPlan, string userId, CancellationToken cancellationToken = default(CancellationToken)) { var command = await _incidentCommandRepository.GetByIdAsync(incidentCommandId); - if (command == null || command.DepartmentId != departmentId) + if (command == null || command.DepartmentId != departmentId || command.Status != (int)IncidentCommandStatus.Active) return null; command.IncidentActionPlan = actionPlan; @@ -854,7 +1143,14 @@ await _incidentCommandNotificationService.NotifyLaneLeadChangedAsync(saved.Depar note.DeletedByUserId = null; var saved = await _incidentNoteRepository.InsertAsync(Touch(note), cancellationToken); - await WriteLogAsync(command.IncidentCommandId, command.DepartmentId, command.CallId, CommandLogEntryType.IncidentNoteAdded, $"Incident note added: {saved.Title ?? ((IncidentNoteType)saved.NoteType).ToString()}", userId, cancellationToken); + + // Public notes land verbatim on the incident log; non-public notes only leave an attributed marker + // (author, their command/ICS standing, and the note id) so the log never carries a private body. + var author = await BuildNoteAuthorLabelAsync(command, userId); + var logText = saved.Visibility == (int)IncidentContentVisibility.Public + ? $"Note from {author}: {saved.Body}" + : $"{author} added a non-public note (note id {saved.IncidentNoteId})"; + await WriteLogAsync(command.IncidentCommandId, command.DepartmentId, command.CallId, CommandLogEntryType.IncidentNoteAdded, logText, userId, cancellationToken); _eventAggregator.SendMessage(new IncidentNoteAddedEvent { DepartmentId = saved.DepartmentId, @@ -886,7 +1182,7 @@ public async Task> GetNotesForCallAsync(int departmentId, int public async Task RemoveNoteAsync(int departmentId, string incidentNoteId, string userId, CancellationToken cancellationToken = default(CancellationToken)) { var note = await _incidentNoteRepository.GetByIdAsync(incidentNoteId); - if (note == null || note.DepartmentId != departmentId || note.DeletedOn.HasValue) + if (note == null || note.DepartmentId != departmentId || note.DeletedOn.HasValue || !await IsCommandActiveAsync(note.IncidentCommandId)) return false; var capabilities = await GetCapabilitiesForUserAsync(departmentId, note.CallId, userId); var required = IncidentCapabilities.ManageNotes; @@ -981,7 +1277,7 @@ public async Task GetAttachmentAsync(int departmentId, strin public async Task RemoveAttachmentAsync(int departmentId, string incidentAttachmentId, string userId, CancellationToken cancellationToken = default(CancellationToken)) { var attachment = await _incidentAttachmentRepository.GetByIdAsync(incidentAttachmentId); - if (attachment == null || attachment.DepartmentId != departmentId || attachment.DeletedOn.HasValue) + if (attachment == null || attachment.DepartmentId != departmentId || attachment.DeletedOn.HasValue || !await IsCommandActiveAsync(attachment.IncidentCommandId)) return false; var capabilities = await GetCapabilitiesForUserAsync(departmentId, attachment.CallId, userId); var required = IncidentCapabilities.ManageDocuments; @@ -1009,7 +1305,8 @@ public async Task GetAttachmentAsync(int departmentId, strin public async Task EnablePublicSharingAsync(int departmentId, string incidentCommandId, string userId, CancellationToken cancellationToken = default(CancellationToken)) { - var command = await GetOwnedCommandAsync(incidentCommandId, departmentId); + // Sharing toggles stay available after close — a public feed may need to be published/pulled post-incident. + var command = await GetOwnedCommandAsync(incidentCommandId, departmentId, requireActive: false); if (command == null) return null; @@ -1031,7 +1328,7 @@ public async Task GetAttachmentAsync(int departmentId, strin public async Task DisablePublicSharingAsync(int departmentId, string incidentCommandId, string userId, CancellationToken cancellationToken = default(CancellationToken)) { - var command = await GetOwnedCommandAsync(incidentCommandId, departmentId); + var command = await GetOwnedCommandAsync(incidentCommandId, departmentId, requireActive: false); if (command == null) return null; @@ -1169,7 +1466,7 @@ await WriteLogAsync(node.IncidentCommandId, node.DepartmentId, node.CallId, public async Task DeleteNodeAsync(int departmentId, string commandStructureNodeId, string userId, CancellationToken cancellationToken = default(CancellationToken)) { var node = await _commandStructureNodeRepository.GetByIdAsync(commandStructureNodeId); - if (node == null || node.DepartmentId != departmentId) + if (node == null || node.DepartmentId != departmentId || !await IsCommandActiveAsync(node.IncidentCommandId)) return false; // Soft-delete (tombstone) rather than hard-delete so the removal propagates to offline clients on the @@ -1241,7 +1538,9 @@ public async Task> GetNodesForCallAsync(int departmen return null; assignment = saved; - await WriteLogAsync(assignment.IncidentCommandId, assignment.DepartmentId, assignment.CallId, CommandLogEntryType.ResourceAssigned, "Resource assigned", userId, cancellationToken); + var assignedLabel = await ResolveResourceLabelAsync(assignment.DepartmentId, assignment.ResourceKind, assignment.ResourceId); + await WriteLogAsync(assignment.IncidentCommandId, assignment.DepartmentId, assignment.CallId, CommandLogEntryType.ResourceAssigned, + string.IsNullOrWhiteSpace(assignedLaneName) ? $"Resource assigned: {assignedLabel}" : $"Resource assigned: {assignedLabel} to lane '{assignedLaneName}'", userId, cancellationToken); _eventAggregator.SendMessage(new IncidentResourceAssignedEvent { DepartmentId = assignment.DepartmentId, CallId = assignment.CallId, IncidentCommandId = assignment.IncidentCommandId, ResourceKind = assignment.ResourceKind, ResourceId = assignment.ResourceId }); @@ -1252,7 +1551,7 @@ public async Task> GetNodesForCallAsync(int departmen public async Task MoveResourceAsync(int departmentId, string resourceAssignmentId, string targetNodeId, string userId, CancellationToken cancellationToken = default(CancellationToken)) { var assignment = await _resourceAssignmentRepository.GetByIdAsync(resourceAssignmentId); - if (assignment == null || assignment.DepartmentId != departmentId) + if (assignment == null || assignment.DepartmentId != departmentId || !await IsCommandActiveAsync(assignment.IncidentCommandId)) return null; // The target lane must exist and live on the SAME incident (department + call) as the assignment; @@ -1291,7 +1590,8 @@ public async Task> GetNodesForCallAsync(int departmen assignment.CommandStructureNodeId = targetNodeId; assignment = await _resourceAssignmentRepository.SaveOrUpdateAsync(Touch(assignment), cancellationToken); - await WriteLogAsync(assignment.IncidentCommandId, assignment.DepartmentId, assignment.CallId, CommandLogEntryType.ResourceMoved, "Resource moved", userId, cancellationToken); + await WriteLogAsync(assignment.IncidentCommandId, assignment.DepartmentId, assignment.CallId, CommandLogEntryType.ResourceMoved, + $"Resource moved: {await ResolveResourceLabelAsync(assignment.DepartmentId, assignment.ResourceKind, assignment.ResourceId)} to lane '{targetNode.Name}'", userId, cancellationToken); _eventAggregator.SendMessage(new IncidentResourceMovedEvent { @@ -1315,13 +1615,14 @@ public async Task> GetNodesForCallAsync(int departmen public async Task ReleaseResourceAsync(int departmentId, string resourceAssignmentId, string userId, CancellationToken cancellationToken = default(CancellationToken)) { var assignment = await _resourceAssignmentRepository.GetByIdAsync(resourceAssignmentId); - if (assignment == null || assignment.DepartmentId != departmentId) + if (assignment == null || assignment.DepartmentId != departmentId || !await IsCommandActiveAsync(assignment.IncidentCommandId)) return false; assignment.ReleasedOn = DateTime.UtcNow; await _resourceAssignmentRepository.SaveOrUpdateAsync(Touch(assignment), cancellationToken); - await WriteLogAsync(assignment.IncidentCommandId, assignment.DepartmentId, assignment.CallId, CommandLogEntryType.ResourceReleased, "Resource released", userId, cancellationToken); + await WriteLogAsync(assignment.IncidentCommandId, assignment.DepartmentId, assignment.CallId, CommandLogEntryType.ResourceReleased, + $"Resource released: {await ResolveResourceLabelAsync(assignment.DepartmentId, assignment.ResourceKind, assignment.ResourceId)}", userId, cancellationToken); _eventAggregator.SendMessage(new IncidentResourceReleasedEvent { DepartmentId = assignment.DepartmentId, CallId = assignment.CallId, ResourceAssignmentId = assignment.ResourceAssignmentId }); @@ -1473,19 +1774,29 @@ public async Task> GetAssignmentsForCallAsync(int depar return objective; } - public async Task CompleteObjectiveAsync(int departmentId, string tacticalObjectiveId, string userId, CancellationToken cancellationToken = default(CancellationToken)) + public async Task CompleteObjectiveAsync(int departmentId, string tacticalObjectiveId, string userId, TacticalObjectiveOutcome outcome = TacticalObjectiveOutcome.NotSet, string note = null, CancellationToken cancellationToken = default(CancellationToken)) { var objective = await _tacticalObjectiveRepository.GetByIdAsync(tacticalObjectiveId); - if (objective == null || objective.DepartmentId != departmentId) + if (objective == null || objective.DepartmentId != departmentId || !await IsCommandActiveAsync(objective.IncidentCommandId)) return null; objective.Status = (int)TacticalObjectiveStatus.Complete; objective.ProgressPercent = 100; objective.CompletedByUserId = userId; objective.CompletedOn = DateTime.UtcNow; + objective.Outcome = (int)outcome; + objective.CompletionNote = TrimToLength(note, 2000); objective = await _tacticalObjectiveRepository.SaveOrUpdateAsync(Touch(objective), cancellationToken); - await WriteLogAsync(objective.IncidentCommandId, objective.DepartmentId, objective.CallId, CommandLogEntryType.ObjectiveCompleted, $"Objective '{objective.Name}' completed", userId, cancellationToken); + // The log carries the close-out audit: outcome, author (never a GUID), and the note. + var description = $"Objective '{objective.Name}' completed"; + if (outcome != TacticalObjectiveOutcome.NotSet) + description = $"{description} — {outcome}"; + description = $"{description} by {await ResolveUserLabelAsync(departmentId, userId)}"; + if (!string.IsNullOrWhiteSpace(objective.CompletionNote)) + description = $"{description}: {objective.CompletionNote}"; + + await WriteLogAsync(objective.IncidentCommandId, objective.DepartmentId, objective.CallId, CommandLogEntryType.ObjectiveCompleted, description, userId, cancellationToken); _eventAggregator.SendMessage(new IncidentObjectiveCompletedEvent { DepartmentId = objective.DepartmentId, CallId = objective.CallId, IncidentCommandId = objective.IncidentCommandId, TacticalObjectiveId = objective.TacticalObjectiveId, Name = objective.Name }); return objective; @@ -1503,15 +1814,16 @@ public async Task> GetObjectivesForCallAsync(int departm 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) + if (objective == null || objective.DepartmentId != departmentId || !await IsCommandActiveAsync(objective.IncidentCommandId)) 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. + // CompletedBy/On behave exactly as an explicit CompleteObjective would. Reaching 100% via + // progress implies the objective was achieved, so it closes out as Successful. if (progressPercent == 100) - return await CompleteObjectiveAsync(departmentId, tacticalObjectiveId, userId, cancellationToken); + return await CompleteObjectiveAsync(departmentId, tacticalObjectiveId, userId, TacticalObjectiveOutcome.Successful, null, cancellationToken); objective.ProgressPercent = progressPercent; if (objective.Status == (int)TacticalObjectiveStatus.Pending && progressPercent > 0) @@ -1576,13 +1888,17 @@ await WriteLogAsync(need.IncidentCommandId, need.DepartmentId, need.CallId, return need; } - public async Task SetNeedStatusAsync(int departmentId, string incidentNeedId, IncidentNeedStatus status, int? quantityFulfilled, string userId, CancellationToken cancellationToken = default(CancellationToken)) + public async Task SetNeedStatusAsync(int departmentId, string incidentNeedId, IncidentNeedStatus status, int? quantityFulfilled, string userId, string note = null, CancellationToken cancellationToken = default(CancellationToken)) { var need = await _incidentNeedRepository.GetByIdAsync(incidentNeedId); - if (need == null || need.DepartmentId != departmentId) + if (need == null || need.DepartmentId != departmentId || !await IsCommandActiveAsync(need.IncidentCommandId)) return null; + var previousStatus = need.Status; + var previousQuantityFulfilled = need.QuantityFulfilled; + need.Status = (int)status; + // The fulfilled quantity may move DOWN as well as up (a filling resource got called off to another incident). if (quantityFulfilled.HasValue) need.QuantityFulfilled = Math.Max(0, quantityFulfilled.Value); @@ -1601,9 +1917,43 @@ await WriteLogAsync(need.IncidentCommandId, need.DepartmentId, need.CallId, need = await _incidentNeedRepository.SaveOrUpdateAsync(Touch(need), cancellationToken); + note = TrimToLength(note, 2000); + + // Append-only audit row: who changed what (status and/or fill), when, and their note. + await _incidentNeedUpdateRepository.InsertAsync(new IncidentNeedUpdate + { + IncidentNeedUpdateId = Guid.NewGuid().ToString(), + IncidentNeedId = need.IncidentNeedId, + IncidentCommandId = need.IncidentCommandId, + DepartmentId = need.DepartmentId, + CallId = need.CallId, + PreviousStatus = previousStatus, + NewStatus = need.Status, + PreviousQuantityFulfilled = previousQuantityFulfilled, + NewQuantityFulfilled = need.QuantityFulfilled, + Note = note, + CreatedByUserId = userId, + CreatedOn = DateTime.UtcNow + }, cancellationToken); + + var actor = await ResolveUserLabelAsync(departmentId, userId); + string change; + if (status == IncidentNeedStatus.Met) + change = "filled"; + else if (status == IncidentNeedStatus.Cancelled) + change = need.QuantityRequested > 0 && need.QuantityFulfilled < need.QuantityRequested ? "closed without being filled" : "closed"; + else if (need.QuantityFulfilled != previousQuantityFulfilled) + change = $"fill changed {previousQuantityFulfilled} to {need.QuantityFulfilled}{(need.QuantityRequested > 0 ? $" of {need.QuantityRequested}" : string.Empty)}"; + else + change = $"status changed to {status}"; + + var description = $"Need '{need.Name}' {change} by {actor}"; + if (!string.IsNullOrWhiteSpace(note)) + description = $"{description}: {note}"; + 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); + description, userId, cancellationToken); _eventAggregator.SendMessage(new IncidentNeedChangedEvent { @@ -1626,6 +1976,323 @@ public async Task> GetNeedsForCallAsync(int departmentId, int return items.Where(x => x.CallId == callId).OrderBy(x => x.SortOrder).ThenBy(x => x.CreatedOn).ToList(); } + public async Task> GetNeedUpdatesAsync(int departmentId, string incidentNeedId) + { + if (string.IsNullOrWhiteSpace(incidentNeedId)) + return new List(); + + var items = await _incidentNeedUpdateRepository.GetAllByDepartmentIdAsync(departmentId); + var updates = (items ?? Enumerable.Empty()) + .Where(x => string.Equals(x.IncidentNeedId, incidentNeedId, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(x => x.CreatedOn) + .ToList(); + + // Resolve author display names in one profile batch so the UI never shows a raw user GUID. + try + { + var userIds = updates.Select(x => x.CreatedByUserId).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct().ToList(); + var profiles = userIds.Count > 0 ? await _userProfileService.GetSelectedUserProfilesAsync(userIds) : null; + var profilesById = (profiles ?? new List()).Where(p => p != null && !string.IsNullOrWhiteSpace(p.UserId)) + .GroupBy(p => p.UserId).ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + + foreach (var update in updates) + { + update.CreatedByUserName = !string.IsNullOrWhiteSpace(update.CreatedByUserId) && profilesById.TryGetValue(update.CreatedByUserId, out var profile) + ? profile.FullName.AsFirstNameLastName + : update.CreatedByUserId; + } + } + catch (Exception ex) + { + Resgrid.Framework.Logging.LogException(ex); + } + + return updates; + } + + public async Task RequestNeedEntitiesAsync(int departmentId, string incidentCommandId, string name, string description, List entities, string userId, CancellationToken cancellationToken = default(CancellationToken)) + { + if (string.IsNullOrWhiteSpace(name) || entities == null || entities.Count == 0) + throw new ArgumentException("A need name and at least one requested entity are required."); + if (entities.Any(e => !Enum.IsDefined(typeof(NeedEntityKind), e.EntityKind) || string.IsNullOrWhiteSpace(e.EntityId))) + throw new ArgumentException("Every requested entity needs a valid kind and id."); + + var command = await GetOwnedCommandAsync(incidentCommandId, departmentId); + if (command == null) + return null; + + var need = new IncidentNeed + { + IncidentNeedId = Guid.NewGuid().ToString(), + IncidentCommandId = command.IncidentCommandId, + DepartmentId = departmentId, + CallId = command.CallId, + Name = TrimToLength(name, 500), + Description = TrimToLength(description, 2000), + Category = (int)IncidentNeedCategory.Entity, + Status = (int)IncidentNeedStatus.Open, + QuantityRequested = entities.Count, + CreatedByUserId = userId, + CreatedOn = DateTime.UtcNow + }; + need = await _incidentNeedRepository.InsertAsync(Touch(need), cancellationToken); + + // Resolve display names + persist the request rows. + var labels = new List(); + foreach (var entity in entities) + { + entity.IncidentNeedEntityId = Guid.NewGuid().ToString(); + entity.IncidentNeedId = need.IncidentNeedId; + entity.IncidentCommandId = command.IncidentCommandId; + entity.DepartmentId = departmentId; + entity.CallId = command.CallId; + entity.CreatedByUserId = userId; + entity.CreatedOn = DateTime.UtcNow; + entity.EntityName = TrimToLength(await ResolveNeedEntityNameAsync(departmentId, entity), 500); + labels.Add(entity.EntityName ?? entity.EntityId); + } + + // Add the entities to the call and dispatch ONLY them (no full re-dispatch), tagged as a + // command request. Failures here must not lose the need itself. + var dispatched = false; + try + { + dispatched = await DispatchNeedEntitiesAsync(departmentId, command.CallId, entities, cancellationToken); + } + catch (Exception ex) + { + Resgrid.Framework.Logging.LogException(ex); + } + + foreach (var entity in entities) + { + if (dispatched) + entity.DispatchedOn = DateTime.UtcNow; + await _incidentNeedEntityRepository.InsertAsync(entity, cancellationToken); + } + + var author = await BuildNoteAuthorLabelAsync(command, userId); + await WriteLogAsync(need.IncidentCommandId, need.DepartmentId, need.CallId, CommandLogEntryType.NeedAdded, + $"Need '{need.Name}' requested by {author}: {string.Join(", ", labels)}{(dispatched ? " — dispatched to the call individually" : string.Empty)}", 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> GetNeedEntitiesAsync(int departmentId, string incidentNeedId) + { + if (string.IsNullOrWhiteSpace(incidentNeedId)) + return new List(); + + var items = await _incidentNeedEntityRepository.GetAllByDepartmentIdAsync(departmentId); + return (items ?? Enumerable.Empty()) + .Where(x => string.Equals(x.IncidentNeedId, incidentNeedId, StringComparison.OrdinalIgnoreCase)) + .OrderBy(x => x.CreatedOn) + .ToList(); + } + + public async Task RecordNeedEntityStatusAsync(int departmentId, int callId, NeedEntityKind entityKind, string entityId, string statusText, string savedByUserId, CancellationToken cancellationToken = default(CancellationToken)) + { + try + { + var command = await GetActiveCommandForCallAsync(departmentId, callId); + if (command == null) + return; + + var all = await _incidentNeedEntityRepository.GetAllByDepartmentIdAsync(departmentId); + var callEntities = (all ?? Enumerable.Empty()).Where(x => x.CallId == callId).ToList(); + if (callEntities.Count == 0) + return; + + // Direct match (the unit/user itself was requested) … + var matches = callEntities.Where(x => x.EntityKind == (int)entityKind && string.Equals(x.EntityId, entityId, StringComparison.OrdinalIgnoreCase)).ToList(); + + // … or an indirect match: the responder satisfies a requested role/group. + if (entityKind == NeedEntityKind.User) + { + if (callEntities.Any(x => x.EntityKind == (int)NeedEntityKind.Group)) + { + var group = await _departmentGroupsService.GetGroupForUserAsync(entityId, departmentId); + if (group != null) + matches.AddRange(callEntities.Where(x => x.EntityKind == (int)NeedEntityKind.Group && x.EntityId == group.DepartmentGroupId.ToString())); + } + if (callEntities.Any(x => x.EntityKind == (int)NeedEntityKind.Role)) + { + var roles = await _personnelRolesService.GetRolesForUserAsync(entityId, departmentId); + var roleIds = new HashSet((roles ?? new List()).Select(r => r.PersonnelRoleId.ToString())); + matches.AddRange(callEntities.Where(x => x.EntityKind == (int)NeedEntityKind.Role && roleIds.Contains(x.EntityId))); + } + } + else if (entityKind == NeedEntityKind.Unit && callEntities.Any(x => x.EntityKind == (int)NeedEntityKind.Group) && int.TryParse(entityId, out var respondingUnitId)) + { + var unit = await _unitsService.GetUnitByIdAsync(respondingUnitId); + if (unit?.StationGroupId != null) + matches.AddRange(callEntities.Where(x => x.EntityKind == (int)NeedEntityKind.Group && x.EntityId == unit.StationGroupId.Value.ToString())); + } + + if (matches.Count == 0) + return; + + var needs = await _incidentNeedRepository.GetAllByDepartmentIdAsync(departmentId); + var needNamesById = (needs ?? Enumerable.Empty()).GroupBy(n => n.IncidentNeedId).ToDictionary(g => g.Key, g => g.First().Name); + + var label = entityKind == NeedEntityKind.Unit && int.TryParse(entityId, out var labelUnitId) + ? await ResolveUnitLabelAsync(labelUnitId) + : await ResolveUserLabelAsync(departmentId, entityId); + + foreach (var needId in matches.Select(m => m.IncidentNeedId).Distinct()) + { + var needName = needNamesById.TryGetValue(needId, out var resolved) ? resolved : "Entity request"; + await WriteLogAsync(command.IncidentCommandId, departmentId, callId, CommandLogEntryType.NeedUpdated, + $"{label} responded to need '{needName}' — {statusText}", savedByUserId, cancellationToken); + } + } + catch (Exception ex) + { + // A response-logging failure must never break the underlying status save. + Resgrid.Framework.Logging.LogException(ex); + } + } + + /// Display-name snapshot for a requested entity (never a raw id in log text). + private async Task ResolveNeedEntityNameAsync(int departmentId, IncidentNeedEntity entity) + { + try + { + switch ((NeedEntityKind)entity.EntityKind) + { + case NeedEntityKind.Unit: + if (int.TryParse(entity.EntityId, out var unitId)) + return await ResolveUnitLabelAsync(unitId); + break; + case NeedEntityKind.User: + return await ResolveUserLabelAsync(departmentId, entity.EntityId); + case NeedEntityKind.Role: + if (int.TryParse(entity.EntityId, out var roleId)) + { + var role = await _personnelRolesService.GetRoleByIdAsync(roleId); + if (role != null) + return $"role '{role.Name}'"; + } + break; + case NeedEntityKind.Group: + if (int.TryParse(entity.EntityId, out var groupId)) + { + var group = await _departmentGroupsService.GetGroupByIdAsync(groupId); + if (group != null) + return $"group '{group.Name}'"; + } + break; + } + } + catch (Exception ex) + { + Resgrid.Framework.Logging.LogException(ex); + } + return entity.EntityId; + } + + /// + /// Adds the requested entities to the call's dispatch lists and broadcasts ONLY them (the same + /// subset-dispatch primitive EditCall uses), tagging the notification as a command request. + /// + private async Task DispatchNeedEntitiesAsync(int departmentId, int callId, List entities, CancellationToken cancellationToken) + { + var call = await _callsService.GetCallByIdAsync(callId); + if (call == null || call.DepartmentId != departmentId) + return false; + call = await _callsService.PopulateCallData(call, true, false, false, true, true, true, false, false, false); + + // Defensive: a call with no prior dispatches of a type can come back with a null collection. + call.Dispatches ??= new List(); + call.GroupDispatches ??= new List(); + call.UnitDispatches ??= new List(); + call.RoleDispatches ??= new List(); + + var newUserIds = new List(); + var newGroupIds = new List(); + var newUnitIds = new List(); + var newRoleIds = new List(); + + foreach (var entity in entities) + { + switch ((NeedEntityKind)entity.EntityKind) + { + case NeedEntityKind.Unit: + if (int.TryParse(entity.EntityId, out var unitId) && !call.HasUnitBeenDispatched(unitId)) + { + call.UnitDispatches.Add(new CallDispatchUnit { CallId = call.CallId, UnitId = unitId }); + newUnitIds.Add(unitId); + } + break; + case NeedEntityKind.User: + if (!call.HasUserBeenDispatched(entity.EntityId)) + { + call.Dispatches.Add(new CallDispatch { CallId = call.CallId, UserId = entity.EntityId }); + newUserIds.Add(entity.EntityId); + } + break; + case NeedEntityKind.Group: + if (int.TryParse(entity.EntityId, out var groupId) && !call.HasGroupBeenDispatched(groupId)) + { + call.GroupDispatches.Add(new CallDispatchGroup { CallId = call.CallId, DepartmentGroupId = groupId }); + newGroupIds.Add(groupId); + } + break; + case NeedEntityKind.Role: + if (int.TryParse(entity.EntityId, out var roleId) && (call.RoleDispatches == null || !call.RoleDispatches.Any(r => r.RoleId == roleId))) + { + call.RoleDispatches.Add(new CallDispatchRole { CallId = call.CallId, RoleId = roleId }); + newRoleIds.Add(roleId); + } + break; + } + } + + if (newUserIds.Count == 0 && newGroupIds.Count == 0 && newUnitIds.Count == 0 && newRoleIds.Count == 0) + return false; + + call = await _callsService.SaveCallAsync(call, cancellationToken); + + try + { + await _callDispatchStatusService.ApplyDispatchStatusesAsync(call, newGroupIds, newUnitIds, cancellationToken); + } + catch (Exception ex) + { + Resgrid.Framework.Logging.LogException(ex); + } + + var cqi = new CallQueueItem { Call = call }; + cqi.SetBroadcastDispatches(newUserIds, newGroupIds, newUnitIds, newRoleIds); + // The queue item's Call is a deep clone used only for notification content — tag it so the + // dispatched resources see this came from Incident Command, without touching the stored call. + cqi.Call.NatureOfCall = string.IsNullOrWhiteSpace(cqi.Call.NatureOfCall) + ? "Requested by Incident Command" + : $"{cqi.Call.NatureOfCall} [Requested by Incident Command]"; + + try + { + var profiles = await _userProfileService.GetAllProfilesForDepartmentAsync(departmentId); + cqi.Profiles = profiles?.Values.ToList(); + } + catch (Exception ex) + { + Resgrid.Framework.Logging.LogException(ex); + } + + await _queueService.EnqueueCallBroadcastAsync(cqi, cancellationToken); + return true; + } + #endregion Needs #region Timers @@ -1664,7 +2331,7 @@ public async Task> GetNeedsForCallAsync(int departmentId, int public async Task AcknowledgeTimerAsync(int departmentId, string incidentTimerId, string userId, CancellationToken cancellationToken = default(CancellationToken)) { var timer = await _incidentTimerRepository.GetByIdAsync(incidentTimerId); - if (timer == null || timer.DepartmentId != departmentId) + if (timer == null || timer.DepartmentId != departmentId || !await IsCommandActiveAsync(timer.IncidentCommandId)) return null; timer.AcknowledgedOn = DateTime.UtcNow; @@ -1716,8 +2383,12 @@ public async Task> GetActiveTimersForCallAsync(int departmen return null; annotation = saved; - if (isNew) - await WriteLogAsync(annotation.IncidentCommandId, annotation.DepartmentId, annotation.CallId, CommandLogEntryType.AnnotationAdded, "Map annotation added", userId, cancellationToken); + // Every map edit is logged with the author's name and command/ICS standing (spec: who + role + when). + var annotationCommand = await GetCommandByIdAsync(annotation.IncidentCommandId); + var annotationAuthor = annotationCommand != null ? await BuildNoteAuthorLabelAsync(annotationCommand, userId) : await ResolveUserLabelAsync(annotation.DepartmentId, userId); + var annotationKind = string.IsNullOrWhiteSpace(annotation.Label) ? ((IncidentMapAnnotationType)annotation.AnnotationType).ToString() : $"{(IncidentMapAnnotationType)annotation.AnnotationType} '{annotation.Label}'"; + await WriteLogAsync(annotation.IncidentCommandId, annotation.DepartmentId, annotation.CallId, CommandLogEntryType.AnnotationAdded, + $"Incident map {(isNew ? "markup added" : "markup updated")} ({annotationKind}) by {annotationAuthor}", userId, cancellationToken); return annotation; } @@ -1725,13 +2396,16 @@ public async Task> GetActiveTimersForCallAsync(int departmen public async Task DeleteAnnotationAsync(int departmentId, string incidentMapAnnotationId, string userId, CancellationToken cancellationToken = default(CancellationToken)) { var annotation = await _incidentMapAnnotationRepository.GetByIdAsync(incidentMapAnnotationId); - if (annotation == null || annotation.DepartmentId != departmentId) + if (annotation == null || annotation.DepartmentId != departmentId || !await IsCommandActiveAsync(annotation.IncidentCommandId)) return false; annotation.DeletedOn = DateTime.UtcNow; await _incidentMapAnnotationRepository.SaveOrUpdateAsync(Touch(annotation), cancellationToken); - await WriteLogAsync(annotation.IncidentCommandId, annotation.DepartmentId, annotation.CallId, CommandLogEntryType.AnnotationRemoved, "Map annotation removed", userId, cancellationToken); + var removalCommand = await GetCommandByIdAsync(annotation.IncidentCommandId); + var removalAuthor = removalCommand != null ? await BuildNoteAuthorLabelAsync(removalCommand, userId) : await ResolveUserLabelAsync(annotation.DepartmentId, userId); + await WriteLogAsync(annotation.IncidentCommandId, annotation.DepartmentId, annotation.CallId, CommandLogEntryType.AnnotationRemoved, + $"Incident map markup removed by {removalAuthor}", userId, cancellationToken); return true; } @@ -1744,6 +2418,101 @@ public async Task> GetAnnotationsForCallAsync(int de return items.Where(x => x.CallId == callId && x.DeletedOn == null).ToList(); } + public async Task UpdateMapViewAsync(int departmentId, string incidentCommandId, string centerLatitude, string centerLongitude, string zoomLevel, string userId, CancellationToken cancellationToken = default(CancellationToken)) + { + if (!TryParseCoordinates(centerLatitude, centerLongitude, out var latitudeValue, out var longitudeValue)) + throw new ArgumentException("A valid map center latitude and longitude are required."); + if (!decimal.TryParse(zoomLevel?.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out var zoomValue) || zoomValue < 0 || zoomValue > 22) + throw new ArgumentException("A valid map zoom level (0-22) is required."); + + var command = await GetOwnedCommandAsync(incidentCommandId, departmentId); + if (command == null) + return null; + + var isNew = string.IsNullOrWhiteSpace(command.MapZoomLevel); + command.MapCenterLatitude = latitudeValue.ToString("0.######", CultureInfo.InvariantCulture); + command.MapCenterLongitude = longitudeValue.ToString("0.######", CultureInfo.InvariantCulture); + command.MapZoomLevel = zoomValue.ToString("0.##", CultureInfo.InvariantCulture); + command = await _incidentCommandRepository.SaveOrUpdateAsync(Touch(command), cancellationToken); + + // Log with the author's name and command/ICS standing (spec: who + role + when). + var author = await BuildNoteAuthorLabelAsync(command, userId); + await WriteLogAsync(command.IncidentCommandId, command.DepartmentId, command.CallId, CommandLogEntryType.MapViewUpdated, + $"Incident map {(isNew ? "created" : "view updated")} by {author}", userId, cancellationToken); + + return command; + } + + public async Task SaveIncidentMapAsync(IncidentMap map, string userId, CancellationToken cancellationToken = default(CancellationToken)) + { + if (map == null || string.IsNullOrWhiteSpace(map.IncidentCommandId) || string.IsNullOrWhiteSpace(map.Name)) + throw new ArgumentException("An incident command and map name are required."); + if (!string.IsNullOrWhiteSpace(map.CenterLatitude) || !string.IsNullOrWhiteSpace(map.CenterLongitude)) + { + if (!TryParseCoordinates(map.CenterLatitude, map.CenterLongitude, out _, out _)) + throw new ArgumentException("A valid map center latitude and longitude are required."); + } + + var command = await GetOwnedCommandAsync(map.IncidentCommandId, map.DepartmentId); + if (command == null) + return null; + map.CallId = command.CallId; + map.Name = TrimToLength(map.Name, 500); + map.Description = TrimToLength(map.Description, 2000); + + var (saved, isNew, rejected) = await UpsertOwnedAsync(_incidentMapRepository, map, map.DepartmentId, + e => e.DepartmentId, (stored, incoming) => + { + incoming.CreatedByUserId = stored.CreatedByUserId; + incoming.CreatedOn = stored.CreatedOn; + incoming.DeletedOn = stored.DeletedOn; + incoming.UpdatedByUserId = userId; + incoming.UpdatedOn = DateTime.UtcNow; + }, cancellationToken); + if (rejected) + return null; + map = saved; + + if (isNew) + { + map.CreatedByUserId = userId; + map.CreatedOn = DateTime.UtcNow; + map = await _incidentMapRepository.SaveOrUpdateAsync(Touch(map), cancellationToken); + } + + // Log with the author's name and command/ICS standing (spec: who + role + when). + var author = await BuildNoteAuthorLabelAsync(command, userId); + await WriteLogAsync(map.IncidentCommandId, map.DepartmentId, map.CallId, CommandLogEntryType.MapViewUpdated, + $"Incident map '{map.Name}' {(isNew ? "created" : "updated")} by {author}", userId, cancellationToken); + + return map; + } + + public async Task DeleteIncidentMapAsync(int departmentId, string incidentMapId, string userId, CancellationToken cancellationToken = default(CancellationToken)) + { + var map = await _incidentMapRepository.GetByIdAsync(incidentMapId); + if (map == null || map.DepartmentId != departmentId || map.DeletedOn.HasValue || !await IsCommandActiveAsync(map.IncidentCommandId)) + return false; + + map.DeletedOn = DateTime.UtcNow; + await _incidentMapRepository.SaveOrUpdateAsync(Touch(map), cancellationToken); + + var mapCommand = await GetCommandByIdAsync(map.IncidentCommandId); + var author = mapCommand != null ? await BuildNoteAuthorLabelAsync(mapCommand, userId) : await ResolveUserLabelAsync(departmentId, userId); + await WriteLogAsync(map.IncidentCommandId, map.DepartmentId, map.CallId, CommandLogEntryType.MapViewUpdated, + $"Incident map '{map.Name}' removed by {author}", userId, cancellationToken); + return true; + } + + public async Task> GetIncidentMapsForCallAsync(int departmentId, int callId) + { + var items = await _incidentMapRepository.GetAllByDepartmentIdAsync(departmentId); + if (items == null) + return new List(); + + return items.Where(x => x.CallId == callId && x.DeletedOn == null).OrderBy(x => x.CreatedOn).ToList(); + } + #endregion Map annotations #region Timeline @@ -1818,15 +2587,214 @@ private static T Touch(T entity) where T : IChangeTracked /// /// Loads the parent incident command and returns it only if it belongs to the given department (else null). /// Gates create/update of child entities AND supplies the authoritative CallId to stamp onto them — a caller - /// must not be trusted to supply a CallId that matches the parent command. + /// must not be trusted to supply a CallId that matches the parent command. By default a CLOSED (ended) + /// command is also rejected: an ended command is read-only. /// - private async Task GetOwnedCommandAsync(string incidentCommandId, int departmentId) + private async Task GetOwnedCommandAsync(string incidentCommandId, int departmentId, bool requireActive = true) { if (string.IsNullOrWhiteSpace(incidentCommandId)) return null; var command = await _incidentCommandRepository.GetByIdAsync(incidentCommandId); - return command != null && command.DepartmentId == departmentId ? command : null; + if (command == null || command.DepartmentId != departmentId) + return null; + + return requireActive && command.Status != (int)IncidentCommandStatus.Active ? null : command; + } + + /// An ended (closed) command is read-only — mutations on its child rows are rejected. + private async Task IsCommandActiveAsync(string incidentCommandId) + { + if (string.IsNullOrWhiteSpace(incidentCommandId)) + return false; + + var command = await _incidentCommandRepository.GetByIdAsync(incidentCommandId); + return command != null && command.Status == (int)IncidentCommandStatus.Active; + } + + /// + /// Human label for a member: "Full Name (Group Name)". Falls back progressively (name only, then the raw + /// user id) so log text composition never throws. + /// + private async Task ResolveUserLabelAsync(int departmentId, string userId, string knownName = null) + { + if (string.IsNullOrWhiteSpace(userId)) + return string.IsNullOrWhiteSpace(knownName) ? "Unknown member" : knownName; + + var label = string.IsNullOrWhiteSpace(knownName) ? userId : knownName; + try + { + if (string.IsNullOrWhiteSpace(knownName)) + { + var profile = await _userProfileService.GetProfileByUserIdAsync(userId); + if (profile != null) + label = profile.FullName.AsFirstNameLastName; + } + + var group = await _departmentGroupsService.GetGroupForUserAsync(userId, departmentId); + if (!string.IsNullOrWhiteSpace(group?.Name)) + label = $"{label} ({group.Name})"; + } + catch (Exception ex) + { + Resgrid.Framework.Logging.LogException(ex); + } + return label; + } + + /// Human label for a unit: "Unit Name (Station/Group Name)". + private async Task ResolveUnitLabelAsync(int unitId) + { + var label = $"Unit {unitId}"; + try + { + var unit = await _unitsService.GetUnitByIdAsync(unitId); + if (unit != null) + { + label = unit.Name; + if (unit.StationGroupId.HasValue) + { + var group = await _departmentGroupsService.GetGroupByIdAsync(unit.StationGroupId.Value); + if (!string.IsNullOrWhiteSpace(group?.Name)) + label = $"{label} ({group.Name})"; + } + } + } + catch (Exception ex) + { + Resgrid.Framework.Logging.LogException(ex); + } + return label; + } + + /// + /// Human label for a polymorphic resource assignment target so timeline text never shows a raw id/GUID: + /// units as "Name (Group)", personnel as "Full Name (Group)", ad-hoc resources as "Name (Agency)". + /// + private async Task ResolveResourceLabelAsync(int departmentId, int resourceKind, string resourceId) + { + try + { + switch ((ResourceAssignmentKind)resourceKind) + { + case ResourceAssignmentKind.RealUnit: + case ResourceAssignmentKind.LinkedDeptUnit: + if (int.TryParse(resourceId, out var unitId)) + return await ResolveUnitLabelAsync(unitId); + break; + case ResourceAssignmentKind.RealPersonnel: + case ResourceAssignmentKind.LinkedDeptPersonnel: + return await ResolveUserLabelAsync(departmentId, resourceId); + case ResourceAssignmentKind.AdHocUnit: + var adHocUnit = await _incidentAdHocUnitRepository.GetByIdAsync(resourceId); + if (adHocUnit != null) + return string.IsNullOrWhiteSpace(adHocUnit.ExternalAgencyName) ? adHocUnit.Name : $"{adHocUnit.Name} ({adHocUnit.ExternalAgencyName})"; + break; + case ResourceAssignmentKind.AdHocPersonnel: + var adHocPersonnel = await _incidentAdHocPersonnelRepository.GetByIdAsync(resourceId); + if (adHocPersonnel != null) + return string.IsNullOrWhiteSpace(adHocPersonnel.ExternalAgencyName) ? adHocPersonnel.Name : $"{adHocPersonnel.Name} ({adHocPersonnel.ExternalAgencyName})"; + break; + } + } + catch (Exception ex) + { + Resgrid.Framework.Logging.LogException(ex); + } + return "Unknown resource"; + } + + /// + /// Note-author attribution for the timeline: "Full Name (Group) [Incident Commander, Safety Officer]" — + /// the bracket names the author's command standing (IC and/or any ICS roles held on this incident). + /// + private async Task BuildNoteAuthorLabelAsync(IncidentCommand command, string userId) + { + var label = await ResolveUserLabelAsync(command.DepartmentId, userId); + + var standings = new List(); + if (string.Equals(userId, command.CurrentCommanderUserId, StringComparison.OrdinalIgnoreCase)) + standings.Add("Incident Commander"); + + try + { + var roles = await GetIncidentRolesAsync(command.DepartmentId, command.CallId); + standings.AddRange(roles.Where(r => string.Equals(r.UserId, userId, StringComparison.OrdinalIgnoreCase)) + .Select(r => ((IncidentRoleType)r.RoleType).ToString())); + } + catch (Exception ex) + { + Resgrid.Framework.Logging.LogException(ex); + } + + return standings.Count > 0 ? $"{label} [{string.Join(", ", standings.Distinct())}]" : label; + } + + /// Forward-geocodes a free-text location; (null, null) when it can't be resolved. + private async Task<(string latitude, string longitude)> GeocodeLocationTextAsync(string locationText) + { + if (string.IsNullOrWhiteSpace(locationText)) + return (null, null); + + try + { + var result = await _geoLocationProvider.GetLatLonFromAddress(locationText.Trim()); + var parts = result?.Split(','); + if (parts != null && parts.Length >= 2 && TryParseCoordinates(parts[0], parts[1], out var latitude, out var longitude)) + return (latitude.ToString("0.######", CultureInfo.InvariantCulture), longitude.ToString("0.######", CultureInfo.InvariantCulture)); + } + catch (Exception ex) + { + Resgrid.Framework.Logging.LogException(ex); + } + return (null, null); + } + + /// + /// Merges one location edit (free text + coordinates) over the stored values: null inputs leave the stored + /// value, empty strings clear it, and supplied coordinates must parse as a valid pair. Whenever the merged + /// text is set while the merged coordinates are blank, the text is geocoded to backfill them. + /// + private async Task<(string text, string latitude, string longitude, bool changed)> ApplyLocationEditAsync( + string currentText, string currentLatitude, string currentLongitude, + string newText, string newLatitude, string newLongitude) + { + var text = newText == null ? currentText : TrimToLength(newText, 1000); + + string latitude; + string longitude; + if (newLatitude == null && newLongitude == null) + { + latitude = currentLatitude; + longitude = currentLongitude; + } + else if (string.IsNullOrWhiteSpace(newLatitude) || string.IsNullOrWhiteSpace(newLongitude)) + { + latitude = null; + longitude = null; + } + else + { + if (!TryParseCoordinates(newLatitude, newLongitude, out var latitudeValue, out var longitudeValue)) + throw new ArgumentException("A valid latitude and longitude are required."); + latitude = latitudeValue.ToString("0.######", CultureInfo.InvariantCulture); + longitude = longitudeValue.ToString("0.######", CultureInfo.InvariantCulture); + } + + if (!string.IsNullOrWhiteSpace(text) && (string.IsNullOrWhiteSpace(latitude) || string.IsNullOrWhiteSpace(longitude))) + { + var geocoded = await GeocodeLocationTextAsync(text); + if (geocoded.latitude != null) + { + latitude = geocoded.latitude; + longitude = geocoded.longitude; + } + } + + var changed = !string.Equals(text ?? string.Empty, currentText ?? string.Empty) || + !string.Equals(latitude ?? string.Empty, currentLatitude ?? string.Empty) || + !string.Equals(longitude ?? string.Empty, currentLongitude ?? string.Empty); + return (text, latitude, longitude, changed); } private async Task WriteLogAsync(string incidentCommandId, int departmentId, int callId, CommandLogEntryType type, string description, string userId, CancellationToken cancellationToken) diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0094_AddIncidentCommandNameAndLocations.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0094_AddIncidentCommandNameAndLocations.cs new file mode 100644 index 000000000..cff1755d1 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0094_AddIncidentCommandNameAndLocations.cs @@ -0,0 +1,54 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Incident-level display name plus the ICP/HQ (command post), Staging, and Rehab locations: a free-form + /// text description each, and latitude/longitude pairs for Staging and Rehab (the command post already + /// has coordinate columns from M0077). + /// + [Migration(94)] + public class M0094_AddIncidentCommandNameAndLocations : Migration + { + public override void Up() + { + if (!Schema.Table("IncidentCommands").Exists()) + return; + + if (!Schema.Table("IncidentCommands").Column("Name").Exists()) + Alter.Table("IncidentCommands").AddColumn("Name").AsString(500).Nullable(); + + if (!Schema.Table("IncidentCommands").Column("CommandPostLocationText").Exists()) + Alter.Table("IncidentCommands").AddColumn("CommandPostLocationText").AsString(1000).Nullable(); + + foreach (var column in new[] { "StagingLocationText", "RehabLocationText" }) + { + if (!Schema.Table("IncidentCommands").Column(column).Exists()) + Alter.Table("IncidentCommands").AddColumn(column).AsString(1000).Nullable(); + } + + foreach (var column in new[] { "StagingLatitude", "StagingLongitude", "RehabLatitude", "RehabLongitude" }) + { + if (!Schema.Table("IncidentCommands").Column(column).Exists()) + Alter.Table("IncidentCommands").AddColumn(column).AsString(int.MaxValue).Nullable(); + } + } + + public override void Down() + { + if (!Schema.Table("IncidentCommands").Exists()) + return; + + foreach (var column in new[] + { + "Name", "CommandPostLocationText", + "StagingLocationText", "StagingLatitude", "StagingLongitude", + "RehabLocationText", "RehabLatitude", "RehabLongitude" + }) + { + if (Schema.Table("IncidentCommands").Column(column).Exists()) + Delete.Column(column).FromTable("IncidentCommands"); + } + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0095_AddIncidentNeedUpdates.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0095_AddIncidentNeedUpdates.cs new file mode 100644 index 000000000..d94069e50 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0095_AddIncidentNeedUpdates.cs @@ -0,0 +1,47 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Append-only audit trail for incident-need fulfillment changes (partial fills, fill reductions, + /// closing an unfilled need) with the caller's note, author, and timestamp. + /// + [Migration(95)] + public class M0095_AddIncidentNeedUpdates : Migration + { + public override void Up() + { + if (Schema.Table("IncidentNeedUpdates").Exists()) + return; + + Create.Table("IncidentNeedUpdates") + .WithColumn("IncidentNeedUpdateId").AsString(128).NotNullable().PrimaryKey() + .WithColumn("IncidentNeedId").AsString(128).NotNullable() + .WithColumn("IncidentCommandId").AsString(128).NotNullable() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("CallId").AsInt32().NotNullable() + .WithColumn("PreviousStatus").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("NewStatus").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("PreviousQuantityFulfilled").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("NewQuantityFulfilled").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("Note").AsString(2000).Nullable() + .WithColumn("CreatedByUserId").AsString(450).Nullable() + .WithColumn("CreatedOn").AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime); + + Create.Index("IX_IncidentNeedUpdates_Need") + .OnTable("IncidentNeedUpdates") + .OnColumn("IncidentNeedId").Ascending(); + + Create.Index("IX_IncidentNeedUpdates_Department_Call") + .OnTable("IncidentNeedUpdates") + .OnColumn("DepartmentId").Ascending() + .OnColumn("CallId").Ascending(); + } + + public override void Down() + { + if (Schema.Table("IncidentNeedUpdates").Exists()) + Delete.Table("IncidentNeedUpdates"); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0096_AddObjectiveOutcome.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0096_AddObjectiveOutcome.cs new file mode 100644 index 000000000..ce2632214 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0096_AddObjectiveOutcome.cs @@ -0,0 +1,36 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Close-out outcome (Successful/Partial/Unsuccessful) and an optional completion note on tactical + /// objectives, recorded when an objective is completed. + /// + [Migration(96)] + public class M0096_AddObjectiveOutcome : Migration + { + public override void Up() + { + if (!Schema.Table("TacticalObjectives").Exists()) + return; + + if (!Schema.Table("TacticalObjectives").Column("Outcome").Exists()) + Alter.Table("TacticalObjectives").AddColumn("Outcome").AsInt32().NotNullable().WithDefaultValue(0); + + if (!Schema.Table("TacticalObjectives").Column("CompletionNote").Exists()) + Alter.Table("TacticalObjectives").AddColumn("CompletionNote").AsString(2000).Nullable(); + } + + public override void Down() + { + if (!Schema.Table("TacticalObjectives").Exists()) + return; + + foreach (var column in new[] { "Outcome", "CompletionNote" }) + { + if (Schema.Table("TacticalObjectives").Column(column).Exists()) + Delete.Column(column).FromTable("TacticalObjectives"); + } + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0097_AddIncidentMapView.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0097_AddIncidentMapView.cs new file mode 100644 index 000000000..3ccc0f9ea --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0097_AddIncidentMapView.cs @@ -0,0 +1,36 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Saved incident-map view (center + zoom) on IncidentCommands so the tactical map opens with a + /// consistent framing for everyone on the incident. + /// + [Migration(97)] + public class M0097_AddIncidentMapView : Migration + { + public override void Up() + { + if (!Schema.Table("IncidentCommands").Exists()) + return; + + foreach (var column in new[] { "MapCenterLatitude", "MapCenterLongitude", "MapZoomLevel" }) + { + if (!Schema.Table("IncidentCommands").Column(column).Exists()) + Alter.Table("IncidentCommands").AddColumn(column).AsString(int.MaxValue).Nullable(); + } + } + + public override void Down() + { + if (!Schema.Table("IncidentCommands").Exists()) + return; + + foreach (var column in new[] { "MapCenterLatitude", "MapCenterLongitude", "MapZoomLevel" }) + { + if (Schema.Table("IncidentCommands").Column(column).Exists()) + Delete.Column(column).FromTable("IncidentCommands"); + } + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0098_AddIncidentMaps.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0098_AddIncidentMaps.cs new file mode 100644 index 000000000..bd1535d91 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0098_AddIncidentMaps.cs @@ -0,0 +1,60 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Named tactical maps per incident (own framing, description, optional expiry, full audit), markup + /// linkage (IncidentMapAnnotations.IncidentMapId), and lane → map attachment + /// (CommandStructureNodes.LinkedMapId). + /// + [Migration(98)] + public class M0098_AddIncidentMaps : Migration + { + public override void Up() + { + if (!Schema.Table("IncidentMaps").Exists()) + { + Create.Table("IncidentMaps") + .WithColumn("IncidentMapId").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("CenterLatitude").AsString(int.MaxValue).Nullable() + .WithColumn("CenterLongitude").AsString(int.MaxValue).Nullable() + .WithColumn("ZoomLevel").AsString(int.MaxValue).Nullable() + .WithColumn("ExpiresOn").AsDateTime2().Nullable() + .WithColumn("CreatedByUserId").AsString(450).Nullable() + .WithColumn("CreatedOn").AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime) + .WithColumn("UpdatedByUserId").AsString(450).Nullable() + .WithColumn("UpdatedOn").AsDateTime2().Nullable() + .WithColumn("DeletedOn").AsDateTime2().Nullable() + .WithColumn("ModifiedOn").AsDateTime2().Nullable(); + + Create.Index("IX_IncidentMaps_Department_Call") + .OnTable("IncidentMaps") + .OnColumn("DepartmentId").Ascending() + .OnColumn("CallId").Ascending(); + } + + if (Schema.Table("IncidentMapAnnotations").Exists() && !Schema.Table("IncidentMapAnnotations").Column("IncidentMapId").Exists()) + Alter.Table("IncidentMapAnnotations").AddColumn("IncidentMapId").AsString(128).Nullable(); + + if (Schema.Table("CommandStructureNodes").Exists() && !Schema.Table("CommandStructureNodes").Column("LinkedMapId").Exists()) + Alter.Table("CommandStructureNodes").AddColumn("LinkedMapId").AsString(128).Nullable(); + } + + public override void Down() + { + if (Schema.Table("CommandStructureNodes").Exists() && Schema.Table("CommandStructureNodes").Column("LinkedMapId").Exists()) + Delete.Column("LinkedMapId").FromTable("CommandStructureNodes"); + + if (Schema.Table("IncidentMapAnnotations").Exists() && Schema.Table("IncidentMapAnnotations").Column("IncidentMapId").Exists()) + Delete.Column("IncidentMapId").FromTable("IncidentMapAnnotations"); + + if (Schema.Table("IncidentMaps").Exists()) + Delete.Table("IncidentMaps"); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0099_AddIncidentNeedEntities.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0099_AddIncidentNeedEntities.cs new file mode 100644 index 000000000..2e6c63a47 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0099_AddIncidentNeedEntities.cs @@ -0,0 +1,46 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Entity-category needs: the specific units/users/roles/groups command requested for the incident, + /// dispatched individually onto the call as "requested by command". + /// + [Migration(99)] + public class M0099_AddIncidentNeedEntities : Migration + { + public override void Up() + { + if (Schema.Table("IncidentNeedEntities").Exists()) + return; + + Create.Table("IncidentNeedEntities") + .WithColumn("IncidentNeedEntityId").AsString(128).NotNullable().PrimaryKey() + .WithColumn("IncidentNeedId").AsString(128).NotNullable() + .WithColumn("IncidentCommandId").AsString(128).NotNullable() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("CallId").AsInt32().NotNullable() + .WithColumn("EntityKind").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("EntityId").AsString(450).NotNullable() + .WithColumn("EntityName").AsString(500).Nullable() + .WithColumn("DispatchedOn").AsDateTime2().Nullable() + .WithColumn("CreatedByUserId").AsString(450).Nullable() + .WithColumn("CreatedOn").AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime); + + Create.Index("IX_IncidentNeedEntities_Need") + .OnTable("IncidentNeedEntities") + .OnColumn("IncidentNeedId").Ascending(); + + Create.Index("IX_IncidentNeedEntities_Department_Call") + .OnTable("IncidentNeedEntities") + .OnColumn("DepartmentId").Ascending() + .OnColumn("CallId").Ascending(); + } + + public override void Down() + { + if (Schema.Table("IncidentNeedEntities").Exists()) + Delete.Table("IncidentNeedEntities"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0094_AddIncidentCommandNameAndLocationsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0094_AddIncidentCommandNameAndLocationsPg.cs new file mode 100644 index 000000000..5d7e6b701 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0094_AddIncidentCommandNameAndLocationsPg.cs @@ -0,0 +1,47 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Incident-level display name plus the ICP/HQ (command post), Staging, and Rehab locations: a free-form + /// text description each, and latitude/longitude pairs for Staging and Rehab (the command post already + /// has coordinate columns from M0077). + /// + [Migration(94)] + public class M0094_AddIncidentCommandNameAndLocationsPg : Migration + { + public override void Up() + { + if (!Schema.Table("incidentcommands").Exists()) + return; + + foreach (var column in new[] + { + "name", "commandpostlocationtext", + "staginglocationtext", "staginglatitude", "staginglongitude", + "rehablocationtext", "rehablatitude", "rehablongitude" + }) + { + if (!Schema.Table("incidentcommands").Column(column).Exists()) + Alter.Table("incidentcommands").AddColumn(column).AsCustom("citext").Nullable(); + } + } + + public override void Down() + { + if (!Schema.Table("incidentcommands").Exists()) + return; + + foreach (var column in new[] + { + "name", "commandpostlocationtext", + "staginglocationtext", "staginglatitude", "staginglongitude", + "rehablocationtext", "rehablatitude", "rehablongitude" + }) + { + if (Schema.Table("incidentcommands").Column(column).Exists()) + Delete.Column(column).FromTable("incidentcommands"); + } + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0095_AddIncidentNeedUpdatesPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0095_AddIncidentNeedUpdatesPg.cs new file mode 100644 index 000000000..6a22fa9b5 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0095_AddIncidentNeedUpdatesPg.cs @@ -0,0 +1,47 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Append-only audit trail for incident-need fulfillment changes (partial fills, fill reductions, + /// closing an unfilled need) with the caller's note, author, and timestamp. + /// + [Migration(95)] + public class M0095_AddIncidentNeedUpdatesPg : Migration + { + public override void Up() + { + if (Schema.Table("incidentneedupdates").Exists()) + return; + + Create.Table("incidentneedupdates") + .WithColumn("incidentneedupdateid").AsCustom("citext").NotNullable().PrimaryKey() + .WithColumn("incidentneedid").AsCustom("citext").NotNullable() + .WithColumn("incidentcommandid").AsCustom("citext").NotNullable() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("callid").AsInt32().NotNullable() + .WithColumn("previousstatus").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("newstatus").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("previousquantityfulfilled").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("newquantityfulfilled").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("note").AsCustom("citext").Nullable() + .WithColumn("createdbyuserid").AsCustom("citext").Nullable() + .WithColumn("createdon").AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime); + + Create.Index("ix_incidentneedupdates_need") + .OnTable("incidentneedupdates") + .OnColumn("incidentneedid").Ascending(); + + Create.Index("ix_incidentneedupdates_department_call") + .OnTable("incidentneedupdates") + .OnColumn("departmentid").Ascending() + .OnColumn("callid").Ascending(); + } + + public override void Down() + { + if (Schema.Table("incidentneedupdates").Exists()) + Delete.Table("incidentneedupdates"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0096_AddObjectiveOutcomePg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0096_AddObjectiveOutcomePg.cs new file mode 100644 index 000000000..8ee3f26d9 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0096_AddObjectiveOutcomePg.cs @@ -0,0 +1,36 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Close-out outcome (Successful/Partial/Unsuccessful) and an optional completion note on tactical + /// objectives, recorded when an objective is completed. + /// + [Migration(96)] + public class M0096_AddObjectiveOutcomePg : Migration + { + public override void Up() + { + if (!Schema.Table("tacticalobjectives").Exists()) + return; + + if (!Schema.Table("tacticalobjectives").Column("outcome").Exists()) + Alter.Table("tacticalobjectives").AddColumn("outcome").AsInt32().NotNullable().WithDefaultValue(0); + + if (!Schema.Table("tacticalobjectives").Column("completionnote").Exists()) + Alter.Table("tacticalobjectives").AddColumn("completionnote").AsCustom("citext").Nullable(); + } + + public override void Down() + { + if (!Schema.Table("tacticalobjectives").Exists()) + return; + + foreach (var column in new[] { "outcome", "completionnote" }) + { + if (Schema.Table("tacticalobjectives").Column(column).Exists()) + Delete.Column(column).FromTable("tacticalobjectives"); + } + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0097_AddIncidentMapViewPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0097_AddIncidentMapViewPg.cs new file mode 100644 index 000000000..a7fcb7851 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0097_AddIncidentMapViewPg.cs @@ -0,0 +1,36 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Saved incident-map view (center + zoom) on IncidentCommands so the tactical map opens with a + /// consistent framing for everyone on the incident. + /// + [Migration(97)] + public class M0097_AddIncidentMapViewPg : Migration + { + public override void Up() + { + if (!Schema.Table("incidentcommands").Exists()) + return; + + foreach (var column in new[] { "mapcenterlatitude", "mapcenterlongitude", "mapzoomlevel" }) + { + if (!Schema.Table("incidentcommands").Column(column).Exists()) + Alter.Table("incidentcommands").AddColumn(column).AsCustom("citext").Nullable(); + } + } + + public override void Down() + { + if (!Schema.Table("incidentcommands").Exists()) + return; + + foreach (var column in new[] { "mapcenterlatitude", "mapcenterlongitude", "mapzoomlevel" }) + { + if (Schema.Table("incidentcommands").Column(column).Exists()) + Delete.Column(column).FromTable("incidentcommands"); + } + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0098_AddIncidentMapsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0098_AddIncidentMapsPg.cs new file mode 100644 index 000000000..2afb348d4 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0098_AddIncidentMapsPg.cs @@ -0,0 +1,60 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Named tactical maps per incident (own framing, description, optional expiry, full audit), markup + /// linkage (incidentmapannotations.incidentmapid), and lane → map attachment + /// (commandstructurenodes.linkedmapid). + /// + [Migration(98)] + public class M0098_AddIncidentMapsPg : Migration + { + public override void Up() + { + if (!Schema.Table("incidentmaps").Exists()) + { + Create.Table("incidentmaps") + .WithColumn("incidentmapid").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("centerlatitude").AsCustom("citext").Nullable() + .WithColumn("centerlongitude").AsCustom("citext").Nullable() + .WithColumn("zoomlevel").AsCustom("citext").Nullable() + .WithColumn("expireson").AsDateTime2().Nullable() + .WithColumn("createdbyuserid").AsCustom("citext").Nullable() + .WithColumn("createdon").AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime) + .WithColumn("updatedbyuserid").AsCustom("citext").Nullable() + .WithColumn("updatedon").AsDateTime2().Nullable() + .WithColumn("deletedon").AsDateTime2().Nullable() + .WithColumn("modifiedon").AsDateTime2().Nullable(); + + Create.Index("ix_incidentmaps_department_call") + .OnTable("incidentmaps") + .OnColumn("departmentid").Ascending() + .OnColumn("callid").Ascending(); + } + + if (Schema.Table("incidentmapannotations").Exists() && !Schema.Table("incidentmapannotations").Column("incidentmapid").Exists()) + Alter.Table("incidentmapannotations").AddColumn("incidentmapid").AsCustom("citext").Nullable(); + + if (Schema.Table("commandstructurenodes").Exists() && !Schema.Table("commandstructurenodes").Column("linkedmapid").Exists()) + Alter.Table("commandstructurenodes").AddColumn("linkedmapid").AsCustom("citext").Nullable(); + } + + public override void Down() + { + if (Schema.Table("commandstructurenodes").Exists() && Schema.Table("commandstructurenodes").Column("linkedmapid").Exists()) + Delete.Column("linkedmapid").FromTable("commandstructurenodes"); + + if (Schema.Table("incidentmapannotations").Exists() && Schema.Table("incidentmapannotations").Column("incidentmapid").Exists()) + Delete.Column("incidentmapid").FromTable("incidentmapannotations"); + + if (Schema.Table("incidentmaps").Exists()) + Delete.Table("incidentmaps"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0099_AddIncidentNeedEntitiesPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0099_AddIncidentNeedEntitiesPg.cs new file mode 100644 index 000000000..8cd1dfa44 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0099_AddIncidentNeedEntitiesPg.cs @@ -0,0 +1,46 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Entity-category needs: the specific units/users/roles/groups command requested for the incident, + /// dispatched individually onto the call as "requested by command". + /// + [Migration(99)] + public class M0099_AddIncidentNeedEntitiesPg : Migration + { + public override void Up() + { + if (Schema.Table("incidentneedentities").Exists()) + return; + + Create.Table("incidentneedentities") + .WithColumn("incidentneedentityid").AsCustom("citext").NotNullable().PrimaryKey() + .WithColumn("incidentneedid").AsCustom("citext").NotNullable() + .WithColumn("incidentcommandid").AsCustom("citext").NotNullable() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("callid").AsInt32().NotNullable() + .WithColumn("entitykind").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("entityid").AsCustom("citext").NotNullable() + .WithColumn("entityname").AsCustom("citext").Nullable() + .WithColumn("dispatchedon").AsDateTime2().Nullable() + .WithColumn("createdbyuserid").AsCustom("citext").Nullable() + .WithColumn("createdon").AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime); + + Create.Index("ix_incidentneedentities_need") + .OnTable("incidentneedentities") + .OnColumn("incidentneedid").Ascending(); + + Create.Index("ix_incidentneedentities_department_call") + .OnTable("incidentneedentities") + .OnColumn("departmentid").Ascending() + .OnColumn("callid").Ascending(); + } + + public override void Down() + { + if (Schema.Table("incidentneedentities").Exists()) + Delete.Table("incidentneedentities"); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/IncidentCommandRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/IncidentCommandRepositories.cs index a0172b316..41318e9a7 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/IncidentCommandRepositories.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/IncidentCommandRepositories.cs @@ -126,6 +126,30 @@ public IncidentNeedRepository(IConnectionProvider connectionProvider, SqlConfigu } } + public class IncidentNeedUpdateRepository : RepositoryBase, IIncidentNeedUpdateRepository + { + public IncidentNeedUpdateRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + { + } + } + + public class IncidentMapRepository : RepositoryBase, IIncidentMapRepository + { + public IncidentMapRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + { + } + } + + public class IncidentNeedEntityRepository : RepositoryBase, IIncidentNeedEntityRepository + { + public IncidentNeedEntityRepository(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 20e548d84..d48a9cd00 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs @@ -114,6 +114,9 @@ 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(); 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 c070d47a2..477745c96 100644 --- a/Tests/Resgrid.Tests/Services/IncidentCommandContentServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/IncidentCommandContentServiceTests.cs @@ -71,7 +71,14 @@ public void SetUp() _weatherProvider.Object, new Mock().Object, new Mock().Object, - new Mock().Object); + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, new Mock().Object, new Mock().Object); } [Test] diff --git a/Tests/Resgrid.Tests/Services/IncidentCommandInfoAndReopenTests.cs b/Tests/Resgrid.Tests/Services/IncidentCommandInfoAndReopenTests.cs new file mode 100644 index 000000000..5fce6b185 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/IncidentCommandInfoAndReopenTests.cs @@ -0,0 +1,214 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Events; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + /// + /// Covers the incident-info editor ( — including the + /// geocode-from-free-text fallback for the ICP/Staging/Rehab locations), command reopen, the read-only guard on + /// ended commands, and the list-card summaries. + /// + [TestFixture] + public class IncidentCommandInfoAndReopenTests + { + private const int Dept = 10; + private const int CallId = 1; + private const string CommandId = "ic1"; + + private Mock _commandRepo; + private Mock _assignmentRepo; + private Mock _logRepo; + private Mock _callsService; + private Mock _eventAggregator; + private Mock _userProfileService; + private Mock _geoLocationProvider; + private IncidentCommandService _service; + + [SetUp] + public void SetUp() + { + _commandRepo = new Mock(); + _assignmentRepo = new Mock(); + _logRepo = new Mock(); + _callsService = new Mock(); + _eventAggregator = new Mock(); + _userProfileService = new Mock(); + _geoLocationProvider = new Mock(); + + _logRepo.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((CommandLogEntry e, CancellationToken ct, bool b) => e); + _commandRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((IncidentCommand e, CancellationToken ct, bool b) => e); + + _service = new IncidentCommandService(_commandRepo.Object, new Mock().Object, + _assignmentRepo.Object, new Mock().Object, + new Mock().Object, new Mock().Object, + _logRepo.Object, new Mock().Object, + new Mock().Object, _callsService.Object, new Mock().Object, + new Mock().Object, new Mock().Object, + _eventAggregator.Object, new Mock().Object, + new Mock().Object, new Mock().Object, + new Mock().Object, new Mock().Object, + new Mock().Object, new Mock().Object, + _userProfileService.Object, new Mock().Object, + _geoLocationProvider.Object, new Mock().Object, + new Mock().Object, new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, new Mock().Object, new Mock().Object); + } + + private IncidentCommand ArrangeCommand(IncidentCommandStatus status = IncidentCommandStatus.Active) + { + var command = new IncidentCommand + { + IncidentCommandId = CommandId, + DepartmentId = Dept, + CallId = CallId, + Status = (int)status, + EstablishedOn = DateTime.UtcNow.AddHours(-2), + ClosedOn = status == IncidentCommandStatus.Closed ? DateTime.UtcNow.AddHours(-1) : (DateTime?)null, + CurrentCommanderUserId = "cmdr-1" + }; + _commandRepo.Setup(x => x.GetByIdAsync(CommandId)).ReturnsAsync(command); + _commandRepo.Setup(x => x.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List { command }); + return command; + } + + [Test] + public async Task UpdateCommandInfoAsync_GeocodesLocationText_WhenCoordinatesBlank() + { + ArrangeCommand(); + _geoLocationProvider.Setup(x => x.GetLatLonFromAddress("Walmart parking lot, Main St")).ReturnsAsync("34.05,-118.24"); + + var saved = await _service.UpdateCommandInfoAsync(Dept, CommandId, new IncidentCommandInfoUpdate + { + StagingLocationText = "Walmart parking lot, Main St" + }, "user1"); + + saved.Should().NotBeNull(); + saved.StagingLocationText.Should().Be("Walmart parking lot, Main St"); + saved.StagingLatitude.Should().Be("34.05"); + saved.StagingLongitude.Should().Be("-118.24"); + } + + [Test] + public async Task UpdateCommandInfoAsync_KeepsSuppliedCoordinates_AndDoesNotGeocode() + { + ArrangeCommand(); + + var saved = await _service.UpdateCommandInfoAsync(Dept, CommandId, new IncidentCommandInfoUpdate + { + RehabLocationText = "Rehab tent", + RehabLatitude = "40.7128", + RehabLongitude = "-74.006" + }, "user1"); + + saved.RehabLatitude.Should().Be("40.7128"); + saved.RehabLongitude.Should().Be("-74.006"); + _geoLocationProvider.Verify(x => x.GetLatLonFromAddress(It.IsAny()), Times.Never); + } + + [Test] + public async Task UpdateCommandInfoAsync_UpdatesNameAndStartTime_AndLeavesNullFieldsAlone() + { + var command = ArrangeCommand(); + command.ImportantInformation = "keep me"; + var newStart = DateTime.UtcNow.AddHours(-5); + + var saved = await _service.UpdateCommandInfoAsync(Dept, CommandId, new IncidentCommandInfoUpdate + { + Name = "Main St Structure Fire", + EstablishedOn = newStart + }, "user1"); + + saved.Name.Should().Be("Main St Structure Fire"); + saved.EstablishedOn.Should().Be(newStart); + saved.ImportantInformation.Should().Be("keep me"); + } + + [Test] + public async Task UpdateCommandInfoAsync_ReturnsNull_WhenCommandEnded() + { + ArrangeCommand(IncidentCommandStatus.Closed); + + var saved = await _service.UpdateCommandInfoAsync(Dept, CommandId, new IncidentCommandInfoUpdate { Name = "x" }, "user1"); + + saved.Should().BeNull(); + _commandRepo.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task ReopenCommandAsync_ReopensClosedCommand_LogsReasonAndRaisesEvent() + { + ArrangeCommand(IncidentCommandStatus.Closed); + CommandLogEntry written = null; + _logRepo.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((CommandLogEntry e, CancellationToken ct, bool b) => written = e) + .ReturnsAsync((CommandLogEntry e, CancellationToken ct, bool b) => e); + + var reopened = await _service.ReopenCommandAsync(Dept, CommandId, "Fire rekindled", "user1"); + + reopened.Should().NotBeNull(); + reopened.Status.Should().Be((int)IncidentCommandStatus.Active); + reopened.ClosedOn.Should().BeNull(); + written.Should().NotBeNull(); + written.EntryType.Should().Be((int)CommandLogEntryType.CommandReopened); + written.Description.Should().Contain("Fire rekindled"); + _eventAggregator.Verify(x => x.SendMessage(It.IsAny()), Times.Once); + } + + [Test] + public async Task ReopenCommandAsync_Throws_WhenAnotherCommandActiveOnCall() + { + var closed = ArrangeCommand(IncidentCommandStatus.Closed); + _commandRepo.Setup(x => x.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List + { + closed, + new IncidentCommand { IncidentCommandId = "ic2", DepartmentId = Dept, CallId = CallId, Status = (int)IncidentCommandStatus.Active } + }); + + var act = async () => await _service.ReopenCommandAsync(Dept, CommandId, "reason", "user1"); + + await act.Should().ThrowAsync(); + } + + [Test] + public async Task GetCommandSummariesForDepartmentAsync_CountsAssignedUnitsAndPersonnel_AndFiltersClosed() + { + var active = new IncidentCommand { IncidentCommandId = "ic-a", DepartmentId = Dept, CallId = CallId, Status = (int)IncidentCommandStatus.Active, EstablishedOn = DateTime.UtcNow.AddHours(-1), CurrentCommanderUserId = "cmdr-1" }; + var closed = new IncidentCommand { IncidentCommandId = "ic-b", DepartmentId = Dept, CallId = 2, Status = (int)IncidentCommandStatus.Closed, EstablishedOn = DateTime.UtcNow.AddHours(-9), ClosedOn = DateTime.UtcNow.AddHours(-3) }; + _commandRepo.Setup(x => x.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List { active, closed }); + _assignmentRepo.Setup(x => x.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List + { + new ResourceAssignment { ResourceAssignmentId = "a1", IncidentCommandId = "ic-a", DepartmentId = Dept, CallId = CallId, CommandStructureNodeId = "n1", ResourceKind = (int)ResourceAssignmentKind.RealUnit, ResourceId = "5" }, + new ResourceAssignment { ResourceAssignmentId = "a2", IncidentCommandId = "ic-a", DepartmentId = Dept, CallId = CallId, CommandStructureNodeId = "n1", ResourceKind = (int)ResourceAssignmentKind.RealPersonnel, ResourceId = "u-1" }, + new ResourceAssignment { ResourceAssignmentId = "a3", IncidentCommandId = "ic-a", DepartmentId = Dept, CallId = CallId, CommandStructureNodeId = "n2", ResourceKind = (int)ResourceAssignmentKind.AdHocUnit, ResourceId = "ah-1" }, + // Released and node-less assignments must NOT count. + new ResourceAssignment { ResourceAssignmentId = "a4", IncidentCommandId = "ic-a", DepartmentId = Dept, CallId = CallId, CommandStructureNodeId = "n1", ResourceKind = (int)ResourceAssignmentKind.RealPersonnel, ResourceId = "u-2", ReleasedOn = DateTime.UtcNow }, + new ResourceAssignment { ResourceAssignmentId = "a5", IncidentCommandId = "ic-a", DepartmentId = Dept, CallId = CallId, CommandStructureNodeId = null, ResourceKind = (int)ResourceAssignmentKind.RealPersonnel, ResourceId = "u-3" } + }); + + var activeOnly = await _service.GetCommandSummariesForDepartmentAsync(Dept, includeClosed: false); + var all = await _service.GetCommandSummariesForDepartmentAsync(Dept, includeClosed: true); + + activeOnly.Should().HaveCount(1); + activeOnly[0].AssignedUnitCount.Should().Be(2); + activeOnly[0].AssignedPersonnelCount.Should().Be(1); + all.Should().HaveCount(2); + all.Select(x => x.IncidentCommandId).Should().Contain("ic-b"); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/IncidentCommandNeedsAndLeadsTests.cs b/Tests/Resgrid.Tests/Services/IncidentCommandNeedsAndLeadsTests.cs index baf08b859..d47003726 100644 --- a/Tests/Resgrid.Tests/Services/IncidentCommandNeedsAndLeadsTests.cs +++ b/Tests/Resgrid.Tests/Services/IncidentCommandNeedsAndLeadsTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using FluentAssertions; @@ -7,6 +8,7 @@ using NUnit.Framework; using Resgrid.Model; using Resgrid.Model.Providers; +using Resgrid.Model.Queue; using Resgrid.Model.Repositories; using Resgrid.Model.Services; using Resgrid.Services; @@ -30,6 +32,7 @@ public class IncidentCommandNeedsAndLeadsTests private Mock _objectiveRepo; private Mock _logRepo; private Mock _needRepo; + private Mock _needUpdateRepo; private Mock _noteRepo; private Mock _attachmentRepo; private Mock _userProfileService; @@ -46,6 +49,9 @@ public void SetUp() _objectiveRepo = new Mock(); _logRepo = new Mock(); _needRepo = new Mock(); + _needUpdateRepo = new Mock(); + _needUpdateRepo.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((IncidentNeedUpdate e, CancellationToken ct, bool b) => e); _noteRepo = new Mock(); _attachmentRepo = new Mock(); _userProfileService = new Mock(); @@ -78,7 +84,12 @@ public void SetUp() _eventAggregator.Object, new Mock().Object, new Mock().Object, new Mock().Object, _noteRepo.Object, _attachmentRepo.Object, new Mock().Object, - _needRepo.Object, _userProfileService.Object, _notificationService.Object); + _needRepo.Object, _userProfileService.Object, _notificationService.Object, + new Mock().Object, new Mock().Object, + new Mock().Object, new Mock().Object, + _needUpdateRepo.Object, + new Mock().Object, + new Mock().Object, new Mock().Object, new Mock().Object); } private static IncidentCommand OwnedCommand() => new IncidentCommand @@ -134,6 +145,268 @@ public async Task SetNeedStatus_Met_StampsMetByAndDefaultsFulfilledQuantity() _logRepo.Verify(x => x.InsertAsync(It.Is(e => e.EntryType == (int)CommandLogEntryType.NeedMet), It.IsAny(), It.IsAny()), Times.Once); } + [Test] + public async Task SetNeedStatus_PartialFillWithNote_WritesAuditRowAndNotedLogEntry() + { + _needRepo.Setup(x => x.GetByIdAsync("need-1")).ReturnsAsync(new IncidentNeed + { + IncidentNeedId = "need-1", + IncidentCommandId = CommandId, + DepartmentId = Dept, + CallId = CallId, + Name = "Engines", + QuantityRequested = 3, + QuantityFulfilled = 0, + Status = (int)IncidentNeedStatus.Open + }); + + IncidentNeedUpdate audit = null; + _needUpdateRepo.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((IncidentNeedUpdate e, CancellationToken ct, bool b) => audit = e) + .ReturnsAsync((IncidentNeedUpdate e, CancellationToken ct, bool b) => e); + + CommandLogEntry logged = null; + _logRepo.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((CommandLogEntry e, CancellationToken ct, bool b) => logged = e) + .ReturnsAsync((CommandLogEntry e, CancellationToken ct, bool b) => e); + + var updated = await _service.SetNeedStatusAsync(Dept, "need-1", IncidentNeedStatus.PartiallyMet, 1, "user-2", "Engine 1 from mutual aid"); + + updated.Status.Should().Be((int)IncidentNeedStatus.PartiallyMet); + updated.QuantityFulfilled.Should().Be(1); + + audit.Should().NotBeNull(); + audit.PreviousStatus.Should().Be((int)IncidentNeedStatus.Open); + audit.NewStatus.Should().Be((int)IncidentNeedStatus.PartiallyMet); + audit.PreviousQuantityFulfilled.Should().Be(0); + audit.NewQuantityFulfilled.Should().Be(1); + audit.Note.Should().Be("Engine 1 from mutual aid"); + audit.CreatedByUserId.Should().Be("user-2"); + audit.CreatedOn.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(10)); + + logged.Description.Should().Contain("fill changed 0 to 1 of 3"); + logged.Description.Should().Contain("Engine 1 from mutual aid"); + } + + [Test] + public async Task SetNeedStatus_ReducingFill_IsAllowedAndAudited() + { + _needRepo.Setup(x => x.GetByIdAsync("need-1")).ReturnsAsync(new IncidentNeed + { + IncidentNeedId = "need-1", + IncidentCommandId = CommandId, + DepartmentId = Dept, + CallId = CallId, + Name = "Engines", + QuantityRequested = 3, + QuantityFulfilled = 2, + Status = (int)IncidentNeedStatus.PartiallyMet + }); + + var updated = await _service.SetNeedStatusAsync(Dept, "need-1", IncidentNeedStatus.PartiallyMet, 1, "user-2", "Engine 1 called off to another incident"); + + updated.QuantityFulfilled.Should().Be(1); + _needUpdateRepo.Verify(x => x.InsertAsync(It.Is(e => + e.PreviousQuantityFulfilled == 2 && e.NewQuantityFulfilled == 1 && e.Note == "Engine 1 called off to another incident"), It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task SetNeedStatus_CancelledUnfilled_LogsClosedWithoutBeingFilled() + { + _needRepo.Setup(x => x.GetByIdAsync("need-1")).ReturnsAsync(new IncidentNeed + { + IncidentNeedId = "need-1", + IncidentCommandId = CommandId, + DepartmentId = Dept, + CallId = CallId, + Name = "Engines", + QuantityRequested = 3, + QuantityFulfilled = 1, + Status = (int)IncidentNeedStatus.PartiallyMet + }); + + CommandLogEntry logged = null; + _logRepo.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((CommandLogEntry e, CancellationToken ct, bool b) => logged = e) + .ReturnsAsync((CommandLogEntry e, CancellationToken ct, bool b) => e); + + var updated = await _service.SetNeedStatusAsync(Dept, "need-1", IncidentNeedStatus.Cancelled, null, "user-2", "No longer needed"); + + updated.Status.Should().Be((int)IncidentNeedStatus.Cancelled); + logged.Description.Should().Contain("closed without being filled"); + logged.Description.Should().Contain("No longer needed"); + } + + [Test] + public async Task GetNeedUpdates_ReturnsNewestFirst_WithResolvedAuthorNames() + { + _needUpdateRepo.Setup(x => x.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List + { + new IncidentNeedUpdate { IncidentNeedUpdateId = "u1", IncidentNeedId = "need-1", DepartmentId = Dept, CreatedByUserId = "user-2", CreatedOn = DateTime.UtcNow.AddMinutes(-10) }, + new IncidentNeedUpdate { IncidentNeedUpdateId = "u2", IncidentNeedId = "need-1", DepartmentId = Dept, CreatedByUserId = "user-2", CreatedOn = DateTime.UtcNow }, + new IncidentNeedUpdate { IncidentNeedUpdateId = "other", IncidentNeedId = "need-9", DepartmentId = Dept, CreatedByUserId = "user-2", CreatedOn = DateTime.UtcNow } + }); + + var updates = await _service.GetNeedUpdatesAsync(Dept, "need-1"); + + updates.Select(x => x.IncidentNeedUpdateId).Should().Equal("u2", "u1"); + } + + [Test] + public async Task RequestNeedEntities_CreatesNeed_DispatchesSubset_AndLogsRequest() + { + var entityRepo = new Mock(); + entityRepo.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((IncidentNeedEntity e, CancellationToken ct, bool b) => e); + var dispatchStatus = new Mock(); + var queue = new Mock(); + CallQueueItem enqueued = null; + queue.Setup(x => x.EnqueueCallBroadcastAsync(It.IsAny(), It.IsAny())) + .Callback((CallQueueItem cqi, CancellationToken ct) => enqueued = cqi) + .ReturnsAsync(true); + + var callsService = new Mock(); + var call = new Call { CallId = CallId, DepartmentId = Dept, Name = "Structure Fire", NatureOfCall = "Working fire" }; + callsService.Setup(x => x.GetCallByIdAsync(CallId, It.IsAny())).ReturnsAsync(call); + callsService + .Setup(x => x.PopulateCallData(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((Call c, bool a1, bool a2, bool a3, bool a4, bool a5, bool a6, bool a7, bool a8, bool a9, bool a10) => c); + callsService.Setup(x => x.SaveCallAsync(It.IsAny(), It.IsAny())).ReturnsAsync((Call c, CancellationToken ct) => c); + + var 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, callsService.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, + new Mock().Object, new Mock().Object, + new Mock().Object, new Mock().Object, + _needUpdateRepo.Object, new Mock().Object, + entityRepo.Object, dispatchStatus.Object, queue.Object); + + CommandLogEntry logged = null; + _logRepo.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((CommandLogEntry e, CancellationToken ct, bool b) => logged = e) + .ReturnsAsync((CommandLogEntry e, CancellationToken ct, bool b) => e); + + var entities = new List + { + new IncidentNeedEntity { EntityKind = (int)NeedEntityKind.Unit, EntityId = "5" }, + new IncidentNeedEntity { EntityKind = (int)NeedEntityKind.User, EntityId = "user-9" } + }; + + var need = await service.RequestNeedEntitiesAsync(Dept, CommandId, "Mutual aid engines", "Need two engines", entities, "user-2"); + + need.Should().NotBeNull(); + need.Category.Should().Be((int)IncidentNeedCategory.Entity); + need.QuantityRequested.Should().Be(2); + + // Subset dispatch was enqueued (only the requested entities), tagged as a command request. + enqueued.Should().NotBeNull(); + enqueued.BroadcastOnlySelectedDispatches.Should().BeTrue(); + enqueued.BroadcastUnitIds.Should().Contain(5); + enqueued.BroadcastUserIds.Should().Contain("user-9"); + enqueued.Call.NatureOfCall.Should().Contain("Requested by Incident Command"); + + // Entity rows persisted with dispatch stamps, and the request landed on the log. + entityRepo.Verify(x => x.InsertAsync(It.Is(e => e.IncidentNeedId == need.IncidentNeedId && e.DispatchedOn != null), It.IsAny(), It.IsAny()), Times.Exactly(2)); + logged.EntryType.Should().Be((int)CommandLogEntryType.NeedAdded); + logged.Description.Should().Contain("Mutual aid engines"); + logged.Description.Should().Contain("dispatched to the call individually"); + } + + [Test] + public async Task RecordNeedEntityStatus_MatchingUnit_WritesResponseLogEntry() + { + var entityRepo = new Mock(); + entityRepo.Setup(x => x.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List + { + new IncidentNeedEntity { IncidentNeedEntityId = "ne-1", IncidentNeedId = "need-1", IncidentCommandId = CommandId, DepartmentId = Dept, CallId = CallId, EntityKind = (int)NeedEntityKind.Unit, EntityId = "5" } + }); + _needRepo.Setup(x => x.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List + { + new IncidentNeed { IncidentNeedId = "need-1", DepartmentId = Dept, CallId = CallId, Name = "Mutual aid engines" } + }); + _commandRepo.Setup(x => x.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List { OwnedCommand() }); + + var 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, + new Mock().Object, new Mock().Object, + new Mock().Object, new Mock().Object, + _needUpdateRepo.Object, new Mock().Object, + entityRepo.Object, new Mock().Object, new Mock().Object); + + CommandLogEntry logged = null; + _logRepo.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((CommandLogEntry e, CancellationToken ct, bool b) => logged = e) + .ReturnsAsync((CommandLogEntry e, CancellationToken ct, bool b) => e); + + await service.RecordNeedEntityStatusAsync(Dept, CallId, NeedEntityKind.Unit, "5", "Responding", "user-9"); + + logged.Should().NotBeNull(); + logged.EntryType.Should().Be((int)CommandLogEntryType.NeedUpdated); + logged.Description.Should().Contain("responded to need 'Mutual aid engines'"); + logged.Description.Should().Contain("Responding"); + } + + [Test] + public async Task SaveIncidentMap_New_StampsAuditAndLogsAttributedEntry() + { + var mapRepo = new Mock(); + mapRepo.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((IncidentMap e, CancellationToken ct, bool b) => e); + mapRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((IncidentMap e, CancellationToken ct, bool b) => e); + + var 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, + new Mock().Object, new Mock().Object, + new Mock().Object, new Mock().Object, + _needUpdateRepo.Object, mapRepo.Object, + new Mock().Object, new Mock().Object, new Mock().Object); + + CommandLogEntry logged = null; + _logRepo.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((CommandLogEntry e, CancellationToken ct, bool b) => logged = e) + .ReturnsAsync((CommandLogEntry e, CancellationToken ct, bool b) => e); + + var saved = await service.SaveIncidentMapAsync(new IncidentMap + { + IncidentCommandId = CommandId, + DepartmentId = Dept, + Name = "North sector cleanup", + Description = "Debris removal area", + ExpiresOn = DateTime.UtcNow.AddDays(2) + }, "user-2"); + + saved.Should().NotBeNull(); + saved.CallId.Should().Be(CallId, "the authoritative CallId comes from the parent command"); + saved.IncidentMapId.Should().NotBeNullOrEmpty(); + saved.CreatedByUserId.Should().Be("user-2"); + saved.CreatedOn.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(10)); + + logged.EntryType.Should().Be((int)CommandLogEntryType.MapViewUpdated); + logged.Description.Should().Contain("North sector cleanup"); + logged.Description.Should().Contain("created"); + } + [Test] public async Task SetNeedStatus_ForeignDepartment_ReturnsNull() { @@ -144,6 +417,58 @@ public async Task SetNeedStatus_ForeignDepartment_ReturnsNull() result.Should().BeNull(); } + [Test] + public async Task CompleteObjective_WithOutcomeAndNote_StampsAuditAndLogsOutcome() + { + _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 = 60 + }); + + CommandLogEntry logged = null; + _logRepo.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((CommandLogEntry e, CancellationToken ct, bool b) => logged = e) + .ReturnsAsync((CommandLogEntry e, CancellationToken ct, bool b) => e); + + var completed = await _service.CompleteObjectiveAsync(Dept, "obj-1", "user-2", TacticalObjectiveOutcome.Partial, "North wing cleared; south wing inaccessible"); + + completed.Status.Should().Be((int)TacticalObjectiveStatus.Complete); + completed.Outcome.Should().Be((int)TacticalObjectiveOutcome.Partial); + completed.CompletionNote.Should().Be("North wing cleared; south wing inaccessible"); + completed.CompletedByUserId.Should().Be("user-2"); + completed.CompletedOn.Should().NotBeNull(); + + logged.EntryType.Should().Be((int)CommandLogEntryType.ObjectiveCompleted); + logged.Description.Should().Contain("Partial"); + logged.Description.Should().Contain("North wing cleared; south wing inaccessible"); + } + + [Test] + public async Task UpdateObjectiveProgress_ReachingFull_ClosesOutAsSuccessful() + { + _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 = 75 + }); + + var completed = await _service.UpdateObjectiveProgressAsync(Dept, "obj-1", 100, "user-2"); + + completed.Status.Should().Be((int)TacticalObjectiveStatus.Complete); + completed.Outcome.Should().Be((int)TacticalObjectiveOutcome.Successful); + } + [Test] public async Task UpdateObjectiveProgress_PartialProgress_MovesPendingToInProgress() { diff --git a/Tests/Resgrid.Tests/Services/IncidentCommandServiceParTests.cs b/Tests/Resgrid.Tests/Services/IncidentCommandServiceParTests.cs index 70252b59c..9a6b4561e 100644 --- a/Tests/Resgrid.Tests/Services/IncidentCommandServiceParTests.cs +++ b/Tests/Resgrid.Tests/Services/IncidentCommandServiceParTests.cs @@ -87,7 +87,12 @@ public void SetUp() _commandsService.Object, _callsService.Object, _checkInTimerService.Object, _voiceService.Object, _roleRepo.Object, _eventAggregator.Object, _coreEventService.Object, _unitsService.Object, _personnelRolesService.Object, _noteRepo.Object, _attachmentRepo.Object, _weatherProvider.Object, - _needRepo.Object, _userProfileService.Object, _commandNotificationService.Object); + _needRepo.Object, _userProfileService.Object, _commandNotificationService.Object, + new Mock().Object, new Mock().Object, + new Mock().Object, new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, new Mock().Object, new Mock().Object); } private void ArrangeCall(bool checkInTimersEnabled = true, int departmentId = Dept) @@ -390,6 +395,11 @@ public async Task SaveNodeAsync_ReturnsNull_WhenParentCommandBelongsToAnotherDep [Test] public async Task MoveResourceAsync_Moves_WhenTargetNodeOnSameDeptAndCall() { + // The parent command must be ACTIVE — ended commands are read-only and reject mutations. + _commandRepo.Setup(x => x.GetByIdAsync("ic1")).ReturnsAsync(new IncidentCommand + { + IncidentCommandId = "ic1", DepartmentId = Dept, CallId = CallId, Status = (int)IncidentCommandStatus.Active + }); _assignmentRepo.Setup(x => x.GetByIdAsync("ra1")).ReturnsAsync(new ResourceAssignment { ResourceAssignmentId = "ra1", DepartmentId = Dept, CallId = CallId, IncidentCommandId = "ic1" @@ -733,6 +743,11 @@ public async Task SaveNodeAsync_WithClientSuppliedId_OwnedByAnotherDepartment_Re [Test] public async Task DeleteNodeAsync_SoftDeletes_SetsDeletedOnAndModifiedOn_AndPersistsInsteadOfHardDeleting() { + // The parent command must be ACTIVE — ended commands are read-only and reject mutations. + _commandRepo.Setup(x => x.GetByIdAsync("ic1")).ReturnsAsync(new IncidentCommand + { + IncidentCommandId = "ic1", DepartmentId = Dept, CallId = CallId, Status = (int)IncidentCommandStatus.Active + }); CommandStructureNode persisted = null; _nodeRepo.Setup(x => x.GetByIdAsync("node-1")).ReturnsAsync(new CommandStructureNode { diff --git a/Web/Resgrid.Web.Services/Controllers/v4/IncidentCommandController.cs b/Web/Resgrid.Web.Services/Controllers/v4/IncidentCommandController.cs index 7a4caba50..4642fb475 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/IncidentCommandController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/IncidentCommandController.cs @@ -8,6 +8,7 @@ using Resgrid.Web.Services.Helpers; using System; using System.IO; +using System.Linq; using System.Net.Mime; using System.Threading; using System.Threading.Tasks; @@ -66,6 +67,116 @@ public IncidentCommandController(IIncidentCommandService incidentCommandService) return result; } + /// + /// Gets the most recent command for a call across ALL statuses (unlike GetCommandBoard, which only + /// resolves the active one). Lets the IC app detect a prior ended command and offer to reopen it. + /// + [HttpGet("GetCommandForCall/{callId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_View)] + public async Task> GetCommandForCall(int callId) + { + var result = new ICModels.IncidentCommandResult(); + var command = await _incidentCommandService.GetCommandForCallAsync(DepartmentId, callId); + + if (command == null) + { + result.Status = ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + result.Data = command; + result.PageSize = 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// + /// Reopens a previously closed command with a reason. Bootstrap-gated like EstablishCommand: after a + /// close there is no active command, so incident capabilities cannot be evaluated — the department-level + /// Command_Create claim is the gate. + /// + [HttpPut("ReopenCommand")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_Create)] + public async Task> ReopenCommand([FromBody] ICModels.ReopenCommandInput input) + { + if (input == null || string.IsNullOrWhiteSpace(input.IncidentCommandId)) + return BadRequest(); + + var result = new ICModels.IncidentCommandResult(); + try + { + var command = await _incidentCommandService.ReopenCommandAsync(DepartmentId, input.IncidentCommandId, input.Reason, UserId, CancellationToken.None); + + if (command == null) + { + result.Status = ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + result.Data = command; + result.PageSize = 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + + /// + /// List-card summaries for the department's commands: duration, resolved commander name, locations, and + /// active unit/personnel counts. Active only by default; includeClosed=true adds ended incidents. + /// + [HttpGet("GetCommandList")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_View)] + public async Task> GetCommandList([FromQuery] bool includeClosed = false) + { + var summaries = await _incidentCommandService.GetCommandSummariesForDepartmentAsync(DepartmentId, includeClosed); + var result = new ICModels.IncidentCommandSummariesResult + { + Data = summaries, + PageSize = summaries.Count, + Status = ResponseHelper.Success + }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// + /// Board snapshot for one specific command instance — including a CLOSED one (read-only history view for + /// ended incidents). Child rows are filtered to that command, so a closed board isn't polluted by a newer + /// command on the same call. + /// + [HttpGet("GetCommandBoardById/{incidentCommandId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_View)] + public async Task> GetCommandBoardById(string incidentCommandId) + { + var result = new ICModels.IncidentCommandBoardResult(); + var board = await _incidentCommandService.GetCommandBoardByIdAsync(DepartmentId, incidentCommandId); + + if (board == null) + { + result.Status = ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + result.Data = board; + result.PageSize = 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + /// Gets the full live command board snapshot for a call. [HttpGet("GetCommandBoard/{callId}")] [ProducesResponseType(StatusCodes.Status200OK)] @@ -216,6 +327,56 @@ public IncidentCommandController(IIncidentCommandService incidentCommandService) return result; } + /// + /// Updates core incident metadata (name, corrected start time, estimated end, important information, ICS + /// level) and the ICP/HQ, Staging, and Rehab locations. A location whose text is supplied without + /// coordinates is geocoded server-side on save. + /// + [HttpPut("UpdateCommandInfo")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_Update)] + [RequiresIncidentCapability(IncidentCapabilities.ManageCommand)] + public async Task> UpdateCommandInfo([FromBody] ICModels.UpdateCommandInfoInput input) + { + if (input == null || string.IsNullOrWhiteSpace(input.IncidentCommandId)) + return BadRequest(); + + try + { + var command = await _incidentCommandService.UpdateCommandInfoAsync(DepartmentId, input.IncidentCommandId, new IncidentCommandInfoUpdate + { + Name = input.Name, + EstablishedOn = input.EstablishedOn, + EstimatedEndOn = input.EstimatedEndOn, + ClearEstimatedEndOn = input.ClearEstimatedEndOn, + ImportantInformation = input.ImportantInformation, + IcsLevel = input.IcsLevel, + CommandPostLocationText = input.CommandPostLocationText, + CommandPostLatitude = input.CommandPostLatitude, + CommandPostLongitude = input.CommandPostLongitude, + StagingLocationText = input.StagingLocationText, + StagingLatitude = input.StagingLatitude, + StagingLongitude = input.StagingLongitude, + RehabLocationText = input.RehabLocationText, + RehabLatitude = input.RehabLatitude, + RehabLongitude = input.RehabLongitude + }, 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; + } + catch (ArgumentException ex) + { + return BadRequest(ex.Message); + } + } + /// /// 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- @@ -690,10 +851,11 @@ public async Task DownloadAttachment(string incidentAttachmentId) [HttpPost("CompleteObjective/{tacticalObjectiveId}")] [ProducesResponseType(StatusCodes.Status200OK)] [Authorize(Policy = ResgridResources.Command_Update)] - public async Task> CompleteObjective(string tacticalObjectiveId) + public async Task> CompleteObjective(string tacticalObjectiveId, [FromBody] ICModels.CompleteObjectiveInput input = null) { var result = new ICModels.TacticalObjectiveResult(); - var objective = await _incidentCommandService.CompleteObjectiveAsync(DepartmentId, tacticalObjectiveId, UserId, CancellationToken.None); + var outcome = input != null && Enum.IsDefined(typeof(TacticalObjectiveOutcome), input.Outcome) ? (TacticalObjectiveOutcome)input.Outcome : TacticalObjectiveOutcome.NotSet; + var objective = await _incidentCommandService.CompleteObjectiveAsync(DepartmentId, tacticalObjectiveId, UserId, outcome, input?.Note, CancellationToken.None); if (objective == null) { @@ -753,7 +915,7 @@ public async Task DownloadAttachment(string incidentAttachmentId) return BadRequest(); var result = new ICModels.IncidentNeedResult(); - var need = await _incidentCommandService.SetNeedStatusAsync(DepartmentId, input.IncidentNeedId, (IncidentNeedStatus)input.Status, input.QuantityFulfilled, UserId, CancellationToken.None); + var need = await _incidentCommandService.SetNeedStatusAsync(DepartmentId, input.IncidentNeedId, (IncidentNeedStatus)input.Status, input.QuantityFulfilled, UserId, input.Note, CancellationToken.None); if (need == null) { @@ -769,6 +931,62 @@ public async Task DownloadAttachment(string incidentAttachmentId) return result; } + /// Audit trail for one need: every fulfillment change with note, author, and timestamp (newest first). + [HttpGet("GetNeedUpdates/{incidentNeedId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_View)] + public async Task> GetNeedUpdates(string incidentNeedId) + { + var updates = await _incidentCommandService.GetNeedUpdatesAsync(DepartmentId, incidentNeedId); + var result = new ICModels.IncidentNeedUpdatesResult { Data = updates, PageSize = updates.Count, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// + /// Creates an Entity need requesting specific units/users/roles/groups; they are added to the call + /// and dispatched individually as "Requested by Incident Command". + /// + [HttpPost("RequestNeedEntities")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_Update)] + [RequiresIncidentCapability(IncidentCapabilities.ManageObjectives)] + public async Task> RequestNeedEntities([FromBody] ICModels.RequestNeedEntitiesInput input) + { + if (input == null || string.IsNullOrWhiteSpace(input.IncidentCommandId) || string.IsNullOrWhiteSpace(input.Name) || input.Entities == null || input.Entities.Count == 0) + return BadRequest(); + + try + { + var entities = input.Entities.Select(e => new IncidentNeedEntity { EntityKind = e.EntityKind, EntityId = e.EntityId }).ToList(); + var need = await _incidentCommandService.RequestNeedEntitiesAsync(DepartmentId, input.IncidentCommandId, input.Name, input.Description, entities, UserId, CancellationToken.None); + var result = new ICModels.IncidentNeedResult + { + Data = need, + PageSize = need == null ? 0 : 1, + Status = need == null ? ResponseHelper.NotFound : ResponseHelper.Created + }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + catch (ArgumentException ex) + { + return BadRequest(ex.Message); + } + } + + /// The requested entities under one Entity-category need. + [HttpGet("GetNeedEntities/{incidentNeedId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_View)] + public async Task> GetNeedEntities(string incidentNeedId) + { + var items = await _incidentCommandService.GetNeedEntitiesAsync(DepartmentId, incidentNeedId); + var result = new ICModels.IncidentNeedEntitiesResult { Data = items, PageSize = items.Count, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + /// Gets the command-level needs for a call. [HttpGet("GetNeeds/{callId}")] [ProducesResponseType(StatusCodes.Status200OK)] @@ -887,6 +1105,94 @@ public async Task DownloadAttachment(string incidentAttachmentId) #endregion Map annotations + /// + /// Creates or updates a NAMED incident map (name, description, framing, optional expiry). Audit + /// fields stamp server-side; the change logs to the incident timeline with author + ICS standing. + /// + [HttpPost("SaveIncidentMap")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_Update)] + [RequiresIncidentCapability(IncidentCapabilities.ManageAnnotations)] + public async Task> SaveIncidentMap([FromBody] IncidentMap map) + { + if (map == null || string.IsNullOrWhiteSpace(map.IncidentCommandId) || string.IsNullOrWhiteSpace(map.Name)) + return BadRequest(); + + map.DepartmentId = DepartmentId; + + try + { + var saved = await _incidentCommandService.SaveIncidentMapAsync(map, UserId, CancellationToken.None); + var result = new ICModels.IncidentMapResult + { + Data = saved, + PageSize = saved == null ? 0 : 1, + Status = saved == null ? ResponseHelper.NotFound : ResponseHelper.Success + }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + catch (ArgumentException ex) + { + return BadRequest(ex.Message); + } + } + + /// Soft-deletes a named incident map. + [HttpDelete("DeleteIncidentMap/{incidentMapId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_Update)] + public async Task> DeleteIncidentMap(string incidentMapId) + { + var deleted = await _incidentCommandService.DeleteIncidentMapAsync(DepartmentId, incidentMapId, UserId, CancellationToken.None); + var result = new ICModels.IncidentCommandActionResult { Data = deleted, Status = deleted ? ResponseHelper.Success : ResponseHelper.NotFound }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// Gets the incident's named tactical maps. + [HttpGet("GetIncidentMaps/{callId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_View)] + public async Task> GetIncidentMaps(int callId) + { + var maps = await _incidentCommandService.GetIncidentMapsForCallAsync(DepartmentId, callId); + var result = new ICModels.IncidentMapsResult { Data = maps, PageSize = maps.Count, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// + /// Creates or updates the incident map's saved view (center + zoom). Logged to the incident + /// timeline with the author's name and ICS standing. + /// + [HttpPut("UpdateMapView")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_Update)] + [RequiresIncidentCapability(IncidentCapabilities.ManageAnnotations)] + public async Task> UpdateMapView([FromBody] ICModels.UpdateMapViewInput input) + { + if (input == null || string.IsNullOrWhiteSpace(input.IncidentCommandId)) + return BadRequest(); + + try + { + var command = await _incidentCommandService.UpdateMapViewAsync(DepartmentId, input.IncidentCommandId, input.CenterLatitude, input.CenterLongitude, input.ZoomLevel, 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; + } + catch (ArgumentException ex) + { + return BadRequest(ex.Message); + } + } + #region Timeline /// Gets the append-only command (ICS-201) timeline for a call. diff --git a/Web/Resgrid.Web.Services/Controllers/v4/PersonnelStatusesController.cs b/Web/Resgrid.Web.Services/Controllers/v4/PersonnelStatusesController.cs index a62dbe265..f76942f0d 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/PersonnelStatusesController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/PersonnelStatusesController.cs @@ -36,6 +36,7 @@ public class PersonnelStatusesController : V4AuthenticatedApiControllerbase private readonly IPersonnelRolesService _personnelRolesService; private readonly IDepartmentSettingsService _departmentSettingsService; private readonly Model.Services.IAuthorizationService _authorizationService; + private readonly IIncidentCommandService _incidentCommandService; public PersonnelStatusesController( IUsersService usersService, @@ -48,8 +49,8 @@ public PersonnelStatusesController( IMappingService mappingService, IPersonnelRolesService personnelRolesService, IDepartmentSettingsService departmentSettingsService, - Model.Services.IAuthorizationService authorizationService - ) + Model.Services.IAuthorizationService authorizationService, + IIncidentCommandService incidentCommandService) { _usersService = usersService; _actionLogsService = actionLogsService; @@ -59,6 +60,7 @@ Model.Services.IAuthorizationService authorizationService _departmentGroupsService = departmentGroupsService; _callsService = callsService; _mappingService = mappingService; + _incidentCommandService = incidentCommandService; _personnelRolesService = personnelRolesService; _departmentSettingsService = departmentSettingsService; _authorizationService = authorizationService; @@ -161,6 +163,21 @@ public async Task> SavePersonStatus(SavePer return BadRequest(); log = await _actionLogsService.SetUserActionAsync(input.UserId, DepartmentId, int.Parse(input.Type), geolocation, destinationId, destinationType, input.Note, cancellationToken); + + // Entity-need response: a member that command requested answering the call lands on the + // incident log. Best-effort — never blocks the status save. + if (destinationType == (int)DestinationEntityTypes.Call) + { + try + { + var actionLabel = int.TryParse(input.Type, out var actionType) && Enum.IsDefined(typeof(ActionTypes), actionType) ? ((ActionTypes)actionType).ToString() : $"status {input.Type}"; + await _incidentCommandService.RecordNeedEntityStatusAsync(DepartmentId, destinationId, NeedEntityKind.User, input.UserId, actionLabel, UserId, cancellationToken); + } + catch (Exception ex) + { + Resgrid.Framework.Logging.LogException(ex); + } + } } result.Id = log.ActionLogId.ToString(); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/UnitStatusController.cs b/Web/Resgrid.Web.Services/Controllers/v4/UnitStatusController.cs index 7b647dcee..cab9c8edc 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/UnitStatusController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/UnitStatusController.cs @@ -35,6 +35,7 @@ public class UnitStatusController : V4AuthenticatedApiControllerbase private readonly IDepartmentSettingsService _departmentSettingsService; private readonly IActionLogsService _actionLogsService; private readonly IMappingService _mappingService; + private readonly IIncidentCommandService _incidentCommandService; public UnitStatusController( ICallsService callsService, @@ -42,7 +43,8 @@ public UnitStatusController( IDepartmentGroupsService departmentGroupsService, IDepartmentSettingsService departmentSettingsService, IActionLogsService actionLogsService, - IMappingService mappingService + IMappingService mappingService, + IIncidentCommandService incidentCommandService ) { _callsService = callsService; @@ -51,6 +53,7 @@ IMappingService mappingService _departmentSettingsService = departmentSettingsService; _actionLogsService = actionLogsService; _mappingService = mappingService; + _incidentCommandService = incidentCommandService; } #endregion Members and Constructors @@ -279,6 +282,21 @@ public async Task> GetUnitStatus(string unitId) var savedState = await _unitsService.SetUnitStateAsync(state, DepartmentId); + // Entity-need response: a unit that command requested answering the call lands on the + // incident log. Best-effort — never blocks the status save. + if (state.DestinationType == (int)DestinationEntityTypes.Call && state.DestinationId > 0) + { + try + { + var stateLabel = Enum.IsDefined(typeof(UnitStateTypes), state.State) ? ((UnitStateTypes)state.State).ToString() : $"status {state.State}"; + await _incidentCommandService.RecordNeedEntityStatusAsync(DepartmentId, state.DestinationId.Value, NeedEntityKind.Unit, state.UnitId.ToString(), stateLabel, UserId); + } + catch (Exception ex) + { + Resgrid.Framework.Logging.LogException(ex); + } + } + if (stateInput.Roles != null && stateInput.Roles.Count > 0) { var unitRoles = await _unitsService.GetRolesForUnitAsync(savedState.UnitId); diff --git a/Web/Resgrid.Web.Services/Models/v4/IncidentCommand/IncidentCommandModels.cs b/Web/Resgrid.Web.Services/Models/v4/IncidentCommand/IncidentCommandModels.cs index b28ac1ac1..5b9fb320a 100644 --- a/Web/Resgrid.Web.Services/Models/v4/IncidentCommand/IncidentCommandModels.cs +++ b/Web/Resgrid.Web.Services/Models/v4/IncidentCommand/IncidentCommandModels.cs @@ -65,6 +65,38 @@ public class UpdateCommandDetailsInput public string ImportantInformation { get; set; } } + /// Input to reopen a previously closed command, with the caller's reason for reopening. + public class ReopenCommandInput + { + public string IncidentCommandId { get; set; } + public string Reason { get; set; } + } + + /// + /// Input to update core incident metadata and the ICP/HQ, Staging, and Rehab locations. Null fields are + /// left unchanged; empty strings clear. A location whose text is set while its coordinates are blank is + /// geocoded server-side on save. + /// + public class UpdateCommandInfoInput + { + public string IncidentCommandId { get; set; } + public string Name { get; set; } + public System.DateTime? EstablishedOn { get; set; } + public System.DateTime? EstimatedEndOn { get; set; } + public bool ClearEstimatedEndOn { get; set; } + public string ImportantInformation { get; set; } + public int? IcsLevel { get; set; } + public string CommandPostLocationText { get; set; } + public string CommandPostLatitude { get; set; } + public string CommandPostLongitude { get; set; } + public string StagingLocationText { get; set; } + public string StagingLatitude { get; set; } + public string StagingLongitude { get; set; } + public string RehabLocationText { get; set; } + public string RehabLatitude { get; set; } + public string RehabLongitude { get; set; } + } + /// Input to set an objective's progress percentage (0-100; 100 completes it). public class UpdateObjectiveProgressInput { @@ -72,13 +104,35 @@ public class UpdateObjectiveProgressInput public int ProgressPercent { get; set; } } + /// Input to create/update the incident map's saved view (center + zoom) for consistent framing. + public class UpdateMapViewInput + { + public string IncidentCommandId { get; set; } + public string CenterLatitude { get; set; } + public string CenterLongitude { get; set; } + /// Map zoom level, 0-22. + public string ZoomLevel { get; set; } + } + + /// Optional close-out details when completing an objective. + public class CompleteObjectiveInput + { + /// Maps to Resgrid.Model.TacticalObjectiveOutcome (Successful/Partial/Unsuccessful). + public int Outcome { get; set; } + /// Optional close-out note, recorded on the objective and the incident log. + public string Note { 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; } + /// New fulfilled quantity — may be lower than the current value (a fill got called off). public int? QuantityFulfilled { get; set; } + /// Optional context recorded on the audit trail and incident log ("Engine 1 from mutual aid"). + public string Note { get; set; } } public class IncidentCommandResult : StandardApiResponseV4Base @@ -91,6 +145,12 @@ public class IncidentCommandBoardResult : StandardApiResponseV4Base public Resgrid.Model.IncidentCommandBoard Data { get; set; } } + /// List-card summaries for the department's incident commands (active only or incl. closed). + public class IncidentCommandSummariesResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + } + public class CommandTransferResult : StandardApiResponseV4Base { public Resgrid.Model.CommandTransfer Data { get; set; } @@ -128,6 +188,34 @@ public class IncidentNeedsResult : StandardApiResponseV4Base public List Data { get; set; } = new List(); } + /// Input to create an Entity need: specific units/users/roles/groups requested by command. + public class RequestNeedEntitiesInput + { + public string IncidentCommandId { get; set; } + public string Name { get; set; } + public string Description { get; set; } + public List Entities { get; set; } = new List(); + } + + /// One requested entity: kind maps to Resgrid.Model.NeedEntityKind (Unit/User/Role/Group). + public class NeedEntityInput + { + public int EntityKind { get; set; } + public string EntityId { get; set; } + } + + /// The requested entities under one Entity-category need. + public class IncidentNeedEntitiesResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + } + + /// Audit trail for one need's fulfillment changes (newest first). + public class IncidentNeedUpdatesResult : 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 { @@ -144,6 +232,18 @@ public class IncidentMapAnnotationResult : StandardApiResponseV4Base public Resgrid.Model.IncidentMapAnnotation Data { get; set; } } + /// A single named incident tactical map. + public class IncidentMapResult : StandardApiResponseV4Base + { + public Resgrid.Model.IncidentMap Data { get; set; } + } + + /// The incident's named tactical maps. + public class IncidentMapsResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + } + public class IncidentTimesReportResult : StandardApiResponseV4Base { public Resgrid.Model.IncidentTimesReport Data { get; set; } diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index 7af086cd4..656883ffe 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -1016,6 +1016,32 @@ Establishes command on a call, optionally seeding lanes from a command definition. + + + Gets the most recent command for a call across ALL statuses (unlike GetCommandBoard, which only + resolves the active one). Lets the IC app detect a prior ended command and offer to reopen it. + + + + + Reopens a previously closed command with a reason. Bootstrap-gated like EstablishCommand: after a + close there is no active command, so incident capabilities cannot be evaluated — the department-level + Command_Create claim is the gate. + + + + + List-card summaries for the department's commands: duration, resolved commander name, locations, and + active unit/personnel counts. Active only by default; includeClosed=true adds ended incidents. + + + + + Board snapshot for one specific command instance — including a CLOSED one (read-only history view for + ended incidents). Child rows are filtered to that command, so a closed board isn't polluted by a newer + command on the same call. + + Gets the full live command board snapshot for a call. @@ -1034,6 +1060,13 @@ Updates command-level details every resource should see: estimated end and important information. + + + Updates core incident metadata (name, corrected start time, estimated end, important information, ICS + level) and the ICP/HQ, Staging, and Rehab locations. A location whose text is supplied without + coordinates is geocoded server-side on save. + + Read-only incident view for the calling responder (or, with unitId, a unit client): commander @@ -1079,7 +1112,7 @@ Sets a tactical objective's progress (0-100; 100 completes it). - + Marks a tactical objective complete. @@ -1088,6 +1121,18 @@ Transitions an incident need's fulfillment status (Open/PartiallyMet/Met/Cancelled). + + Audit trail for one need: every fulfillment change with note, author, and timestamp (newest first). + + + + Creates an Entity need requesting specific units/users/roles/groups; they are added to the call + and dispatched individually as "Requested by Incident Command". + + + + The requested entities under one Entity-category need. + Gets the command-level needs for a call. @@ -1103,6 +1148,24 @@ Removes a map annotation. + + + Creates or updates a NAMED incident map (name, description, framing, optional expiry). Audit + fields stamp server-side; the change logs to the incident timeline with author + ICS standing. + + + + Soft-deletes a named incident map. + + + Gets the incident's named tactical maps. + + + + Creates or updates the incident map's saved view (center + zoom). Logged to the incident + timeline with the author's name and ICS standing. + + Gets the append-only command (ICS-201) timeline for a call. @@ -7557,15 +7620,49 @@ Input to update command-level details every resource should see. + + Input to reopen a previously closed command, with the caller's reason for reopening. + + + + Input to update core incident metadata and the ICP/HQ, Staging, and Rehab locations. Null fields are + left unchanged; empty strings clear. A location whose text is set while its coordinates are blank is + geocoded server-side on save. + + Input to set an objective's progress percentage (0-100; 100 completes it). + + Input to create/update the incident map's saved view (center + zoom) for consistent framing. + + + Map zoom level, 0-22. + + + Optional close-out details when completing an objective. + + + Maps to Resgrid.Model.TacticalObjectiveOutcome (Successful/Partial/Unsuccessful). + + + Optional close-out note, recorded on the objective and the incident log. + Input to transition an incident need's fulfillment status. Maps to Resgrid.Model.IncidentNeedStatus. + + New fulfilled quantity — may be lower than the current value (a fill got called off). + + + Optional context recorded on the audit trail and incident log ("Engine 1 from mutual aid"). + + + List-card summaries for the department's incident commands (active only or incl. closed). + Human-readable requirements notice. On Status=failure: why the assignment was rejected (forced @@ -7573,9 +7670,27 @@ meet the lane's non-forced requirements (also stamped on Data.RequirementsWarning/-Message). + + Input to create an Entity need: specific units/users/roles/groups requested by command. + + + One requested entity: kind maps to Resgrid.Model.NeedEntityKind (Unit/User/Role/Group). + + + The requested entities under one Entity-category need. + + + Audit trail for one need's fulfillment changes (newest first). + Read-only incident view for a responder or unit: commander, timing, objectives, needs, notes, attachments, own lane assignment. + + A single named incident tactical map. + + + The incident's named tactical maps. + Input logging one completed PTT transmission on an incident channel. From 4a236ca16971d57fb09445462c9ac5cb830c3b75 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Wed, 22 Jul 2026 14:30:43 -0700 Subject: [PATCH 2/2] RIC-T39 PR#435 fixes --- .../IncidentCommandService.cs | 26 +++++++++++-------- ...94_AddIncidentCommandNameAndLocationsPg.cs | 21 +++++++-------- .../v4/IncidentCommandController.cs | 3 +++ .../v4/PersonnelStatusesController.cs | 2 +- .../Controllers/v4/UnitStatusController.cs | 2 +- 5 files changed, 29 insertions(+), 25 deletions(-) diff --git a/Core/Resgrid.Services/IncidentCommandService.cs b/Core/Resgrid.Services/IncidentCommandService.cs index dc0943ce0..e9f5c85a7 100644 --- a/Core/Resgrid.Services/IncidentCommandService.cs +++ b/Core/Resgrid.Services/IncidentCommandService.cs @@ -798,19 +798,26 @@ public async Task> GetCommandSummariesForDepartment static bool IsUnitKind(int kind) => kind == (int)ResourceAssignmentKind.RealUnit || kind == (int)ResourceAssignmentKind.LinkedDeptUnit || kind == (int)ResourceAssignmentKind.AdHocUnit; - var summaries = new List(); - foreach (var command in selected) + // Best-effort per call: a failing lookup only loses that call's display fields. + var callsById = new Dictionary(); + foreach (var callId in selected.Select(x => x.CallId).Distinct()) { - Call call = null; try { - call = await _callsService.GetCallByIdAsync(command.CallId); + var loadedCall = await _callsService.GetCallByIdAsync(callId); + if (loadedCall != null) + callsById[callId] = loadedCall; } catch (Exception ex) { Resgrid.Framework.Logging.LogException(ex); } + } + var summaries = new List(); + foreach (var command in selected) + { + callsById.TryGetValue(command.CallId, out var call); var activeAssignments = assignmentsByCommand[command.IncidentCommandId].ToList(); profilesById.TryGetValue(command.CurrentCommanderUserId ?? string.Empty, out var commanderProfile); @@ -2460,6 +2467,10 @@ await WriteLogAsync(command.IncidentCommandId, command.DepartmentId, command.Cal map.Name = TrimToLength(map.Name, 500); map.Description = TrimToLength(map.Description, 2000); + // Server-stamped create audit; on updates the preserve callback restores the stored values. + map.CreatedByUserId = userId; + map.CreatedOn = DateTime.UtcNow; + var (saved, isNew, rejected) = await UpsertOwnedAsync(_incidentMapRepository, map, map.DepartmentId, e => e.DepartmentId, (stored, incoming) => { @@ -2473,13 +2484,6 @@ await WriteLogAsync(command.IncidentCommandId, command.DepartmentId, command.Cal return null; map = saved; - if (isNew) - { - map.CreatedByUserId = userId; - map.CreatedOn = DateTime.UtcNow; - map = await _incidentMapRepository.SaveOrUpdateAsync(Touch(map), cancellationToken); - } - // Log with the author's name and command/ICS standing (spec: who + role + when). var author = await BuildNoteAuthorLabelAsync(command, userId); await WriteLogAsync(map.IncidentCommandId, map.DepartmentId, map.CallId, CommandLogEntryType.MapViewUpdated, diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0094_AddIncidentCommandNameAndLocationsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0094_AddIncidentCommandNameAndLocationsPg.cs index 5d7e6b701..6dfa6ddb0 100644 --- a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0094_AddIncidentCommandNameAndLocationsPg.cs +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0094_AddIncidentCommandNameAndLocationsPg.cs @@ -10,17 +10,19 @@ namespace Resgrid.Providers.MigrationsPg.Migrations [Migration(94)] public class M0094_AddIncidentCommandNameAndLocationsPg : Migration { + private static readonly string[] IncidentCommandColumns = + { + "name", "commandpostlocationtext", + "staginglocationtext", "staginglatitude", "staginglongitude", + "rehablocationtext", "rehablatitude", "rehablongitude" + }; + public override void Up() { if (!Schema.Table("incidentcommands").Exists()) return; - foreach (var column in new[] - { - "name", "commandpostlocationtext", - "staginglocationtext", "staginglatitude", "staginglongitude", - "rehablocationtext", "rehablatitude", "rehablongitude" - }) + foreach (var column in IncidentCommandColumns) { if (!Schema.Table("incidentcommands").Column(column).Exists()) Alter.Table("incidentcommands").AddColumn(column).AsCustom("citext").Nullable(); @@ -32,12 +34,7 @@ public override void Down() if (!Schema.Table("incidentcommands").Exists()) return; - foreach (var column in new[] - { - "name", "commandpostlocationtext", - "staginglocationtext", "staginglatitude", "staginglongitude", - "rehablocationtext", "rehablatitude", "rehablongitude" - }) + foreach (var column in IncidentCommandColumns) { if (Schema.Table("incidentcommands").Column(column).Exists()) Delete.Column(column).FromTable("incidentcommands"); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/IncidentCommandController.cs b/Web/Resgrid.Web.Services/Controllers/v4/IncidentCommandController.cs index 4642fb475..accc5517d 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/IncidentCommandController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/IncidentCommandController.cs @@ -956,6 +956,9 @@ public async Task DownloadAttachment(string incidentAttachmentId) if (input == null || string.IsNullOrWhiteSpace(input.IncidentCommandId) || string.IsNullOrWhiteSpace(input.Name) || input.Entities == null || input.Entities.Count == 0) return BadRequest(); + if (input.Entities.Any(e => !Enum.IsDefined(typeof(NeedEntityKind), e.EntityKind))) + return BadRequest(); + try { var entities = input.Entities.Select(e => new IncidentNeedEntity { EntityKind = e.EntityKind, EntityId = e.EntityId }).ToList(); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/PersonnelStatusesController.cs b/Web/Resgrid.Web.Services/Controllers/v4/PersonnelStatusesController.cs index f76942f0d..9abf46010 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/PersonnelStatusesController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/PersonnelStatusesController.cs @@ -175,7 +175,7 @@ public async Task> SavePersonStatus(SavePer } catch (Exception ex) { - Resgrid.Framework.Logging.LogException(ex); + Resgrid.Framework.Logging.LogException(ex, $"RecordNeedEntityStatusAsync failed for DepartmentId {DepartmentId}, DestinationId {destinationId}, UserId {input.UserId}"); } } } diff --git a/Web/Resgrid.Web.Services/Controllers/v4/UnitStatusController.cs b/Web/Resgrid.Web.Services/Controllers/v4/UnitStatusController.cs index cab9c8edc..0443c2900 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/UnitStatusController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/UnitStatusController.cs @@ -289,7 +289,7 @@ public async Task> GetUnitStatus(string unitId) try { var stateLabel = Enum.IsDefined(typeof(UnitStateTypes), state.State) ? ((UnitStateTypes)state.State).ToString() : $"status {state.State}"; - await _incidentCommandService.RecordNeedEntityStatusAsync(DepartmentId, state.DestinationId.Value, NeedEntityKind.Unit, state.UnitId.ToString(), stateLabel, UserId); + await _incidentCommandService.RecordNeedEntityStatusAsync(DepartmentId, state.DestinationId.Value, NeedEntityKind.Unit, state.UnitId.ToString(), stateLabel, UserId, cancellationToken); } catch (Exception ex) {