diff --git a/Core/Resgrid.Model/Events/IncidentCommandEvents.cs b/Core/Resgrid.Model/Events/IncidentCommandEvents.cs
index 97fd78b2..657d52ef 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 4ac52c41..30034d4c 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 f9c6906a..0cbe86c6 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 8b1e1e5a..7938ea58 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 fed18a71..a4495e83 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 edf855a1..4fb06223 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 00000000..ee2a557c
--- /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 00000000..ee017134
--- /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 e4fecdd6..92bebe5b 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 00000000..4907454a
--- /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 72bd77ba..29deb550 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 f09f9f63..f9d8e408 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 ec6bf99b..0098966c 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 5f8d116a..e9f5c85a 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,236 @@ 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;
+
+ // 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())
+ {
+ try
+ {
+ 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);
+
+ 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 +899,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 +911,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 +1150,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 +1189,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 +1284,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 +1312,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 +1335,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 +1473,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 +1545,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 +1558,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 +1597,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 +1622,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 +1781,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 +1821,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 +1895,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 +1924,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 +1983,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 +2338,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 +2390,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 +2403,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 +2425,98 @@ 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);
+
+ // 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) =>
+ {
+ 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;
+
+ // 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 +2591,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 00000000..cff1755d
--- /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 00000000..d94069e5
--- /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 00000000..ce263221
--- /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 00000000..3ccc0f9e
--- /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 00000000..bd1535d9
--- /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 00000000..2e6c63a4
--- /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 00000000..6dfa6ddb
--- /dev/null
+++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0094_AddIncidentCommandNameAndLocationsPg.cs
@@ -0,0 +1,44 @@
+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
+ {
+ 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 IncidentCommandColumns)
+ {
+ 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 IncidentCommandColumns)
+ {
+ 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 00000000..6a22fa9b
--- /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 00000000..8ee3f26d
--- /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 00000000..a7fcb785
--- /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 00000000..2afb348d
--- /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 00000000..8cd1dfa4
--- /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 a0172b31..41318e9a 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 20e548d8..d48a9cd0 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 c070d47a..477745c9 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 00000000..5fce6b18
--- /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