diff --git a/Core/Resgrid.Model/CommandDefinitionRole.cs b/Core/Resgrid.Model/CommandDefinitionRole.cs
index f10d7e55a..001a90d74 100644
--- a/Core/Resgrid.Model/CommandDefinitionRole.cs
+++ b/Core/Resgrid.Model/CommandDefinitionRole.cs
@@ -35,6 +35,17 @@ public class CommandDefinitionRole : IEntity
public bool ForceRequirements { get; set; }
+ ///
+ /// The type of ICS lane/node this role maps to on the runtime command board
+ /// (e.g. Division, Group, Branch, Staging). Backs §3.2 CommandStructureNode seeding.
+ ///
+ public int LaneType { get; set; }
+
+ ///
+ /// Display/ordering position of this lane within the command definition.
+ ///
+ public int SortOrder { get; set; }
+
public virtual ICollection RequiredUnitTypes { get; set; }
public virtual ICollection RequiredCerts { get; set; }
diff --git a/Core/Resgrid.Model/CqrsEventTypes.cs b/Core/Resgrid.Model/CqrsEventTypes.cs
index 707f7b47f..ba806ceb5 100644
--- a/Core/Resgrid.Model/CqrsEventTypes.cs
+++ b/Core/Resgrid.Model/CqrsEventTypes.cs
@@ -24,5 +24,6 @@ public enum CqrsEventTypes
PaddleSubscriptionUpdated = 19,
PaddleSubscriptionCanceled = 20,
PaddleSubscriptionCreated = 21,
+ IncidentCommandUpdated = 22,
}
}
diff --git a/Core/Resgrid.Model/DepartmentVoiceChannel.cs b/Core/Resgrid.Model/DepartmentVoiceChannel.cs
index f15b04d9a..82cf5bbb8 100644
--- a/Core/Resgrid.Model/DepartmentVoiceChannel.cs
+++ b/Core/Resgrid.Model/DepartmentVoiceChannel.cs
@@ -1,4 +1,5 @@
-using Newtonsoft.Json;
+using System;
+using Newtonsoft.Json;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations.Schema;
@@ -26,6 +27,15 @@ public class DepartmentVoiceChannel : IEntity
public bool IsDefault { get; set; }
+ /// When set, this is an on-demand tactical channel scoped to a specific Call/incident (§3.4).
+ public int? CallId { get; set; }
+
+ /// True for IC-created on-demand incident channels (vs. standing department channels).
+ public bool IsOnDemand { get; set; }
+
+ /// When the on-demand incident channel was closed (soft-close at incident close).
+ public DateTime? ClosedOn { get; set; }
+
[NotMapped]
[JsonIgnore]
public object IdValue
diff --git a/Core/Resgrid.Model/Events/IncidentCommandEvents.cs b/Core/Resgrid.Model/Events/IncidentCommandEvents.cs
new file mode 100644
index 000000000..3985b1c69
--- /dev/null
+++ b/Core/Resgrid.Model/Events/IncidentCommandEvents.cs
@@ -0,0 +1,96 @@
+namespace Resgrid.Model.Events
+{
+ /// Raised when command is established on an incident (§3.12 workflow trigger).
+ public class CommandEstablishedEvent
+ {
+ public int DepartmentId { get; set; }
+ public int CallId { get; set; }
+ public string IncidentCommandId { get; set; }
+ public string EstablishedByUserId { get; set; }
+ }
+
+ /// Raised when command is transferred to another user.
+ public class CommandTransferredEvent
+ {
+ public int DepartmentId { get; set; }
+ public int CallId { get; set; }
+ public string IncidentCommandId { get; set; }
+ public string FromUserId { get; set; }
+ public string ToUserId { get; set; }
+ }
+
+ /// Raised when a tactical objective / benchmark is completed.
+ public class IncidentObjectiveCompletedEvent
+ {
+ public int DepartmentId { get; set; }
+ public int CallId { get; set; }
+ public string IncidentCommandId { get; set; }
+ public string TacticalObjectiveId { get; set; }
+ public string Name { get; set; }
+ }
+
+ /// Raised when command is closed on an incident.
+ public class IncidentClosedEvent
+ {
+ public int DepartmentId { get; set; }
+ public int CallId { get; set; }
+ public string IncidentCommandId { get; set; }
+ }
+
+ /// Raised when a resource is assigned to a command structure node.
+ public class IncidentResourceAssignedEvent
+ {
+ public int DepartmentId { get; set; }
+ public int CallId { get; set; }
+ public string IncidentCommandId { get; set; }
+ public int ResourceKind { get; set; }
+ public string ResourceId { get; set; }
+ }
+
+ /// Raised when a resource is released from an incident.
+ public class IncidentResourceReleasedEvent
+ {
+ public int DepartmentId { get; set; }
+ public int CallId { get; set; }
+ public string ResourceAssignmentId { get; set; }
+ }
+
+ /// Raised when a user is assigned a functional incident role.
+ public class IncidentRoleAssignedEvent
+ {
+ public int DepartmentId { get; set; }
+ public int CallId { get; set; }
+ public string UserId { get; set; }
+ public int RoleType { get; set; }
+ }
+
+ /// Raised when an ad-hoc resource is created for an incident.
+ public class AdHocResourceCreatedEvent
+ {
+ public int DepartmentId { get; set; }
+ public int CallId { get; set; }
+ public string ResourceId { get; set; }
+ public string Name { get; set; }
+ public string Kind { get; set; }
+ }
+
+ /// Raised when an on-demand tactical voice channel is opened for an incident.
+ public class IncidentChannelOpenedEvent
+ {
+ public int DepartmentId { get; set; }
+ public int CallId { get; set; }
+ public string DepartmentVoiceChannelId { get; set; }
+ public string Name { get; set; }
+ }
+
+ ///
+ /// Raised when personnel accountability reaches a critical state (PAR overdue). No firing point yet — a PAR
+ /// evaluation worker would raise this; included so departments can configure workflows for it.
+ ///
+ public class CriticalParDetectedEvent
+ {
+ public int DepartmentId { get; set; }
+ public int CallId { get; set; }
+ public string UserId { get; set; }
+ }
+}
diff --git a/Core/Resgrid.Model/IncidentCommand/AssignableResource.cs b/Core/Resgrid.Model/IncidentCommand/AssignableResource.cs
new file mode 100644
index 000000000..2346a4149
--- /dev/null
+++ b/Core/Resgrid.Model/IncidentCommand/AssignableResource.cs
@@ -0,0 +1,27 @@
+namespace Resgrid.Model
+{
+ ///
+ /// A resource (unit or person) that the Commander can assign to a lane — sourced from the own department
+ /// or a linked (mutual-aid) department. Color-coded per the mutual-aid link.
+ ///
+ public class AssignableResource
+ {
+ /// Maps to (RealUnit/RealPersonnel/LinkedDeptUnit/LinkedDeptPersonnel).
+ public int ResourceKind { get; set; }
+
+ /// Unit id (as string) or personnel user id.
+ public string ResourceId { get; set; }
+
+ /// Display name of the unit or person.
+ public string Name { get; set; }
+
+ /// The department this resource belongs to.
+ public int DepartmentId { get; set; }
+
+ /// True when the resource comes from a linked (mutual-aid) department.
+ public bool IsMutualAid { get; set; }
+
+ /// Map/marker color from the mutual-aid link (null for own-department resources).
+ public string Color { get; set; }
+ }
+}
diff --git a/Core/Resgrid.Model/IncidentCommand/CommandStructureNode.cs b/Core/Resgrid.Model/IncidentCommand/CommandStructureNode.cs
new file mode 100644
index 000000000..5b705a742
--- /dev/null
+++ b/Core/Resgrid.Model/IncidentCommand/CommandStructureNode.cs
@@ -0,0 +1,108 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations.Schema;
+using Newtonsoft.Json;
+
+namespace Resgrid.Model
+{
+ ///
+ /// A live lane / span-of-control node on the command board (Division, Group, Branch, Staging, ...).
+ /// Initially seeded from a CommandDefinitionRole then per-incident editable.
+ ///
+ public class CommandStructureNode : IEntity
+ {
+ public string CommandStructureNodeId { get; set; }
+
+ public string IncidentCommandId { get; set; }
+
+ public int DepartmentId { get; set; }
+
+ public int CallId { get; set; }
+
+ /// Maps to .
+ public int NodeType { get; set; }
+
+ public string Name { get; set; }
+
+ /// Parent node for branch/division/group hierarchies; null for top-level nodes.
+ public string ParentNodeId { get; set; }
+
+ public string SupervisorUserId { get; set; }
+
+ public int? SupervisorUnitId { get; set; }
+
+ public int SortOrder { get; set; }
+
+ /// The CommandDefinitionRole this node was seeded from, if any.
+ public int? SourceRoleId { get; set; }
+
+ [NotMapped]
+ public string TableName => "CommandStructureNodes";
+
+ [NotMapped]
+ public string IdName => "CommandStructureNodeId";
+
+ [NotMapped]
+ public int IdType => 1;
+
+ [NotMapped]
+ [JsonIgnore]
+ public object IdValue
+ {
+ get { return CommandStructureNodeId; }
+ set { CommandStructureNodeId = (string)value; }
+ }
+
+ [NotMapped]
+ public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" };
+ }
+
+ ///
+ /// Assigns a resource to a command structure node. Polymorphic: the resource may be an own-department
+ /// unit/person, a linked (mutual-aid) department unit/person, or an incident ad-hoc unit/person.
+ ///
+ public class ResourceAssignment : IEntity
+ {
+ public string ResourceAssignmentId { get; set; }
+
+ public string IncidentCommandId { get; set; }
+
+ public int DepartmentId { get; set; }
+
+ public int CallId { get; set; }
+
+ public string CommandStructureNodeId { get; set; }
+
+ /// Maps to .
+ public int ResourceKind { get; set; }
+
+ /// Polymorphic resource id (unit id, user id, or ad-hoc guid) stored as string.
+ public string ResourceId { get; set; }
+
+ public string AssignedByUserId { get; set; }
+
+ public DateTime AssignedOn { get; set; }
+
+ public DateTime? ReleasedOn { get; set; }
+
+ [NotMapped]
+ public string TableName => "ResourceAssignments";
+
+ [NotMapped]
+ public string IdName => "ResourceAssignmentId";
+
+ [NotMapped]
+ public int IdType => 1;
+
+ [NotMapped]
+ [JsonIgnore]
+ public object IdValue
+ {
+ get { return ResourceAssignmentId; }
+ set { ResourceAssignmentId = (string)value; }
+ }
+
+ [NotMapped]
+ public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" };
+ }
+}
diff --git a/Core/Resgrid.Model/IncidentCommand/IncidentAdHocResources.cs b/Core/Resgrid.Model/IncidentCommand/IncidentAdHocResources.cs
new file mode 100644
index 000000000..816f0703b
--- /dev/null
+++ b/Core/Resgrid.Model/IncidentCommand/IncidentAdHocResources.cs
@@ -0,0 +1,111 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations.Schema;
+using Newtonsoft.Json;
+
+namespace Resgrid.Model
+{
+ ///
+ /// An incident-scoped, ad-hoc unit created on the fly for resources not in Resgrid (e.g. a mutual-aid
+ /// crew from a non-Resgrid agency, or a unit formed from on-scene personnel). Not a real department Unit.
+ ///
+ public class IncidentAdHocUnit : IEntity
+ {
+ public string IncidentAdHocUnitId { get; set; }
+
+ public int DepartmentId { get; set; }
+
+ public int CallId { get; set; }
+
+ public string Name { get; set; }
+
+ /// Optional reference to a department UnitType for classification.
+ public int? UnitTypeId { get; set; }
+
+ /// Free-text unit type (e.g. "Engine", "Ambulance") when no UnitTypeId applies.
+ public string Type { get; set; }
+
+ /// Name of the external (non-Resgrid) agency this resource belongs to, if any.
+ public string ExternalAgencyName { get; set; }
+
+ public string CreatedByUserId { get; set; }
+
+ public DateTime CreatedOn { get; set; }
+
+ public DateTime? ReleasedOn { get; set; }
+
+ [NotMapped]
+ public string TableName => "IncidentAdHocUnits";
+
+ [NotMapped]
+ public string IdName => "IncidentAdHocUnitId";
+
+ [NotMapped]
+ public int IdType => 1;
+
+ [NotMapped]
+ [JsonIgnore]
+ public object IdValue
+ {
+ get { return IncidentAdHocUnitId; }
+ set { IncidentAdHocUnitId = (string)value; }
+ }
+
+ [NotMapped]
+ public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" };
+ }
+
+ ///
+ /// An incident-scoped, ad-hoc person created on the fly for resources not in Resgrid. May ride an ad-hoc
+ /// (or real) unit for accountability via + .
+ ///
+ public class IncidentAdHocPersonnel : IEntity
+ {
+ public string IncidentAdHocPersonnelId { get; set; }
+
+ public int DepartmentId { get; set; }
+
+ public int CallId { get; set; }
+
+ public string Name { get; set; }
+
+ /// Role / qualification (e.g. "Paramedic", "Firefighter").
+ public string Role { get; set; }
+
+ public string ExternalAgencyName { get; set; }
+
+ public string Contact { get; set; }
+
+ /// The kind of unit this person is riding for accountability (maps to ).
+ public int RidingResourceKind { get; set; }
+
+ /// Identifier of the unit this person is riding (ad-hoc unit id, real unit id, ...), or null.
+ public string RidingResourceId { get; set; }
+
+ public string CreatedByUserId { get; set; }
+
+ public DateTime CreatedOn { get; set; }
+
+ public DateTime? ReleasedOn { get; set; }
+
+ [NotMapped]
+ public string TableName => "IncidentAdHocPersonnel";
+
+ [NotMapped]
+ public string IdName => "IncidentAdHocPersonnelId";
+
+ [NotMapped]
+ public int IdType => 1;
+
+ [NotMapped]
+ [JsonIgnore]
+ public object IdValue
+ {
+ get { return IncidentAdHocPersonnelId; }
+ set { IncidentAdHocPersonnelId = (string)value; }
+ }
+
+ [NotMapped]
+ public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" };
+ }
+}
diff --git a/Core/Resgrid.Model/IncidentCommand/IncidentCommand.cs b/Core/Resgrid.Model/IncidentCommand/IncidentCommand.cs
new file mode 100644
index 000000000..698815472
--- /dev/null
+++ b/Core/Resgrid.Model/IncidentCommand/IncidentCommand.cs
@@ -0,0 +1,148 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations.Schema;
+using Newtonsoft.Json;
+
+namespace Resgrid.Model
+{
+ ///
+ /// A live incident-command instance established on a specific Call. Seeded (optionally) from a
+ /// CommandDefinition template and then freely editable by the Commander for the life of the incident.
+ ///
+ public class IncidentCommand : IEntity
+ {
+ public string IncidentCommandId { get; set; }
+
+ public int DepartmentId { get; set; }
+
+ public int CallId { get; set; }
+
+ /// The CommandDefinition this instance was seeded from, if any.
+ public int? SourceCommandDefinitionId { get; set; }
+
+ public string EstablishedByUserId { get; set; }
+
+ public DateTime EstablishedOn { get; set; }
+
+ public string CurrentCommanderUserId { get; set; }
+
+ public string CommandPostLatitude { get; set; }
+
+ public string CommandPostLongitude { get; set; }
+
+ public string IncidentActionPlan { get; set; }
+
+ /// NIMS/ICS escalation level for the incident (department defined).
+ public int IcsLevel { get; set; }
+
+ /// Maps to .
+ public int Status { get; set; }
+
+ public DateTime? ClosedOn { get; set; }
+
+ [NotMapped]
+ public string TableName => "IncidentCommands";
+
+ [NotMapped]
+ public string IdName => "IncidentCommandId";
+
+ [NotMapped]
+ public int IdType => 1;
+
+ [NotMapped]
+ [JsonIgnore]
+ public object IdValue
+ {
+ get { return IncidentCommandId; }
+ set { IncidentCommandId = (string)value; }
+ }
+
+ [NotMapped]
+ public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" };
+ }
+
+ /// Log of a command transfer (handoff of Incident Commander).
+ public class CommandTransfer : IEntity
+ {
+ public string CommandTransferId { get; set; }
+
+ public string IncidentCommandId { get; set; }
+
+ public int DepartmentId { get; set; }
+
+ public int CallId { get; set; }
+
+ public string FromUserId { get; set; }
+
+ public string ToUserId { get; set; }
+
+ public DateTime TransferredOn { get; set; }
+
+ public string Notes { get; set; }
+
+ [NotMapped]
+ public string TableName => "CommandTransfers";
+
+ [NotMapped]
+ public string IdName => "CommandTransferId";
+
+ [NotMapped]
+ public int IdType => 1;
+
+ [NotMapped]
+ [JsonIgnore]
+ public object IdValue
+ {
+ get { return CommandTransferId; }
+ set { CommandTransferId = (string)value; }
+ }
+
+ [NotMapped]
+ public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" };
+ }
+
+ /// An append-only ICS-201 style timeline entry, auto-written on every command action.
+ public class CommandLogEntry : IEntity
+ {
+ public string CommandLogEntryId { get; set; }
+
+ public string IncidentCommandId { get; set; }
+
+ public int DepartmentId { get; set; }
+
+ public int CallId { get; set; }
+
+ /// Maps to .
+ public int EntryType { get; set; }
+
+ public string Description { get; set; }
+
+ public string UserId { get; set; }
+
+ public string Latitude { get; set; }
+
+ public string Longitude { get; set; }
+
+ public DateTime OccurredOn { get; set; }
+
+ [NotMapped]
+ public string TableName => "CommandLogEntries";
+
+ [NotMapped]
+ public string IdName => "CommandLogEntryId";
+
+ [NotMapped]
+ public int IdType => 1;
+
+ [NotMapped]
+ [JsonIgnore]
+ public object IdValue
+ {
+ get { return CommandLogEntryId; }
+ set { CommandLogEntryId = (string)value; }
+ }
+
+ [NotMapped]
+ public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" };
+ }
+}
diff --git a/Core/Resgrid.Model/IncidentCommand/IncidentCommandBoard.cs b/Core/Resgrid.Model/IncidentCommand/IncidentCommandBoard.cs
new file mode 100644
index 000000000..076bc90f5
--- /dev/null
+++ b/Core/Resgrid.Model/IncidentCommand/IncidentCommandBoard.cs
@@ -0,0 +1,28 @@
+using System.Collections.Generic;
+
+namespace Resgrid.Model
+{
+ ///
+ /// Composite, one-shot snapshot of a live incident command board (Tablet Command "Real Time Sync" view).
+ ///
+ public class IncidentCommandBoard
+ {
+ public IncidentCommand Command { get; set; }
+
+ public List Nodes { get; set; } = new List();
+
+ public List Assignments { get; set; } = new List();
+
+ public List Objectives { get; set; } = new List();
+
+ public List Timers { get; set; } = new List();
+
+ public List Annotations { get; set; } = new List();
+
+ /// Personnel accountability / PAR status (from the Checkin feature) for the incident.
+ public List Accountability { get; set; } = new List();
+
+ /// Active functional command-role assignments for the incident (§3.11).
+ public List Roles { get; set; } = new List();
+ }
+}
diff --git a/Core/Resgrid.Model/IncidentCommand/IncidentCommandEnums.cs b/Core/Resgrid.Model/IncidentCommand/IncidentCommandEnums.cs
new file mode 100644
index 000000000..ac359f2cf
--- /dev/null
+++ b/Core/Resgrid.Model/IncidentCommand/IncidentCommandEnums.cs
@@ -0,0 +1,118 @@
+namespace Resgrid.Model
+{
+ /// Lifecycle status of a live incident command instance.
+ public enum IncidentCommandStatus
+ {
+ Active = 0,
+ Closed = 1
+ }
+
+ /// ICS structural node types (the "lanes" / span-of-control units on the command board).
+ public enum CommandNodeType
+ {
+ Division = 0,
+ Group = 1,
+ Branch = 2,
+ Sector = 3,
+ StrikeTeam = 4,
+ TaskForce = 5,
+ Staging = 6,
+ UnifiedCommand = 7
+ }
+
+ /// What kind of resource a ResourceAssignment points at (polymorphic).
+ public enum ResourceAssignmentKind
+ {
+ RealUnit = 0,
+ RealPersonnel = 1,
+ LinkedDeptUnit = 2,
+ LinkedDeptPersonnel = 3,
+ AdHocUnit = 4,
+ AdHocPersonnel = 5
+ }
+
+ /// Classification of a tactical objective / benchmark.
+ public enum TacticalObjectiveType
+ {
+ General = 0,
+ Benchmark = 1,
+ Safety = 2
+ }
+
+ /// Completion state of a tactical objective.
+ public enum TacticalObjectiveStatus
+ {
+ Pending = 0,
+ Complete = 1
+ }
+
+ /// Type of incident timer (personnel PAR is handled by the Checkin feature, not these).
+ public enum IncidentTimerType
+ {
+ Scene = 0,
+ Benchmark = 1,
+ Role = 2,
+ Custom = 3
+ }
+
+ /// What an incident timer is scoped to.
+ public enum IncidentTimerScopeType
+ {
+ Incident = 0,
+ Node = 1,
+ Unit = 2
+ }
+
+ /// Runtime status of an incident timer.
+ public enum IncidentTimerStatus
+ {
+ Running = 0,
+ Due = 1,
+ Acknowledged = 2,
+ Stopped = 3
+ }
+
+ /// Type of a real-time map annotation drawn on the tactical map.
+ public enum IncidentMapAnnotationType
+ {
+ Line = 0,
+ Polygon = 1,
+ Symbol = 2,
+ Text = 3,
+ Marker = 4
+ }
+
+ /// Type of an entry in the append-only command (ICS-201) timeline.
+ public enum CommandLogEntryType
+ {
+ CommandEstablished = 0,
+ CommandTransferred = 1,
+ NodeAdded = 2,
+ NodeUpdated = 3,
+ NodeRemoved = 4,
+ ResourceAssigned = 5,
+ ResourceMoved = 6,
+ ResourceReleased = 7,
+ ObjectiveAdded = 8,
+ ObjectiveCompleted = 9,
+ TimerStarted = 10,
+ TimerAcknowledged = 11,
+ AnnotationAdded = 12,
+ AnnotationRemoved = 13,
+ CheckIn = 14,
+ ChannelOpened = 15,
+ ChannelClosed = 16,
+ RoleAssigned = 17,
+ RoleRemoved = 18,
+ AdHocResourceCreated = 19,
+ Note = 20,
+ CommandClosed = 21,
+
+ ///
+ /// Personnel accountability (PAR) for a member went Critical (overdue for check-in). Written by the
+ /// PAR sweep (IncidentCommandService.EvaluateCriticalParAsync) keyed on the subject user, and
+ /// doubles as the dedup marker so the alert only re-fires after the member checks in and lapses again.
+ ///
+ ParCritical = 22
+ }
+}
diff --git a/Core/Resgrid.Model/IncidentCommand/IncidentReport.cs b/Core/Resgrid.Model/IncidentCommand/IncidentReport.cs
new file mode 100644
index 000000000..e9e087a1f
--- /dev/null
+++ b/Core/Resgrid.Model/IncidentCommand/IncidentReport.cs
@@ -0,0 +1,36 @@
+using System;
+using System.Collections.Generic;
+
+namespace Resgrid.Model
+{
+ /// ICS-201/209-style summary metrics for an incident (§3.13).
+ public class IncidentReportSummary
+ {
+ public int CallId { get; set; }
+ public string IncidentCommandId { get; set; }
+ public DateTime? EstablishedOn { get; set; }
+ public DateTime? ClosedOn { get; set; }
+ public double DurationMinutes { get; set; }
+ public string CurrentCommanderUserId { get; set; }
+ public int LaneCount { get; set; }
+ public int ActiveAssignmentCount { get; set; }
+ public int ObjectiveCount { get; set; }
+ public int CompletedObjectiveCount { get; set; }
+ public int TimelineEntryCount { get; set; }
+ public int RoleCount { get; set; }
+ public int AccountabilityGreen { get; set; }
+ public int AccountabilityWarning { get; set; }
+ public int AccountabilityCritical { get; set; }
+ }
+
+ /// A complete after-action bundle for an incident.
+ public class IncidentAfterActionReport
+ {
+ public IncidentReportSummary Summary { get; set; }
+ public List Nodes { get; set; } = new List();
+ public List Assignments { get; set; } = new List();
+ public List Objectives { get; set; } = new List();
+ public List Timeline { get; set; } = new List();
+ public List Roles { get; set; } = new List();
+ }
+}
diff --git a/Core/Resgrid.Model/IncidentCommand/IncidentRole.cs b/Core/Resgrid.Model/IncidentCommand/IncidentRole.cs
new file mode 100644
index 000000000..c12583610
--- /dev/null
+++ b/Core/Resgrid.Model/IncidentCommand/IncidentRole.cs
@@ -0,0 +1,197 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations.Schema;
+using Newtonsoft.Json;
+
+namespace Resgrid.Model
+{
+ ///
+ /// Functional incident-command positions (NIMS/ICS) across Fire / EMS / SAR / Natural-disaster / Industrial-HazMat.
+ /// Each maps to a specialized Resgrid IC app view and a capability set (see ).
+ ///
+ public enum IncidentRoleType
+ {
+ IncidentCommander = 0,
+ DeputyIncidentCommander = 1,
+ UnifiedCommandMember = 2,
+ OperationsSectionChief = 3,
+ PlanningSectionChief = 4,
+ LogisticsSectionChief = 5,
+ FinanceAdminSectionChief = 6,
+ SafetyOfficer = 7,
+ LiaisonOfficer = 8,
+ PublicInformationOfficer = 9,
+ StagingAreaManager = 10,
+ ResourcesUnitLeader = 11,
+ SituationUnitLeader = 12,
+ DocumentationUnitLeader = 13,
+ CommunicationsUnitLeader = 14,
+ DivisionGroupSupervisor = 15,
+ BranchDirector = 16,
+ StrikeTeamTaskForceLeader = 17,
+ MedicalUnitLeader = 18,
+ RehabOfficer = 19,
+ MedicalBranchDirector = 20,
+ TriageOfficer = 21,
+ TreatmentOfficer = 22,
+ TransportOfficer = 23,
+ HazMatGroupSupervisor = 24,
+ DeconOfficer = 25,
+ EntryTeamLeader = 26,
+ SearchGroupSupervisor = 27,
+ AirOperationsBranchDirector = 28,
+ ShelterMassCareCoordinator = 29,
+ DamageAssessmentLead = 30
+ }
+
+ /// Capabilities an incident role may have; drives both server-side checks and the app's view gating.
+ [Flags]
+ public enum IncidentCapabilities
+ {
+ None = 0,
+ ViewBoard = 1,
+ ManageCommand = 2, // establish/close/transfer/action-plan/assign-roles (command staff only)
+ ManageStructure = 4, // add/edit/remove lanes
+ AssignResources = 8, // assign/move/release resources
+ ManageObjectives = 16,
+ ManageTimers = 32,
+ ManageAnnotations = 64,
+ ManageAccountability = 128, // check-ins / PAR
+ ManageChannels = 256, // on-demand voice channels
+ ManageResources = 512, // create ad-hoc resources / staging intake
+ ViewReports = 1024,
+ All = ViewBoard | ManageCommand | ManageStructure | AssignResources | ManageObjectives | ManageTimers | ManageAnnotations | ManageAccountability | ManageChannels | ManageResources | ViewReports
+ }
+
+ ///
+ /// Maps each to its capability set. Code table (not config) so it ships with
+ /// the app; the IC/Deputy/Unified-Command roles get everything, others are scoped to their function.
+ ///
+ public static class IncidentRoleCapabilityMap
+ {
+ public static IncidentCapabilities GetCapabilities(IncidentRoleType role)
+ {
+ switch (role)
+ {
+ case IncidentRoleType.IncidentCommander:
+ case IncidentRoleType.DeputyIncidentCommander:
+ case IncidentRoleType.UnifiedCommandMember:
+ return IncidentCapabilities.All;
+
+ case IncidentRoleType.OperationsSectionChief:
+ return IncidentCapabilities.ViewBoard | IncidentCapabilities.ManageStructure | IncidentCapabilities.AssignResources |
+ IncidentCapabilities.ManageObjectives | IncidentCapabilities.ManageTimers | IncidentCapabilities.ManageResources;
+
+ case IncidentRoleType.PlanningSectionChief:
+ case IncidentRoleType.SituationUnitLeader:
+ return IncidentCapabilities.ViewBoard | IncidentCapabilities.ManageObjectives | IncidentCapabilities.ManageAnnotations | IncidentCapabilities.ViewReports;
+
+ case IncidentRoleType.DocumentationUnitLeader:
+ return IncidentCapabilities.ViewBoard | IncidentCapabilities.ViewReports;
+
+ case IncidentRoleType.LogisticsSectionChief:
+ return IncidentCapabilities.ViewBoard | IncidentCapabilities.ManageChannels | IncidentCapabilities.ManageResources;
+
+ case IncidentRoleType.CommunicationsUnitLeader:
+ return IncidentCapabilities.ViewBoard | IncidentCapabilities.ManageChannels;
+
+ case IncidentRoleType.FinanceAdminSectionChief:
+ case IncidentRoleType.PublicInformationOfficer:
+ return IncidentCapabilities.ViewBoard | IncidentCapabilities.ViewReports;
+
+ case IncidentRoleType.SafetyOfficer:
+ return IncidentCapabilities.ViewBoard | IncidentCapabilities.ManageAnnotations | IncidentCapabilities.ManageObjectives;
+
+ case IncidentRoleType.LiaisonOfficer:
+ return IncidentCapabilities.ViewBoard | IncidentCapabilities.ManageResources;
+
+ case IncidentRoleType.StagingAreaManager:
+ return IncidentCapabilities.ViewBoard | IncidentCapabilities.ManageResources | IncidentCapabilities.AssignResources;
+
+ case IncidentRoleType.ResourcesUnitLeader:
+ return IncidentCapabilities.ViewBoard | IncidentCapabilities.ManageAccountability | IncidentCapabilities.AssignResources;
+
+ case IncidentRoleType.DivisionGroupSupervisor:
+ case IncidentRoleType.BranchDirector:
+ case IncidentRoleType.StrikeTeamTaskForceLeader:
+ return IncidentCapabilities.ViewBoard | IncidentCapabilities.AssignResources | IncidentCapabilities.ManageObjectives | IncidentCapabilities.ManageAccountability;
+
+ case IncidentRoleType.MedicalUnitLeader:
+ case IncidentRoleType.RehabOfficer:
+ return IncidentCapabilities.ViewBoard | IncidentCapabilities.ManageAccountability;
+
+ case IncidentRoleType.MedicalBranchDirector:
+ case IncidentRoleType.TriageOfficer:
+ case IncidentRoleType.TreatmentOfficer:
+ case IncidentRoleType.TransportOfficer:
+ return IncidentCapabilities.ViewBoard | IncidentCapabilities.ManageObjectives | IncidentCapabilities.ManageAccountability;
+
+ case IncidentRoleType.HazMatGroupSupervisor:
+ case IncidentRoleType.DeconOfficer:
+ case IncidentRoleType.EntryTeamLeader:
+ return IncidentCapabilities.ViewBoard | IncidentCapabilities.ManageObjectives | IncidentCapabilities.ManageTimers | IncidentCapabilities.ManageAccountability;
+
+ case IncidentRoleType.SearchGroupSupervisor:
+ case IncidentRoleType.AirOperationsBranchDirector:
+ return IncidentCapabilities.ViewBoard | IncidentCapabilities.AssignResources | IncidentCapabilities.ManageObjectives | IncidentCapabilities.ManageAnnotations;
+
+ case IncidentRoleType.ShelterMassCareCoordinator:
+ case IncidentRoleType.DamageAssessmentLead:
+ return IncidentCapabilities.ViewBoard | IncidentCapabilities.ManageObjectives | IncidentCapabilities.ViewReports;
+
+ default:
+ return IncidentCapabilities.ViewBoard;
+ }
+ }
+ }
+
+ ///
+ /// Assigns a Resgrid user to a functional incident-command role for a specific incident (Call). Incident-scoped,
+ /// not a department-wide claim. Optionally scoped to a structure node for supervisors.
+ ///
+ public class IncidentRoleAssignment : IEntity
+ {
+ public string IncidentRoleAssignmentId { get; set; }
+
+ public string IncidentCommandId { get; set; }
+
+ public int DepartmentId { get; set; }
+
+ public int CallId { get; set; }
+
+ /// The assigned Resgrid user (must be a member of the department).
+ public string UserId { get; set; }
+
+ /// Maps to .
+ public int RoleType { get; set; }
+
+ /// Optional command structure node this role is scoped to (e.g. a Division/Group supervisor).
+ public string ScopeNodeId { get; set; }
+
+ public string AssignedByUserId { get; set; }
+
+ public DateTime AssignedOn { get; set; }
+
+ public DateTime? RemovedOn { get; set; }
+
+ [NotMapped]
+ public string TableName => "IncidentRoleAssignments";
+
+ [NotMapped]
+ public string IdName => "IncidentRoleAssignmentId";
+
+ [NotMapped]
+ public int IdType => 1;
+
+ [NotMapped]
+ [JsonIgnore]
+ public object IdValue
+ {
+ get { return IncidentRoleAssignmentId; }
+ set { IncidentRoleAssignmentId = (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
new file mode 100644
index 000000000..deeada03f
--- /dev/null
+++ b/Core/Resgrid.Model/IncidentCommand/IncidentTacticals.cs
@@ -0,0 +1,161 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations.Schema;
+using Newtonsoft.Json;
+
+namespace Resgrid.Model
+{
+ /// A tactical objective / benchmark for an incident (e.g. "Primary search complete").
+ public class TacticalObjective : IEntity
+ {
+ public string TacticalObjectiveId { get; set; }
+
+ public string IncidentCommandId { get; set; }
+
+ public int DepartmentId { get; set; }
+
+ public int CallId { get; set; }
+
+ public string Name { get; set; }
+
+ /// Maps to .
+ public int ObjectiveType { get; set; }
+
+ /// Maps to .
+ public int Status { get; set; }
+
+ public bool AutoPopulated { get; set; }
+
+ public string CompletedByUserId { get; set; }
+
+ public DateTime? CompletedOn { get; set; }
+
+ public int SortOrder { get; set; }
+
+ [NotMapped]
+ public string TableName => "TacticalObjectives";
+
+ [NotMapped]
+ public string IdName => "TacticalObjectiveId";
+
+ [NotMapped]
+ public int IdType => 1;
+
+ [NotMapped]
+ [JsonIgnore]
+ public object IdValue
+ {
+ get { return TacticalObjectiveId; }
+ set { TacticalObjectiveId = (string)value; }
+ }
+
+ [NotMapped]
+ public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" };
+ }
+
+ ///
+ /// A scene / benchmark / role timer for an incident. Personnel accountability (PAR) is handled by the
+ /// Checkin feature, not by these timers.
+ ///
+ public class IncidentTimer : IEntity
+ {
+ public string IncidentTimerId { get; set; }
+
+ public string IncidentCommandId { get; set; }
+
+ public int DepartmentId { get; set; }
+
+ public int CallId { get; set; }
+
+ /// Maps to .
+ public int TimerType { get; set; }
+
+ /// Maps to .
+ public int ScopeType { get; set; }
+
+ /// Identifier of the scoped object (node id, unit id, ...), null for incident scope.
+ public string ScopeId { get; set; }
+
+ public string Name { get; set; }
+
+ public int IntervalSeconds { get; set; }
+
+ public DateTime StartedOn { get; set; }
+
+ public DateTime? NextDueOn { get; set; }
+
+ /// Maps to .
+ public int Status { get; set; }
+
+ public DateTime? AcknowledgedOn { get; set; }
+
+ [NotMapped]
+ public string TableName => "IncidentTimers";
+
+ [NotMapped]
+ public string IdName => "IncidentTimerId";
+
+ [NotMapped]
+ public int IdType => 1;
+
+ [NotMapped]
+ [JsonIgnore]
+ public object IdValue
+ {
+ get { return IncidentTimerId; }
+ set { IncidentTimerId = (string)value; }
+ }
+
+ [NotMapped]
+ public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" };
+ }
+
+ /// A real-time map annotation (markup) on the tactical map, synced across devices.
+ public class IncidentMapAnnotation : IEntity
+ {
+ public string IncidentMapAnnotationId { get; set; }
+
+ public string IncidentCommandId { get; set; }
+
+ public int DepartmentId { get; set; }
+
+ public int CallId { get; set; }
+
+ /// Maps to .
+ public int AnnotationType { get; set; }
+
+ /// The annotation geometry as a GeoJSON feature.
+ public string GeoJson { get; set; }
+
+ /// Optional ICS standard symbology code.
+ public string IcsSymbolCode { get; set; }
+
+ public string Label { get; set; }
+
+ public string CreatedByUserId { get; set; }
+
+ public DateTime CreatedOn { get; set; }
+
+ public DateTime? DeletedOn { get; set; }
+
+ [NotMapped]
+ public string TableName => "IncidentMapAnnotations";
+
+ [NotMapped]
+ public string IdName => "IncidentMapAnnotationId";
+
+ [NotMapped]
+ public int IdType => 1;
+
+ [NotMapped]
+ [JsonIgnore]
+ public object IdValue
+ {
+ get { return IncidentMapAnnotationId; }
+ set { IncidentMapAnnotationId = (string)value; }
+ }
+
+ [NotMapped]
+ public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" };
+ }
+}
diff --git a/Core/Resgrid.Model/Reporting/ExportProfile.cs b/Core/Resgrid.Model/Reporting/ExportProfile.cs
index 759f9bec6..2f2b517fa 100644
--- a/Core/Resgrid.Model/Reporting/ExportProfile.cs
+++ b/Core/Resgrid.Model/Reporting/ExportProfile.cs
@@ -21,6 +21,11 @@ public enum ExportProfile
/// National EMS Information System (NEMSIS) field mapping (EMS).
[Description("NEMSIS")]
[Display(Name = "NEMSIS")]
- Nemsis = 2
+ Nemsis = 2,
+
+ /// ICS-209 incident status summary field mapping (incident command).
+ [Description("ICS-209")]
+ [Display(Name = "ICS-209")]
+ Ics209 = 3
}
}
diff --git a/Core/Resgrid.Model/Repositories/IIncidentAdHocResourceRepositories.cs b/Core/Resgrid.Model/Repositories/IIncidentAdHocResourceRepositories.cs
new file mode 100644
index 000000000..5992c7a71
--- /dev/null
+++ b/Core/Resgrid.Model/Repositories/IIncidentAdHocResourceRepositories.cs
@@ -0,0 +1,10 @@
+namespace Resgrid.Model.Repositories
+{
+ public interface IIncidentAdHocUnitRepository : IRepository
+ {
+ }
+
+ public interface IIncidentAdHocPersonnelRepository : IRepository
+ {
+ }
+}
diff --git a/Core/Resgrid.Model/Repositories/IIncidentCommandRepositories.cs b/Core/Resgrid.Model/Repositories/IIncidentCommandRepositories.cs
new file mode 100644
index 000000000..03d2707c1
--- /dev/null
+++ b/Core/Resgrid.Model/Repositories/IIncidentCommandRepositories.cs
@@ -0,0 +1,34 @@
+namespace Resgrid.Model.Repositories
+{
+ public interface IIncidentCommandRepository : IRepository
+ {
+ }
+
+ public interface ICommandStructureNodeRepository : IRepository
+ {
+ }
+
+ public interface IResourceAssignmentRepository : IRepository
+ {
+ }
+
+ public interface ITacticalObjectiveRepository : IRepository
+ {
+ }
+
+ public interface IIncidentTimerRepository : IRepository
+ {
+ }
+
+ public interface IIncidentMapAnnotationRepository : IRepository
+ {
+ }
+
+ public interface ICommandLogEntryRepository : IRepository
+ {
+ }
+
+ public interface ICommandTransferRepository : IRepository
+ {
+ }
+}
diff --git a/Core/Resgrid.Model/Repositories/IIncidentRoleAssignmentRepository.cs b/Core/Resgrid.Model/Repositories/IIncidentRoleAssignmentRepository.cs
new file mode 100644
index 000000000..8b15be550
--- /dev/null
+++ b/Core/Resgrid.Model/Repositories/IIncidentRoleAssignmentRepository.cs
@@ -0,0 +1,6 @@
+namespace Resgrid.Model.Repositories
+{
+ public interface IIncidentRoleAssignmentRepository : IRepository
+ {
+ }
+}
diff --git a/Core/Resgrid.Model/Services/ICommandsService.cs b/Core/Resgrid.Model/Services/ICommandsService.cs
index 60177f4b9..c15fc9982 100644
--- a/Core/Resgrid.Model/Services/ICommandsService.cs
+++ b/Core/Resgrid.Model/Services/ICommandsService.cs
@@ -19,5 +19,29 @@ public interface ICommandsService
/// The cancellation token that can be used by other objects or threads to receive notice of cancellation.
/// Task<CommandDefinition>.
Task Save(CommandDefinition command, CancellationToken cancellationToken = default(CancellationToken));
+
+ ///
+ /// Gets a single command definition by its identifier.
+ ///
+ /// The command definition identifier.
+ /// Task<CommandDefinition>.
+ Task GetCommandByIdAsync(int commandDefinitionId);
+
+ ///
+ /// Resolves the command definition (template) to use for a given call type, falling back to the
+ /// department's "Any Call Type" definition (CallTypeId == null) when no type-specific one exists.
+ ///
+ /// The department identifier.
+ /// The call type identifier, or null for the "Any Call Type" template.
+ /// Task<CommandDefinition>.
+ Task GetCommandForCallTypeAsync(int departmentId, int? callTypeId);
+
+ ///
+ /// Deletes the specified command definition.
+ ///
+ /// The command definition.
+ /// The cancellation token.
+ /// Task<System.Boolean>.
+ Task DeleteAsync(CommandDefinition command, CancellationToken cancellationToken = default(CancellationToken));
}
}
diff --git a/Core/Resgrid.Model/Services/ICoreEventService.cs b/Core/Resgrid.Model/Services/ICoreEventService.cs
index 4a504dc8a..943a2e669 100644
--- a/Core/Resgrid.Model/Services/ICoreEventService.cs
+++ b/Core/Resgrid.Model/Services/ICoreEventService.cs
@@ -8,5 +8,11 @@ namespace Resgrid.Model.Services
{
public interface ICoreEventService
{
+ ///
+ /// Publishes a real-time "incident command updated" notification for a call so connected IC clients
+ /// re-sync the board (Tablet Command-style Real Time Sync). Fans out via the CQRS/eventing pipeline
+ /// to the per-department SignalR group.
+ ///
+ Task IncidentCommandUpdatedAsync(int departmentId, int callId);
}
}
diff --git a/Core/Resgrid.Model/Services/IIncidentCommandService.cs b/Core/Resgrid.Model/Services/IIncidentCommandService.cs
new file mode 100644
index 000000000..eef68d477
--- /dev/null
+++ b/Core/Resgrid.Model/Services/IIncidentCommandService.cs
@@ -0,0 +1,69 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Services
+{
+ ///
+ /// Manages a live incident-command instance on a Call: establishing/closing/transferring command, editing the
+ /// command structure (lanes), assigning resources, objectives, timers, map annotations, and the action timeline.
+ /// Every mutation appends a .
+ ///
+ public interface IIncidentCommandService
+ {
+ // Command lifecycle
+ Task EstablishCommandAsync(int departmentId, int callId, string userId, int? commandDefinitionId, CancellationToken cancellationToken = default(CancellationToken));
+ Task GetActiveCommandForCallAsync(int departmentId, int callId);
+ Task GetCommandByIdAsync(string incidentCommandId);
+ Task GetCommandForCallAsync(int departmentId, int callId);
+ Task GetCommandBoardAsync(int departmentId, int callId);
+ Task> GetAccountabilityForCallAsync(int departmentId, int callId);
+
+ ///
+ /// Sweeps personnel accountability (PAR) for the call and raises CriticalParDetectedEvent once per
+ /// member each time they transition into the Critical (overdue) state. Idempotent via a timeline marker —
+ /// re-alerts only after a member checks in and lapses again. Returns the user ids flagged this pass (empty
+ /// when nothing changed). Safe to call from a read path, a manual endpoint, or a recurring worker.
+ ///
+ Task> EvaluateCriticalParAsync(int departmentId, int callId, CancellationToken cancellationToken = default(CancellationToken));
+
+ // Incident roles (§3.11)
+ Task AssignIncidentRoleAsync(IncidentRoleAssignment assignment, string userId, CancellationToken cancellationToken = default(CancellationToken));
+ Task RemoveIncidentRoleAsync(int departmentId, string incidentRoleAssignmentId, string userId, CancellationToken cancellationToken = default(CancellationToken));
+ 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));
+ 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));
+
+ // Structure (lanes)
+ Task SaveNodeAsync(CommandStructureNode node, string userId, CancellationToken cancellationToken = default(CancellationToken));
+ Task DeleteNodeAsync(int departmentId, string commandStructureNodeId, string userId, CancellationToken cancellationToken = default(CancellationToken));
+ Task> GetNodesForCallAsync(int departmentId, int callId);
+
+ // Resource assignments
+ Task AssignResourceAsync(ResourceAssignment assignment, string userId, CancellationToken cancellationToken = default(CancellationToken));
+ Task MoveResourceAsync(int departmentId, string resourceAssignmentId, string targetNodeId, string userId, CancellationToken cancellationToken = default(CancellationToken));
+ Task ReleaseResourceAsync(int departmentId, string resourceAssignmentId, string userId, CancellationToken cancellationToken = default(CancellationToken));
+ Task> GetAssignmentsForCallAsync(int departmentId, int callId);
+
+ // Objectives / benchmarks
+ Task SaveObjectiveAsync(TacticalObjective objective, string userId, CancellationToken cancellationToken = default(CancellationToken));
+ Task CompleteObjectiveAsync(int departmentId, string tacticalObjectiveId, string userId, CancellationToken cancellationToken = default(CancellationToken));
+ Task> GetObjectivesForCallAsync(int departmentId, int callId);
+
+ // Timers (scene/benchmark/role)
+ Task StartTimerAsync(IncidentTimer timer, string userId, CancellationToken cancellationToken = default(CancellationToken));
+ Task AcknowledgeTimerAsync(int departmentId, string incidentTimerId, string userId, CancellationToken cancellationToken = default(CancellationToken));
+ Task> GetActiveTimersForCallAsync(int departmentId, int callId);
+
+ // Map annotations
+ Task SaveAnnotationAsync(IncidentMapAnnotation annotation, string userId, CancellationToken cancellationToken = default(CancellationToken));
+ Task DeleteAnnotationAsync(int departmentId, string incidentMapAnnotationId, string userId, CancellationToken cancellationToken = default(CancellationToken));
+ Task> GetAnnotationsForCallAsync(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.Model/Services/IIncidentReportingService.cs b/Core/Resgrid.Model/Services/IIncidentReportingService.cs
new file mode 100644
index 000000000..fe5059c5e
--- /dev/null
+++ b/Core/Resgrid.Model/Services/IIncidentReportingService.cs
@@ -0,0 +1,15 @@
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Services
+{
+ ///
+ /// Per-incident reporting & analytics (§3.13): incident status summary (ICS-201/209), after-action bundle,
+ /// and timeline export. Built on top of the incident-command data.
+ ///
+ public interface IIncidentReportingService
+ {
+ Task GetIncidentSummaryAsync(int departmentId, int callId);
+ Task GetAfterActionReportAsync(int departmentId, int callId);
+ Task ExportTimelineCsvAsync(int departmentId, int callId);
+ }
+}
diff --git a/Core/Resgrid.Model/Services/IIncidentResourcesService.cs b/Core/Resgrid.Model/Services/IIncidentResourcesService.cs
new file mode 100644
index 000000000..2d3a6d2f0
--- /dev/null
+++ b/Core/Resgrid.Model/Services/IIncidentResourcesService.cs
@@ -0,0 +1,30 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Services
+{
+ ///
+ /// Manages incident-scoped ad-hoc resources (units and personnel created on the fly for non-Resgrid
+ /// resources), unit rosters, and forming a unit from on-scene personnel (§3.10).
+ ///
+ public interface IIncidentResourcesService
+ {
+ // Ad-hoc units
+ Task CreateAdHocUnitAsync(IncidentAdHocUnit unit, string userId, CancellationToken cancellationToken = default(CancellationToken));
+ Task GetAdHocUnitByIdAsync(string incidentAdHocUnitId);
+ Task> GetAdHocUnitsForCallAsync(int departmentId, int callId);
+ Task ReleaseAdHocUnitAsync(int departmentId, string incidentAdHocUnitId, string userId, CancellationToken cancellationToken = default(CancellationToken));
+
+ // Ad-hoc personnel
+ Task CreateAdHocPersonnelAsync(IncidentAdHocPersonnel personnel, string userId, CancellationToken cancellationToken = default(CancellationToken));
+ Task> GetAdHocPersonnelForCallAsync(int departmentId, int callId);
+ Task ReleaseAdHocPersonnelAsync(int departmentId, string incidentAdHocPersonnelId, string userId, CancellationToken cancellationToken = default(CancellationToken));
+
+ // Roster building
+ Task AssignPersonnelToUnitAsync(int departmentId, string incidentAdHocPersonnelId, int ridingResourceKind, string ridingResourceId, string userId, CancellationToken cancellationToken = default(CancellationToken));
+
+ /// Forms a new ad-hoc unit and attaches the given ad-hoc personnel to it as its roster.
+ Task FormUnitFromPersonnelAsync(IncidentAdHocUnit unit, List adHocPersonnelIds, string userId, CancellationToken cancellationToken = default(CancellationToken));
+ }
+}
diff --git a/Core/Resgrid.Model/Services/IIncidentVoiceService.cs b/Core/Resgrid.Model/Services/IIncidentVoiceService.cs
new file mode 100644
index 000000000..f7c11bcee
--- /dev/null
+++ b/Core/Resgrid.Model/Services/IIncidentVoiceService.cs
@@ -0,0 +1,26 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Services
+{
+ ///
+ /// On-demand PTT tactical voice channels scoped to a single incident (§3.4). Built on top of the existing
+ /// department Voice/PTT addon (); channels live for the duration of the incident
+ /// and are visible to the units/users assigned to it.
+ ///
+ public interface IIncidentVoiceService
+ {
+ /// Whether the department has the PTT voice addon enabled.
+ Task CanUseVoiceAsync(int departmentId);
+
+ /// Creates an on-demand tactical channel scoped to the given call (gated on the voice addon).
+ Task CreateIncidentChannelAsync(int departmentId, int callId, string name, string userId, CancellationToken cancellationToken = default(CancellationToken));
+
+ /// Gets the open on-demand tactical channels for a call.
+ Task> GetChannelsForCallAsync(int departmentId, int callId);
+
+ /// Closes (soft-close) all open on-demand tactical channels for a call.
+ Task CloseIncidentChannelsForCallAsync(int departmentId, int callId, string userId, CancellationToken cancellationToken = default(CancellationToken));
+ }
+}
diff --git a/Core/Resgrid.Model/Services/IMutualAidService.cs b/Core/Resgrid.Model/Services/IMutualAidService.cs
new file mode 100644
index 000000000..e77ae0ccd
--- /dev/null
+++ b/Core/Resgrid.Model/Services/IMutualAidService.cs
@@ -0,0 +1,18 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Services
+{
+ ///
+ /// Aggregates resources (units + personnel) that an incident commander can assign to lanes, unioning the
+ /// own department with linked (mutual-aid) departments per the DepartmentLink share flags (§3.9).
+ ///
+ public interface IMutualAidService
+ {
+ ///
+ /// Returns own-department resources plus those shared toward this department by accepted, enabled
+ /// mutual-aid links (gated by the linked department's share-units / share-personnel flags), color-coded.
+ ///
+ Task> GetAssignableResourcesForIncidentAsync(int departmentId);
+ }
+}
diff --git a/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs b/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs
index 8181f8702..8e5ec3d96 100644
--- a/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs
+++ b/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs
@@ -92,6 +92,26 @@ public static IReadOnlyList GetVariableCatalog(Workf
switch (eventType)
{
+ case WorkflowTriggerEventType.CommandEstablished:
+ case WorkflowTriggerEventType.CommandTransferred:
+ case WorkflowTriggerEventType.IncidentClosed:
+ case WorkflowTriggerEventType.ResourceAssigned:
+ case WorkflowTriggerEventType.ResourceReleased:
+ case WorkflowTriggerEventType.ObjectiveCompleted:
+ case WorkflowTriggerEventType.CriticalParDetected:
+ case WorkflowTriggerEventType.IncidentRoleAssigned:
+ case WorkflowTriggerEventType.AdHocResourceCreated:
+ case WorkflowTriggerEventType.IncidentChannelOpened:
+ list.AddRange(new[]
+ {
+ new TemplateVariableDescriptor("incident.command_id", "Incident command identifier", "string", false),
+ new TemplateVariableDescriptor("incident.call_id", "Call/incident identifier", "int", false),
+ new TemplateVariableDescriptor("incident.department_id", "Department identifier", "int", false),
+ new TemplateVariableDescriptor("incident.user_id", "User associated with the event (when applicable)", "string", false),
+ new TemplateVariableDescriptor("incident.name", "Name associated with the event (objective/resource/channel)", "string", false),
+ });
+ break;
+
case WorkflowTriggerEventType.CallAdded:
case WorkflowTriggerEventType.CallUpdated:
case WorkflowTriggerEventType.CallClosed:
diff --git a/Core/Resgrid.Model/WorkflowTriggerEventType.cs b/Core/Resgrid.Model/WorkflowTriggerEventType.cs
index f4f489d26..666abf3fb 100644
--- a/Core/Resgrid.Model/WorkflowTriggerEventType.cs
+++ b/Core/Resgrid.Model/WorkflowTriggerEventType.cs
@@ -29,7 +29,19 @@ public enum WorkflowTriggerEventType
FormSubmitted = 24,
PersonnelRoleChanged = 25,
GroupAdded = 26,
- GroupUpdated = 27
+ GroupUpdated = 27,
+
+ // Incident Command (§3.12)
+ CommandEstablished = 28,
+ ResourceAssigned = 29,
+ ResourceReleased = 30,
+ ObjectiveCompleted = 31,
+ CriticalParDetected = 32,
+ CommandTransferred = 33,
+ IncidentRoleAssigned = 34,
+ AdHocResourceCreated = 35,
+ IncidentChannelOpened = 36,
+ IncidentClosed = 37
}
}
diff --git a/Core/Resgrid.Services/CheckInTimerService.cs b/Core/Resgrid.Services/CheckInTimerService.cs
index df75fd5ef..96d33c0d4 100644
--- a/Core/Resgrid.Services/CheckInTimerService.cs
+++ b/Core/Resgrid.Services/CheckInTimerService.cs
@@ -17,6 +17,7 @@ public class CheckInTimerService : ICheckInTimerService
private readonly IActionLogsService _actionLogsService;
private readonly IUnitsService _unitsService;
private readonly ICallsService _callsService;
+ private readonly ICoreEventService _coreEventService;
public CheckInTimerService(
ICheckInTimerConfigRepository configRepository,
@@ -24,7 +25,8 @@ public CheckInTimerService(
ICheckInRecordRepository recordRepository,
IActionLogsService actionLogsService,
IUnitsService unitsService,
- ICallsService callsService)
+ ICallsService callsService,
+ ICoreEventService coreEventService)
{
_configRepository = configRepository;
_overrideRepository = overrideRepository;
@@ -32,6 +34,7 @@ public CheckInTimerService(
_actionLogsService = actionLogsService;
_unitsService = unitsService;
_callsService = callsService;
+ _coreEventService = coreEventService;
}
#region Configuration CRUD
@@ -318,7 +321,21 @@ public async Task> ResolveAllTimersForCallAsync(Call
public async Task PerformCheckInAsync(CheckInRecord record, CancellationToken cancellationToken = default)
{
record.Timestamp = DateTime.UtcNow;
- return await _recordRepository.SaveOrUpdateAsync(record, cancellationToken);
+ var saved = await _recordRepository.SaveOrUpdateAsync(record, cancellationToken);
+
+ // Real-time board refresh is best-effort: the check-in is already persisted, so a CQRS/Redis
+ // publish failure must not fail the check-in — that would 500 the caller and a retry would
+ // duplicate the record. Log and move on; the worker/board sweep reconciles the PAR view anyway.
+ try
+ {
+ await _coreEventService.IncidentCommandUpdatedAsync(record.DepartmentId, record.CallId);
+ }
+ catch (Exception ex)
+ {
+ Resgrid.Framework.Logging.LogException(ex);
+ }
+
+ return saved;
}
public async Task> GetCheckInsForCallAsync(int callId)
diff --git a/Core/Resgrid.Services/CommandsService.cs b/Core/Resgrid.Services/CommandsService.cs
index 940696cb3..fb9bf89b7 100644
--- a/Core/Resgrid.Services/CommandsService.cs
+++ b/Core/Resgrid.Services/CommandsService.cs
@@ -31,5 +31,35 @@ public async Task> GetAllCommandsForDepartmentAsync(int
{
return await _commandDefinitionRepository.SaveOrUpdateAsync(command, cancellationToken);
}
+
+ public async Task GetCommandByIdAsync(int commandDefinitionId)
+ {
+ return await _commandDefinitionRepository.GetByIdAsync(commandDefinitionId);
+ }
+
+ public async Task GetCommandForCallTypeAsync(int departmentId, int? callTypeId)
+ {
+ var items = await _commandDefinitionRepository.GetAllByDepartmentIdAsync(departmentId);
+
+ if (items == null || !items.Any())
+ return null;
+
+ var list = items.ToList();
+
+ if (callTypeId.HasValue)
+ {
+ var match = list.FirstOrDefault(x => x.CallTypeId == callTypeId.Value);
+ if (match != null)
+ return match;
+ }
+
+ // Fall back to the department's "Any Call Type" definition (CallTypeId == null)
+ return list.FirstOrDefault(x => !x.CallTypeId.HasValue);
+ }
+
+ public async Task DeleteAsync(CommandDefinition command, CancellationToken cancellationToken = default(CancellationToken))
+ {
+ return await _commandDefinitionRepository.DeleteAsync(command, cancellationToken);
+ }
}
}
diff --git a/Core/Resgrid.Services/CoreEventService.cs b/Core/Resgrid.Services/CoreEventService.cs
index 2bdb49cb5..30b76061e 100644
--- a/Core/Resgrid.Services/CoreEventService.cs
+++ b/Core/Resgrid.Services/CoreEventService.cs
@@ -1,4 +1,5 @@
using System;
+using System.Threading.Tasks;
using CommonServiceLocator;
using Resgrid.Model;
using Resgrid.Model.Events;
@@ -25,5 +26,17 @@ public CoreEventService(IEventAggregator eventAggregator, ICqrsProvider cqrsProv
var departmentSettingsService = ServiceLocator.Current.GetInstance();
var result = await departmentSettingsService.SaveOrUpdateSettingAsync(message.DepartmentId, DateTime.UtcNow.ToString("G"), DepartmentSettingTypes.UpdateTimestamp);
};
+
+ public async Task IncidentCommandUpdatedAsync(int departmentId, int callId)
+ {
+ var cqrsEvent = new CqrsEvent
+ {
+ Type = (int)CqrsEventTypes.IncidentCommandUpdated,
+ AggregateId = callId.ToString(),
+ Data = $"{{\"departmentId\":{departmentId},\"callId\":{callId}}}"
+ };
+
+ await _cqrsProvider.EnqueueCqrsEventAsync(cqrsEvent);
+ }
}
}
diff --git a/Core/Resgrid.Services/IncidentCommandService.cs b/Core/Resgrid.Services/IncidentCommandService.cs
new file mode 100644
index 000000000..01749caa1
--- /dev/null
+++ b/Core/Resgrid.Services/IncidentCommandService.cs
@@ -0,0 +1,790 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Resgrid.Model;
+using Resgrid.Model.Events;
+using Resgrid.Model.Providers;
+using Resgrid.Model.Repositories;
+using Resgrid.Model.Services;
+
+namespace Resgrid.Services
+{
+ ///
+ /// Live incident-command management. Persistence-backed; every mutation appends a CommandLogEntry to the
+ /// incident timeline. NOTE: queries currently filter department-scoped result sets by CallId in memory —
+ /// a per-CallId repository query is a follow-up optimization. SignalR/Workflow event publication is wired
+ /// in a later pass (§3.6 / §3.12).
+ ///
+ public class IncidentCommandService : IIncidentCommandService
+ {
+ private readonly IIncidentCommandRepository _incidentCommandRepository;
+ private readonly ICommandStructureNodeRepository _commandStructureNodeRepository;
+ private readonly IResourceAssignmentRepository _resourceAssignmentRepository;
+ private readonly ITacticalObjectiveRepository _tacticalObjectiveRepository;
+ private readonly IIncidentTimerRepository _incidentTimerRepository;
+ private readonly IIncidentMapAnnotationRepository _incidentMapAnnotationRepository;
+ private readonly ICommandLogEntryRepository _commandLogEntryRepository;
+ private readonly ICommandTransferRepository _commandTransferRepository;
+ private readonly ICommandsService _commandsService;
+ private readonly ICallsService _callsService;
+ private readonly ICheckInTimerService _checkInTimerService;
+ private readonly IIncidentVoiceService _incidentVoiceService;
+ private readonly IIncidentRoleAssignmentRepository _incidentRoleAssignmentRepository;
+ private readonly IEventAggregator _eventAggregator;
+ private readonly ICoreEventService _coreEventService;
+
+ public IncidentCommandService(
+ IIncidentCommandRepository incidentCommandRepository,
+ ICommandStructureNodeRepository commandStructureNodeRepository,
+ IResourceAssignmentRepository resourceAssignmentRepository,
+ ITacticalObjectiveRepository tacticalObjectiveRepository,
+ IIncidentTimerRepository incidentTimerRepository,
+ IIncidentMapAnnotationRepository incidentMapAnnotationRepository,
+ ICommandLogEntryRepository commandLogEntryRepository,
+ ICommandTransferRepository commandTransferRepository,
+ ICommandsService commandsService,
+ ICallsService callsService,
+ ICheckInTimerService checkInTimerService,
+ IIncidentVoiceService incidentVoiceService,
+ IIncidentRoleAssignmentRepository incidentRoleAssignmentRepository,
+ IEventAggregator eventAggregator,
+ ICoreEventService coreEventService)
+ {
+ _incidentCommandRepository = incidentCommandRepository;
+ _commandStructureNodeRepository = commandStructureNodeRepository;
+ _resourceAssignmentRepository = resourceAssignmentRepository;
+ _tacticalObjectiveRepository = tacticalObjectiveRepository;
+ _incidentTimerRepository = incidentTimerRepository;
+ _incidentMapAnnotationRepository = incidentMapAnnotationRepository;
+ _commandLogEntryRepository = commandLogEntryRepository;
+ _commandTransferRepository = commandTransferRepository;
+ _commandsService = commandsService;
+ _callsService = callsService;
+ _checkInTimerService = checkInTimerService;
+ _incidentVoiceService = incidentVoiceService;
+ _incidentRoleAssignmentRepository = incidentRoleAssignmentRepository;
+ _eventAggregator = eventAggregator;
+ _coreEventService = coreEventService;
+ }
+
+ #region Command lifecycle
+
+ public async Task EstablishCommandAsync(int departmentId, int callId, string userId, int? commandDefinitionId, CancellationToken cancellationToken = default(CancellationToken))
+ {
+ // The call must belong to the caller's department. CallId is an auto-increment integer (guessable),
+ // so this prevents establishing command on another department's call.
+ var call = await _callsService.GetCallByIdAsync(callId);
+ if (call == null || call.DepartmentId != departmentId)
+ return null;
+
+ // Idempotent: if command is already established and active for this call, return it.
+ var existing = await GetActiveCommandForCallAsync(departmentId, callId);
+ if (existing != null)
+ return existing;
+
+ var command = new IncidentCommand
+ {
+ IncidentCommandId = Guid.NewGuid().ToString(),
+ DepartmentId = departmentId,
+ CallId = callId,
+ SourceCommandDefinitionId = commandDefinitionId,
+ EstablishedByUserId = userId,
+ EstablishedOn = DateTime.UtcNow,
+ CurrentCommanderUserId = userId,
+ Status = (int)IncidentCommandStatus.Active
+ };
+
+ try
+ {
+ command = await _incidentCommandRepository.SaveOrUpdateAsync(command, cancellationToken);
+ }
+ catch (Exception)
+ {
+ // The active-command check above is check-then-insert and races under concurrency; the partial
+ // unique index (UX_IncidentCommands_Department_Call_Active) is the real guard. If we lost the race,
+ // adopt the winner — same idempotent result as the check, rather than surfacing a 500.
+ var winner = await GetActiveCommandForCallAsync(departmentId, callId);
+ if (winner != null)
+ return winner;
+ throw;
+ }
+
+ await WriteLogAsync(command.IncidentCommandId, departmentId, callId, CommandLogEntryType.CommandEstablished, "Command established", userId, cancellationToken);
+
+ // Enable personnel accountability (check-in timers) on the call when the department has a config.
+ await EnableAccountabilityIfConfiguredAsync(departmentId, callId, userId, command.IncidentCommandId, cancellationToken);
+
+ _eventAggregator.SendMessage(new CommandEstablishedEvent { DepartmentId = departmentId, CallId = callId, IncidentCommandId = command.IncidentCommandId, EstablishedByUserId = userId });
+
+ // Seed lanes from the command definition template, if one was supplied and its lanes were loaded.
+ if (commandDefinitionId.HasValue)
+ {
+ var definition = await _commandsService.GetCommandByIdAsync(commandDefinitionId.Value);
+
+ // The template must belong to the caller's department. CommandDefinitionId is an auto-increment
+ // integer (guessable), so this prevents seeding from / disclosing another department's template.
+ if (definition?.Assignments != null && definition.DepartmentId == departmentId)
+ {
+ foreach (var role in definition.Assignments.OrderBy(r => r.SortOrder))
+ {
+ var node = new CommandStructureNode
+ {
+ CommandStructureNodeId = Guid.NewGuid().ToString(),
+ IncidentCommandId = command.IncidentCommandId,
+ DepartmentId = departmentId,
+ CallId = callId,
+ NodeType = role.LaneType,
+ Name = role.Name,
+ SortOrder = role.SortOrder,
+ SourceRoleId = role.CommandDefinitionRoleId
+ };
+
+ await _commandStructureNodeRepository.SaveOrUpdateAsync(node, cancellationToken);
+ }
+ }
+ }
+
+ return command;
+ }
+
+ public async Task GetActiveCommandForCallAsync(int departmentId, int callId)
+ {
+ var items = await _incidentCommandRepository.GetAllByDepartmentIdAsync(departmentId);
+ return items?.FirstOrDefault(x => x.CallId == callId && x.Status == (int)IncidentCommandStatus.Active);
+ }
+
+ public async Task GetCommandByIdAsync(string incidentCommandId)
+ {
+ return await _incidentCommandRepository.GetByIdAsync(incidentCommandId);
+ }
+
+ public async Task GetCommandForCallAsync(int departmentId, int callId)
+ {
+ var items = await _incidentCommandRepository.GetAllByDepartmentIdAsync(departmentId);
+ return items?.Where(x => x.CallId == callId).OrderByDescending(x => x.EstablishedOn).FirstOrDefault();
+ }
+
+ public async Task> GetAccountabilityForCallAsync(int departmentId, int callId)
+ {
+ var call = await _callsService.GetCallByIdAsync(callId);
+ if (call == null || call.DepartmentId != departmentId || !call.CheckInTimersEnabled)
+ return new List();
+
+ var statuses = await _checkInTimerService.GetCallPersonnelCheckInStatusesAsync(call);
+ return statuses ?? new List();
+ }
+
+ public async Task> EvaluateCriticalParAsync(int departmentId, int callId, CancellationToken cancellationToken = default(CancellationToken))
+ {
+ var newlyCritical = new List();
+
+ // The call must belong to the caller's department and have accountability enabled.
+ var call = await _callsService.GetCallByIdAsync(callId);
+ if (call == null || call.DepartmentId != departmentId || !call.CheckInTimersEnabled)
+ return newlyCritical;
+
+ // Without an active command there is nothing to append the alert to (and no incident to alert on).
+ var command = await GetActiveCommandForCallAsync(departmentId, callId);
+ if (command == null)
+ return newlyCritical;
+
+ var statuses = await _checkInTimerService.GetCallPersonnelCheckInStatusesAsync(call);
+ var critical = statuses?
+ .Where(s => !string.IsNullOrWhiteSpace(s.UserId) && string.Equals(s.Status, "Critical", StringComparison.OrdinalIgnoreCase))
+ .ToList() ?? new List();
+ if (!critical.Any())
+ return newlyCritical;
+
+ // Timeline-based dedup: most-recent ParCritical marker per subject user on this call.
+ var timeline = await GetTimelineForCallAsync(departmentId, callId);
+ var lastMarkerByUser = timeline
+ .Where(e => e.EntryType == (int)CommandLogEntryType.ParCritical && !string.IsNullOrWhiteSpace(e.UserId))
+ .GroupBy(e => e.UserId)
+ .ToDictionary(g => g.Key, g => g.Max(e => e.OccurredOn));
+
+ foreach (var status in critical)
+ {
+ // A "Critical episode" begins at the member's last check-in (or when command was established if
+ // they never checked in). A marker at/after that baseline means we already alerted this episode;
+ // once they check in again the baseline moves past the marker and the next lapse re-alerts.
+ var baseline = status.LastCheckIn ?? command.EstablishedOn;
+ if (lastMarkerByUser.TryGetValue(status.UserId, out var lastMarker) && lastMarker >= baseline)
+ continue;
+
+ var overdueBy = Math.Abs(Math.Round(status.MinutesRemaining, 1));
+ var who = string.IsNullOrWhiteSpace(status.FullName) ? 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.
+ await WriteLogAsync(command.IncidentCommandId, departmentId, callId, CommandLogEntryType.ParCritical,
+ $"PAR critical: {who} overdue for check-in by {overdueBy} min", status.UserId, cancellationToken);
+
+ _eventAggregator.SendMessage(new CriticalParDetectedEvent
+ {
+ DepartmentId = departmentId,
+ CallId = callId,
+ UserId = status.UserId
+ });
+
+ newlyCritical.Add(status.UserId);
+ }
+
+ return newlyCritical;
+ }
+
+ private async Task EnableAccountabilityIfConfiguredAsync(int departmentId, int callId, string userId, string incidentCommandId, CancellationToken cancellationToken)
+ {
+ var configs = await _checkInTimerService.GetTimerConfigsForDepartmentAsync(departmentId);
+ if (configs == null || !configs.Any(c => c.IsEnabled))
+ return;
+
+ var call = await _callsService.GetCallByIdAsync(callId);
+ if (call == null || call.DepartmentId != departmentId || call.CheckInTimersEnabled)
+ return;
+
+ call.CheckInTimersEnabled = true;
+ await _callsService.SaveCallAsync(call, cancellationToken);
+
+ await WriteLogAsync(incidentCommandId, departmentId, callId, CommandLogEntryType.Note, "Personnel accountability check-in timers enabled", userId, cancellationToken);
+ }
+
+ public async Task AssignIncidentRoleAsync(IncidentRoleAssignment assignment, string userId, CancellationToken cancellationToken = default(CancellationToken))
+ {
+ // The parent incident command must belong to the caller's department; stamp the authoritative
+ // CallId from it so this row can't be filed under a different call than its parent command.
+ var command = await GetOwnedCommandAsync(assignment.IncidentCommandId, assignment.DepartmentId);
+ if (command == null)
+ return null;
+ assignment.CallId = command.CallId;
+
+ if (string.IsNullOrWhiteSpace(assignment.IncidentRoleAssignmentId))
+ {
+ assignment.IncidentRoleAssignmentId = Guid.NewGuid().ToString();
+ }
+ else
+ {
+ // On update, the existing row must belong to the caller's department.
+ var existing = await _incidentRoleAssignmentRepository.GetByIdAsync(assignment.IncidentRoleAssignmentId);
+ if (existing == null || existing.DepartmentId != assignment.DepartmentId)
+ return null;
+ }
+
+ assignment.AssignedByUserId = userId;
+ if (assignment.AssignedOn == default(DateTime))
+ assignment.AssignedOn = DateTime.UtcNow;
+
+ assignment = await _incidentRoleAssignmentRepository.SaveOrUpdateAsync(assignment, cancellationToken);
+
+ await WriteLogAsync(assignment.IncidentCommandId, assignment.DepartmentId, assignment.CallId, CommandLogEntryType.RoleAssigned, $"Role {(IncidentRoleType)assignment.RoleType} assigned", userId, cancellationToken);
+
+ _eventAggregator.SendMessage(new IncidentRoleAssignedEvent { DepartmentId = assignment.DepartmentId, CallId = assignment.CallId, UserId = assignment.UserId, RoleType = assignment.RoleType });
+ return assignment;
+ }
+
+ 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)
+ return false;
+
+ assignment.RemovedOn = DateTime.UtcNow;
+ await _incidentRoleAssignmentRepository.SaveOrUpdateAsync(assignment, cancellationToken);
+
+ await WriteLogAsync(assignment.IncidentCommandId, assignment.DepartmentId, assignment.CallId, CommandLogEntryType.RoleRemoved, $"Role {(IncidentRoleType)assignment.RoleType} removed", userId, cancellationToken);
+ return true;
+ }
+
+ public async Task> GetIncidentRolesAsync(int departmentId, int callId)
+ {
+ var items = await _incidentRoleAssignmentRepository.GetAllByDepartmentIdAsync(departmentId);
+ if (items == null)
+ return new List();
+
+ return items.Where(x => x.CallId == callId && x.RemovedOn == null).ToList();
+ }
+
+ public async Task GetCapabilitiesForUserAsync(int departmentId, int callId, string userId)
+ {
+ var caps = IncidentCapabilities.None;
+
+ var command = await GetActiveCommandForCallAsync(departmentId, callId);
+ if (command != null && (string.Equals(userId, command.CurrentCommanderUserId) || string.Equals(userId, command.EstablishedByUserId)))
+ caps = IncidentCapabilities.All;
+
+ var roles = await GetIncidentRolesAsync(departmentId, callId);
+ foreach (var role in roles.Where(r => string.Equals(r.UserId, userId)))
+ caps |= IncidentRoleCapabilityMap.GetCapabilities((IncidentRoleType)role.RoleType);
+
+ return caps;
+ }
+
+ public async Task GetCommandBoardAsync(int departmentId, int callId)
+ {
+ var command = await GetActiveCommandForCallAsync(departmentId, callId);
+ if (command == null)
+ return null;
+
+ // The board is the IC app's primary polled read, so piggyback the PAR sweep here to keep
+ // accountability alerts flowing without a worker. Never let a sweep failure break the read.
+ try
+ {
+ await EvaluateCriticalParAsync(departmentId, callId);
+ }
+ catch (Exception ex)
+ {
+ Resgrid.Framework.Logging.LogException(ex);
+ }
+
+ var board = new IncidentCommandBoard
+ {
+ Command = command,
+ Nodes = await GetNodesForCallAsync(departmentId, callId),
+ Assignments = (await GetAssignmentsForCallAsync(departmentId, callId)).Where(a => a.ReleasedOn == null).ToList(),
+ Objectives = await GetObjectivesForCallAsync(departmentId, callId),
+ Timers = await GetActiveTimersForCallAsync(departmentId, callId),
+ Annotations = await GetAnnotationsForCallAsync(departmentId, callId),
+ Accountability = await GetAccountabilityForCallAsync(departmentId, callId),
+ Roles = await GetIncidentRolesAsync(departmentId, callId)
+ };
+
+ return board;
+ }
+
+ public async Task CloseCommandAsync(int departmentId, string incidentCommandId, string userId, CancellationToken cancellationToken = default(CancellationToken))
+ {
+ var command = await _incidentCommandRepository.GetByIdAsync(incidentCommandId);
+ if (command == null || command.DepartmentId != departmentId)
+ return null;
+
+ command.Status = (int)IncidentCommandStatus.Closed;
+ command.ClosedOn = DateTime.UtcNow;
+ command = await _incidentCommandRepository.SaveOrUpdateAsync(command, cancellationToken);
+
+ await WriteLogAsync(command.IncidentCommandId, command.DepartmentId, command.CallId, CommandLogEntryType.CommandClosed, "Command closed", userId, cancellationToken);
+
+ // Auto-close any on-demand incident voice channels for this call.
+ await _incidentVoiceService.CloseIncidentChannelsForCallAsync(command.DepartmentId, command.CallId, userId, cancellationToken);
+
+ _eventAggregator.SendMessage(new IncidentClosedEvent { DepartmentId = command.DepartmentId, CallId = command.CallId, IncidentCommandId = command.IncidentCommandId });
+ return command;
+ }
+
+ 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)
+ return null;
+
+ command.CurrentCommanderUserId = toUserId;
+ await _incidentCommandRepository.SaveOrUpdateAsync(command, cancellationToken);
+
+ var transfer = new CommandTransfer
+ {
+ CommandTransferId = Guid.NewGuid().ToString(),
+ IncidentCommandId = incidentCommandId,
+ DepartmentId = command.DepartmentId,
+ CallId = command.CallId,
+ FromUserId = fromUserId,
+ ToUserId = toUserId,
+ TransferredOn = DateTime.UtcNow,
+ Notes = notes
+ };
+ transfer = await _commandTransferRepository.SaveOrUpdateAsync(transfer, cancellationToken);
+
+ await WriteLogAsync(command.IncidentCommandId, command.DepartmentId, command.CallId, CommandLogEntryType.CommandTransferred, "Command transferred", fromUserId, cancellationToken);
+
+ _eventAggregator.SendMessage(new CommandTransferredEvent { DepartmentId = command.DepartmentId, CallId = command.CallId, IncidentCommandId = incidentCommandId, FromUserId = fromUserId, ToUserId = toUserId });
+ return transfer;
+ }
+
+ 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)
+ return null;
+
+ command.IncidentActionPlan = actionPlan;
+ command = await _incidentCommandRepository.SaveOrUpdateAsync(command, cancellationToken);
+
+ await WriteLogAsync(command.IncidentCommandId, command.DepartmentId, command.CallId, CommandLogEntryType.Note, "Incident action plan updated", userId, cancellationToken);
+ return command;
+ }
+
+ #endregion Command lifecycle
+
+ #region Structure (lanes)
+
+ public async Task SaveNodeAsync(CommandStructureNode node, string userId, CancellationToken cancellationToken = default(CancellationToken))
+ {
+ // The parent incident command must belong to the caller's department (node.DepartmentId is set
+ // from the authenticated claim by the controller); stamp the authoritative CallId from it so this
+ // row can't be filed under a different call than its parent command.
+ var command = await GetOwnedCommandAsync(node.IncidentCommandId, node.DepartmentId);
+ if (command == null)
+ return null;
+ node.CallId = command.CallId;
+
+ var isNew = string.IsNullOrWhiteSpace(node.CommandStructureNodeId);
+ if (isNew)
+ {
+ node.CommandStructureNodeId = Guid.NewGuid().ToString();
+ }
+ else
+ {
+ // On update, the existing row must belong to the caller's department (no foreign-row takeover).
+ var existing = await _commandStructureNodeRepository.GetByIdAsync(node.CommandStructureNodeId);
+ if (existing == null || existing.DepartmentId != node.DepartmentId)
+ return null;
+ }
+
+ node = await _commandStructureNodeRepository.SaveOrUpdateAsync(node, cancellationToken);
+
+ await WriteLogAsync(node.IncidentCommandId, node.DepartmentId, node.CallId,
+ isNew ? CommandLogEntryType.NodeAdded : CommandLogEntryType.NodeUpdated,
+ $"Lane '{node.Name}' {(isNew ? "added" : "updated")}", userId, cancellationToken);
+ return node;
+ }
+
+ 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)
+ return false;
+
+ var result = await _commandStructureNodeRepository.DeleteAsync(node, cancellationToken);
+
+ await WriteLogAsync(node.IncidentCommandId, node.DepartmentId, node.CallId, CommandLogEntryType.NodeRemoved, $"Lane '{node.Name}' removed", userId, cancellationToken);
+ return result;
+ }
+
+ public async Task> GetNodesForCallAsync(int departmentId, int callId)
+ {
+ var items = await _commandStructureNodeRepository.GetAllByDepartmentIdAsync(departmentId);
+ if (items == null)
+ return new List();
+
+ return items.Where(x => x.CallId == callId).OrderBy(x => x.SortOrder).ToList();
+ }
+
+ #endregion Structure (lanes)
+
+ #region Resource assignments
+
+ public async Task AssignResourceAsync(ResourceAssignment assignment, string userId, CancellationToken cancellationToken = default(CancellationToken))
+ {
+ // The parent incident command must belong to the caller's department; stamp the authoritative
+ // CallId from it so this row can't be filed under a different call than its parent command.
+ var command = await GetOwnedCommandAsync(assignment.IncidentCommandId, assignment.DepartmentId);
+ if (command == null)
+ return null;
+ assignment.CallId = command.CallId;
+
+ if (string.IsNullOrWhiteSpace(assignment.ResourceAssignmentId))
+ {
+ assignment.ResourceAssignmentId = Guid.NewGuid().ToString();
+ }
+ else
+ {
+ // On update, the existing row must belong to the caller's department.
+ var existing = await _resourceAssignmentRepository.GetByIdAsync(assignment.ResourceAssignmentId);
+ if (existing == null || existing.DepartmentId != assignment.DepartmentId)
+ return null;
+ }
+
+ if (assignment.AssignedOn == default(DateTime))
+ assignment.AssignedOn = DateTime.UtcNow;
+
+ assignment.AssignedByUserId = userId;
+ assignment = await _resourceAssignmentRepository.SaveOrUpdateAsync(assignment, cancellationToken);
+
+ await WriteLogAsync(assignment.IncidentCommandId, assignment.DepartmentId, assignment.CallId, CommandLogEntryType.ResourceAssigned, "Resource assigned", userId, cancellationToken);
+
+ _eventAggregator.SendMessage(new IncidentResourceAssignedEvent { DepartmentId = assignment.DepartmentId, CallId = assignment.CallId, IncidentCommandId = assignment.IncidentCommandId, ResourceKind = assignment.ResourceKind, ResourceId = assignment.ResourceId });
+ return assignment;
+ }
+
+ 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)
+ return null;
+
+ // The target lane must exist and live on the SAME incident (department + call) as the assignment;
+ // otherwise the move would point the resource at a non-existent/foreign lane and corrupt the board.
+ var targetNode = await _commandStructureNodeRepository.GetByIdAsync(targetNodeId);
+ if (targetNode == null || targetNode.DepartmentId != departmentId || targetNode.CallId != assignment.CallId)
+ return null;
+
+ assignment.CommandStructureNodeId = targetNodeId;
+ assignment = await _resourceAssignmentRepository.SaveOrUpdateAsync(assignment, cancellationToken);
+
+ await WriteLogAsync(assignment.IncidentCommandId, assignment.DepartmentId, assignment.CallId, CommandLogEntryType.ResourceMoved, "Resource moved", userId, cancellationToken);
+ return assignment;
+ }
+
+ 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)
+ return false;
+
+ assignment.ReleasedOn = DateTime.UtcNow;
+ await _resourceAssignmentRepository.SaveOrUpdateAsync(assignment, cancellationToken);
+
+ await WriteLogAsync(assignment.IncidentCommandId, assignment.DepartmentId, assignment.CallId, CommandLogEntryType.ResourceReleased, "Resource released", userId, cancellationToken);
+
+ _eventAggregator.SendMessage(new IncidentResourceReleasedEvent { DepartmentId = assignment.DepartmentId, CallId = assignment.CallId, ResourceAssignmentId = assignment.ResourceAssignmentId });
+ return true;
+ }
+
+ public async Task> GetAssignmentsForCallAsync(int departmentId, int callId)
+ {
+ var items = await _resourceAssignmentRepository.GetAllByDepartmentIdAsync(departmentId);
+ if (items == null)
+ return new List();
+
+ return items.Where(x => x.CallId == callId).ToList();
+ }
+
+ #endregion Resource assignments
+
+ #region Objectives
+
+ public async Task SaveObjectiveAsync(TacticalObjective objective, string userId, CancellationToken cancellationToken = default(CancellationToken))
+ {
+ // The parent incident command must belong to the caller's department; stamp the authoritative
+ // CallId from it so this row can't be filed under a different call than its parent command.
+ var command = await GetOwnedCommandAsync(objective.IncidentCommandId, objective.DepartmentId);
+ if (command == null)
+ return null;
+ objective.CallId = command.CallId;
+
+ var isNew = string.IsNullOrWhiteSpace(objective.TacticalObjectiveId);
+ if (isNew)
+ {
+ objective.TacticalObjectiveId = Guid.NewGuid().ToString();
+ }
+ else
+ {
+ // On update, the existing row must belong to the caller's department.
+ var existing = await _tacticalObjectiveRepository.GetByIdAsync(objective.TacticalObjectiveId);
+ if (existing == null || existing.DepartmentId != objective.DepartmentId)
+ return null;
+ }
+
+ objective = await _tacticalObjectiveRepository.SaveOrUpdateAsync(objective, cancellationToken);
+
+ if (isNew)
+ await WriteLogAsync(objective.IncidentCommandId, objective.DepartmentId, objective.CallId, CommandLogEntryType.ObjectiveAdded, $"Objective '{objective.Name}' added", userId, cancellationToken);
+
+ return objective;
+ }
+
+ public async Task CompleteObjectiveAsync(int departmentId, string tacticalObjectiveId, string userId, CancellationToken cancellationToken = default(CancellationToken))
+ {
+ var objective = await _tacticalObjectiveRepository.GetByIdAsync(tacticalObjectiveId);
+ if (objective == null || objective.DepartmentId != departmentId)
+ return null;
+
+ objective.Status = (int)TacticalObjectiveStatus.Complete;
+ objective.CompletedByUserId = userId;
+ objective.CompletedOn = DateTime.UtcNow;
+ objective = await _tacticalObjectiveRepository.SaveOrUpdateAsync(objective, cancellationToken);
+
+ await WriteLogAsync(objective.IncidentCommandId, objective.DepartmentId, objective.CallId, CommandLogEntryType.ObjectiveCompleted, $"Objective '{objective.Name}' completed", userId, cancellationToken);
+
+ _eventAggregator.SendMessage(new IncidentObjectiveCompletedEvent { DepartmentId = objective.DepartmentId, CallId = objective.CallId, IncidentCommandId = objective.IncidentCommandId, TacticalObjectiveId = objective.TacticalObjectiveId, Name = objective.Name });
+ return objective;
+ }
+
+ public async Task> GetObjectivesForCallAsync(int departmentId, int callId)
+ {
+ var items = await _tacticalObjectiveRepository.GetAllByDepartmentIdAsync(departmentId);
+ if (items == null)
+ return new List();
+
+ return items.Where(x => x.CallId == callId).OrderBy(x => x.SortOrder).ToList();
+ }
+
+ #endregion Objectives
+
+ #region Timers
+
+ public async Task StartTimerAsync(IncidentTimer timer, string userId, CancellationToken cancellationToken = default(CancellationToken))
+ {
+ // The parent incident command must belong to the caller's department; stamp the authoritative
+ // CallId from it so this row can't be filed under a different call than its parent command.
+ var command = await GetOwnedCommandAsync(timer.IncidentCommandId, timer.DepartmentId);
+ if (command == null)
+ return null;
+ timer.CallId = command.CallId;
+
+ if (string.IsNullOrWhiteSpace(timer.IncidentTimerId))
+ {
+ timer.IncidentTimerId = Guid.NewGuid().ToString();
+ }
+ else
+ {
+ // On update, the existing row must belong to the caller's department.
+ var existing = await _incidentTimerRepository.GetByIdAsync(timer.IncidentTimerId);
+ if (existing == null || existing.DepartmentId != timer.DepartmentId)
+ return null;
+ }
+
+ timer.StartedOn = DateTime.UtcNow;
+ timer.Status = (int)IncidentTimerStatus.Running;
+ if (timer.IntervalSeconds > 0)
+ timer.NextDueOn = timer.StartedOn.AddSeconds(timer.IntervalSeconds);
+
+ timer = await _incidentTimerRepository.SaveOrUpdateAsync(timer, cancellationToken);
+
+ await WriteLogAsync(timer.IncidentCommandId, timer.DepartmentId, timer.CallId, CommandLogEntryType.TimerStarted, $"Timer '{timer.Name}' started", userId, cancellationToken);
+ return timer;
+ }
+
+ 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)
+ return null;
+
+ timer.AcknowledgedOn = DateTime.UtcNow;
+ timer.Status = (int)IncidentTimerStatus.Acknowledged;
+ if (timer.IntervalSeconds > 0)
+ timer.NextDueOn = timer.AcknowledgedOn.Value.AddSeconds(timer.IntervalSeconds);
+
+ timer = await _incidentTimerRepository.SaveOrUpdateAsync(timer, cancellationToken);
+
+ await WriteLogAsync(timer.IncidentCommandId, timer.DepartmentId, timer.CallId, CommandLogEntryType.TimerAcknowledged, $"Timer '{timer.Name}' acknowledged", userId, cancellationToken);
+ return timer;
+ }
+
+ public async Task> GetActiveTimersForCallAsync(int departmentId, int callId)
+ {
+ var items = await _incidentTimerRepository.GetAllByDepartmentIdAsync(departmentId);
+ if (items == null)
+ return new List();
+
+ return items.Where(x => x.CallId == callId && x.Status != (int)IncidentTimerStatus.Stopped).ToList();
+ }
+
+ #endregion Timers
+
+ #region Map annotations
+
+ public async Task SaveAnnotationAsync(IncidentMapAnnotation annotation, string userId, CancellationToken cancellationToken = default(CancellationToken))
+ {
+ // The parent incident command must belong to the caller's department; stamp the authoritative
+ // CallId from it so this row can't be filed under a different call than its parent command.
+ var command = await GetOwnedCommandAsync(annotation.IncidentCommandId, annotation.DepartmentId);
+ if (command == null)
+ return null;
+ annotation.CallId = command.CallId;
+
+ var isNew = string.IsNullOrWhiteSpace(annotation.IncidentMapAnnotationId);
+ if (isNew)
+ {
+ annotation.IncidentMapAnnotationId = Guid.NewGuid().ToString();
+ annotation.CreatedOn = DateTime.UtcNow;
+ annotation.CreatedByUserId = userId;
+ }
+ else
+ {
+ // On update, the existing row must belong to the caller's department.
+ var existing = await _incidentMapAnnotationRepository.GetByIdAsync(annotation.IncidentMapAnnotationId);
+ if (existing == null || existing.DepartmentId != annotation.DepartmentId)
+ return null;
+ }
+
+ annotation = await _incidentMapAnnotationRepository.SaveOrUpdateAsync(annotation, cancellationToken);
+
+ if (isNew)
+ await WriteLogAsync(annotation.IncidentCommandId, annotation.DepartmentId, annotation.CallId, CommandLogEntryType.AnnotationAdded, "Map annotation added", userId, cancellationToken);
+
+ return annotation;
+ }
+
+ 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)
+ return false;
+
+ annotation.DeletedOn = DateTime.UtcNow;
+ await _incidentMapAnnotationRepository.SaveOrUpdateAsync(annotation, cancellationToken);
+
+ await WriteLogAsync(annotation.IncidentCommandId, annotation.DepartmentId, annotation.CallId, CommandLogEntryType.AnnotationRemoved, "Map annotation removed", userId, cancellationToken);
+ return true;
+ }
+
+ public async Task> GetAnnotationsForCallAsync(int departmentId, int callId)
+ {
+ var items = await _incidentMapAnnotationRepository.GetAllByDepartmentIdAsync(departmentId);
+ if (items == null)
+ return new List();
+
+ return items.Where(x => x.CallId == callId && x.DeletedOn == null).ToList();
+ }
+
+ #endregion Map annotations
+
+ #region Timeline
+
+ public async Task> GetTimelineForCallAsync(int departmentId, int callId)
+ {
+ var items = await _commandLogEntryRepository.GetAllByDepartmentIdAsync(departmentId);
+ if (items == null)
+ return new List();
+
+ return items.Where(x => x.CallId == callId).OrderBy(x => x.OccurredOn).ToList();
+ }
+
+ public async Task AddLogEntryAsync(string incidentCommandId, int departmentId, int callId, CommandLogEntryType type, string description, string userId, CancellationToken cancellationToken = default(CancellationToken))
+ {
+ return await WriteLogAsync(incidentCommandId, departmentId, callId, type, description, userId, cancellationToken);
+ }
+
+ #endregion Timeline
+
+ #region Private helpers
+
+ ///
+ /// 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.
+ ///
+ private async Task GetOwnedCommandAsync(string incidentCommandId, int departmentId)
+ {
+ if (string.IsNullOrWhiteSpace(incidentCommandId))
+ return null;
+
+ var command = await _incidentCommandRepository.GetByIdAsync(incidentCommandId);
+ return command != null && command.DepartmentId == departmentId ? command : null;
+ }
+
+ private async Task WriteLogAsync(string incidentCommandId, int departmentId, int callId, CommandLogEntryType type, string description, string userId, CancellationToken cancellationToken)
+ {
+ var entry = new CommandLogEntry
+ {
+ CommandLogEntryId = Guid.NewGuid().ToString(),
+ IncidentCommandId = incidentCommandId,
+ DepartmentId = departmentId,
+ CallId = callId,
+ EntryType = (int)type,
+ Description = description,
+ UserId = userId,
+ OccurredOn = DateTime.UtcNow
+ };
+
+ var saved = await _commandLogEntryRepository.SaveOrUpdateAsync(entry, cancellationToken);
+
+ // Real-time: every command mutation flows through here, so push one board-changed signal.
+ await _coreEventService.IncidentCommandUpdatedAsync(departmentId, callId);
+ return saved;
+ }
+
+ #endregion Private helpers
+ }
+}
diff --git a/Core/Resgrid.Services/IncidentReportingService.cs b/Core/Resgrid.Services/IncidentReportingService.cs
new file mode 100644
index 000000000..b74e6beb7
--- /dev/null
+++ b/Core/Resgrid.Services/IncidentReportingService.cs
@@ -0,0 +1,108 @@
+using System;
+using System.Globalization;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using Resgrid.Model;
+using Resgrid.Model.Services;
+
+namespace Resgrid.Services
+{
+ ///
+ /// Per-incident reporting & analytics, aggregated from the incident-command data. Read-only; depends only on
+ /// IIncidentCommandService (no cycle).
+ ///
+ public class IncidentReportingService : IIncidentReportingService
+ {
+ private readonly IIncidentCommandService _incidentCommandService;
+
+ public IncidentReportingService(IIncidentCommandService incidentCommandService)
+ {
+ _incidentCommandService = incidentCommandService;
+ }
+
+ public async Task GetIncidentSummaryAsync(int departmentId, int callId)
+ {
+ var command = await _incidentCommandService.GetCommandForCallAsync(departmentId, callId);
+ if (command == null)
+ return null;
+
+ var nodes = await _incidentCommandService.GetNodesForCallAsync(departmentId, callId);
+ var assignments = await _incidentCommandService.GetAssignmentsForCallAsync(departmentId, callId);
+ var objectives = await _incidentCommandService.GetObjectivesForCallAsync(departmentId, callId);
+ var timeline = await _incidentCommandService.GetTimelineForCallAsync(departmentId, callId);
+ var roles = await _incidentCommandService.GetIncidentRolesAsync(departmentId, callId);
+ var par = await _incidentCommandService.GetAccountabilityForCallAsync(departmentId, callId);
+
+ var end = command.ClosedOn ?? DateTime.UtcNow;
+
+ return new IncidentReportSummary
+ {
+ CallId = callId,
+ IncidentCommandId = command.IncidentCommandId,
+ EstablishedOn = command.EstablishedOn,
+ ClosedOn = command.ClosedOn,
+ DurationMinutes = Math.Round((end - command.EstablishedOn).TotalMinutes, 1),
+ CurrentCommanderUserId = command.CurrentCommanderUserId,
+ LaneCount = nodes.Count,
+ ActiveAssignmentCount = assignments.Count(a => a.ReleasedOn == null),
+ ObjectiveCount = objectives.Count,
+ CompletedObjectiveCount = objectives.Count(o => o.Status == (int)TacticalObjectiveStatus.Complete),
+ TimelineEntryCount = timeline.Count,
+ RoleCount = roles.Count,
+ AccountabilityGreen = par.Count(p => string.Equals(p.Status, "Green", StringComparison.OrdinalIgnoreCase)),
+ AccountabilityWarning = par.Count(p => string.Equals(p.Status, "Warning", StringComparison.OrdinalIgnoreCase)),
+ AccountabilityCritical = par.Count(p => string.Equals(p.Status, "Critical", StringComparison.OrdinalIgnoreCase))
+ };
+ }
+
+ public async Task GetAfterActionReportAsync(int departmentId, int callId)
+ {
+ var summary = await GetIncidentSummaryAsync(departmentId, callId);
+ if (summary == null)
+ return null;
+
+ return new IncidentAfterActionReport
+ {
+ Summary = summary,
+ Nodes = await _incidentCommandService.GetNodesForCallAsync(departmentId, callId),
+ Assignments = await _incidentCommandService.GetAssignmentsForCallAsync(departmentId, callId),
+ Objectives = await _incidentCommandService.GetObjectivesForCallAsync(departmentId, callId),
+ Timeline = await _incidentCommandService.GetTimelineForCallAsync(departmentId, callId),
+ Roles = await _incidentCommandService.GetIncidentRolesAsync(departmentId, callId)
+ };
+ }
+
+ public async Task ExportTimelineCsvAsync(int departmentId, int callId)
+ {
+ var timeline = await _incidentCommandService.GetTimelineForCallAsync(departmentId, callId);
+
+ var sb = new StringBuilder();
+ sb.AppendLine("OccurredOn,EntryType,Description,UserId");
+
+ foreach (var entry in timeline)
+ {
+ sb.AppendLine($"{entry.OccurredOn:o},{(CommandLogEntryType)entry.EntryType},{Escape(entry.Description)},{Escape(entry.UserId)}");
+ }
+
+ return sb.ToString();
+ }
+
+ private static string Escape(string value)
+ {
+ if (string.IsNullOrEmpty(value))
+ return string.Empty;
+
+ // A leading =, +, -, @, tab or CR makes Excel/Sheets evaluate the cell as a formula on import
+ // (CSV injection). Plain numeric values (e.g. "-122.5") are exempt so coordinates/numbers survive.
+ if ((value[0] == '=' || value[0] == '+' || value[0] == '-' || value[0] == '@' || value[0] == '\t' || value[0] == '\r')
+ && !double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out _))
+ value = "'" + value;
+
+ if (value.Contains(",") || value.Contains("\"") || value.Contains("\n") || value.Contains("\r"))
+ return "\"" + value.Replace("\"", "\"\"") + "\"";
+
+ return value;
+ }
+ }
+}
diff --git a/Core/Resgrid.Services/IncidentResourcesService.cs b/Core/Resgrid.Services/IncidentResourcesService.cs
new file mode 100644
index 000000000..ab7f0f352
--- /dev/null
+++ b/Core/Resgrid.Services/IncidentResourcesService.cs
@@ -0,0 +1,196 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Resgrid.Model;
+using Resgrid.Model.Events;
+using Resgrid.Model.Providers;
+using Resgrid.Model.Repositories;
+using Resgrid.Model.Services;
+
+namespace Resgrid.Services
+{
+ ///
+ /// Manages incident-scoped ad-hoc units/personnel and unit forming. Logs creation to the command timeline
+ /// via (no cycle — IncidentCommandService does not depend on this).
+ ///
+ public class IncidentResourcesService : IIncidentResourcesService
+ {
+ private readonly IIncidentAdHocUnitRepository _adHocUnitRepository;
+ private readonly IIncidentAdHocPersonnelRepository _adHocPersonnelRepository;
+ private readonly IIncidentCommandService _incidentCommandService;
+ private readonly IEventAggregator _eventAggregator;
+
+ public IncidentResourcesService(
+ IIncidentAdHocUnitRepository adHocUnitRepository,
+ IIncidentAdHocPersonnelRepository adHocPersonnelRepository,
+ IIncidentCommandService incidentCommandService,
+ IEventAggregator eventAggregator)
+ {
+ _adHocUnitRepository = adHocUnitRepository;
+ _adHocPersonnelRepository = adHocPersonnelRepository;
+ _incidentCommandService = incidentCommandService;
+ _eventAggregator = eventAggregator;
+ }
+
+ #region Ad-hoc units
+
+ public async Task CreateAdHocUnitAsync(IncidentAdHocUnit unit, string userId, CancellationToken cancellationToken = default(CancellationToken))
+ {
+ // The call must have an active command owned by the caller's department. CallId is an auto-increment
+ // integer (guessable), so this prevents creating resources against another department's call.
+ var command = await _incidentCommandService.GetActiveCommandForCallAsync(unit.DepartmentId, unit.CallId);
+ if (command == null)
+ return null;
+
+ if (string.IsNullOrWhiteSpace(unit.IncidentAdHocUnitId))
+ unit.IncidentAdHocUnitId = Guid.NewGuid().ToString();
+
+ unit.CreatedByUserId = userId;
+ if (unit.CreatedOn == default(DateTime))
+ unit.CreatedOn = DateTime.UtcNow;
+
+ unit = await _adHocUnitRepository.SaveOrUpdateAsync(unit, cancellationToken);
+
+ await LogAsync(unit.DepartmentId, unit.CallId, $"Ad-hoc unit '{unit.Name}' created", userId, cancellationToken);
+
+ _eventAggregator.SendMessage(new AdHocResourceCreatedEvent { DepartmentId = unit.DepartmentId, CallId = unit.CallId, ResourceId = unit.IncidentAdHocUnitId, Name = unit.Name, Kind = "Unit" });
+ return unit;
+ }
+
+ public async Task GetAdHocUnitByIdAsync(string incidentAdHocUnitId)
+ {
+ return await _adHocUnitRepository.GetByIdAsync(incidentAdHocUnitId);
+ }
+
+ public async Task> GetAdHocUnitsForCallAsync(int departmentId, int callId)
+ {
+ var items = await _adHocUnitRepository.GetAllByDepartmentIdAsync(departmentId);
+ if (items == null)
+ return new List();
+
+ return items.Where(x => x.CallId == callId && x.ReleasedOn == null).ToList();
+ }
+
+ public async Task ReleaseAdHocUnitAsync(int departmentId, string incidentAdHocUnitId, string userId, CancellationToken cancellationToken = default(CancellationToken))
+ {
+ var unit = await _adHocUnitRepository.GetByIdAsync(incidentAdHocUnitId);
+ if (unit == null || unit.DepartmentId != departmentId)
+ return false;
+
+ unit.ReleasedOn = DateTime.UtcNow;
+ await _adHocUnitRepository.SaveOrUpdateAsync(unit, cancellationToken);
+
+ await LogAsync(unit.DepartmentId, unit.CallId, $"Ad-hoc unit '{unit.Name}' released", userId, cancellationToken);
+ return true;
+ }
+
+ #endregion Ad-hoc units
+
+ #region Ad-hoc personnel
+
+ public async Task CreateAdHocPersonnelAsync(IncidentAdHocPersonnel personnel, string userId, CancellationToken cancellationToken = default(CancellationToken))
+ {
+ // The call must have an active command owned by the caller's department (guessable integer CallId guard).
+ var command = await _incidentCommandService.GetActiveCommandForCallAsync(personnel.DepartmentId, personnel.CallId);
+ if (command == null)
+ return null;
+
+ if (string.IsNullOrWhiteSpace(personnel.IncidentAdHocPersonnelId))
+ personnel.IncidentAdHocPersonnelId = Guid.NewGuid().ToString();
+
+ personnel.CreatedByUserId = userId;
+ if (personnel.CreatedOn == default(DateTime))
+ personnel.CreatedOn = DateTime.UtcNow;
+
+ personnel = await _adHocPersonnelRepository.SaveOrUpdateAsync(personnel, cancellationToken);
+
+ await LogAsync(personnel.DepartmentId, personnel.CallId, $"Ad-hoc personnel '{personnel.Name}' created", userId, cancellationToken);
+
+ _eventAggregator.SendMessage(new AdHocResourceCreatedEvent { DepartmentId = personnel.DepartmentId, CallId = personnel.CallId, ResourceId = personnel.IncidentAdHocPersonnelId, Name = personnel.Name, Kind = "Personnel" });
+ return personnel;
+ }
+
+ public async Task> GetAdHocPersonnelForCallAsync(int departmentId, int callId)
+ {
+ var items = await _adHocPersonnelRepository.GetAllByDepartmentIdAsync(departmentId);
+ if (items == null)
+ return new List();
+
+ return items.Where(x => x.CallId == callId && x.ReleasedOn == null).ToList();
+ }
+
+ public async Task ReleaseAdHocPersonnelAsync(int departmentId, string incidentAdHocPersonnelId, string userId, CancellationToken cancellationToken = default(CancellationToken))
+ {
+ var personnel = await _adHocPersonnelRepository.GetByIdAsync(incidentAdHocPersonnelId);
+ if (personnel == null || personnel.DepartmentId != departmentId)
+ return false;
+
+ personnel.ReleasedOn = DateTime.UtcNow;
+ await _adHocPersonnelRepository.SaveOrUpdateAsync(personnel, cancellationToken);
+
+ await LogAsync(personnel.DepartmentId, personnel.CallId, $"Ad-hoc personnel '{personnel.Name}' released", userId, cancellationToken);
+ return true;
+ }
+
+ #endregion Ad-hoc personnel
+
+ #region Roster building
+
+ public async Task AssignPersonnelToUnitAsync(int departmentId, string incidentAdHocPersonnelId, int ridingResourceKind, string ridingResourceId, string userId, CancellationToken cancellationToken = default(CancellationToken))
+ {
+ var personnel = await _adHocPersonnelRepository.GetByIdAsync(incidentAdHocPersonnelId);
+ if (personnel == null || personnel.DepartmentId != departmentId)
+ return null;
+
+ personnel.RidingResourceKind = ridingResourceKind;
+ personnel.RidingResourceId = ridingResourceId;
+ personnel = await _adHocPersonnelRepository.SaveOrUpdateAsync(personnel, cancellationToken);
+
+ await LogAsync(personnel.DepartmentId, personnel.CallId, $"'{personnel.Name}' added to unit roster", userId, cancellationToken);
+ return personnel;
+ }
+
+ public async Task FormUnitFromPersonnelAsync(IncidentAdHocUnit unit, List adHocPersonnelIds, string userId, CancellationToken cancellationToken = default(CancellationToken))
+ {
+ var createdUnit = await CreateAdHocUnitAsync(unit, userId, cancellationToken);
+
+ // Rejected by the create guard (call not owned / no active command for the caller's department).
+ if (createdUnit == null)
+ return null;
+
+ if (adHocPersonnelIds != null)
+ {
+ foreach (var personnelId in adHocPersonnelIds)
+ {
+ var personnel = await _adHocPersonnelRepository.GetByIdAsync(personnelId);
+ if (personnel == null || personnel.DepartmentId != createdUnit.DepartmentId)
+ continue;
+
+ personnel.RidingResourceKind = (int)ResourceAssignmentKind.AdHocUnit;
+ personnel.RidingResourceId = createdUnit.IncidentAdHocUnitId;
+ await _adHocPersonnelRepository.SaveOrUpdateAsync(personnel, cancellationToken);
+ }
+ }
+
+ await LogAsync(createdUnit.DepartmentId, createdUnit.CallId, $"Unit '{createdUnit.Name}' formed from on-scene personnel", userId, cancellationToken);
+ return createdUnit;
+ }
+
+ #endregion Roster building
+
+ #region Private helpers
+
+ private async Task LogAsync(int departmentId, int callId, string description, string userId, CancellationToken cancellationToken)
+ {
+ var command = await _incidentCommandService.GetActiveCommandForCallAsync(departmentId, callId);
+ if (command == null)
+ return;
+
+ await _incidentCommandService.AddLogEntryAsync(command.IncidentCommandId, departmentId, callId, CommandLogEntryType.AdHocResourceCreated, description, userId, cancellationToken);
+ }
+
+ #endregion Private helpers
+ }
+}
diff --git a/Core/Resgrid.Services/IncidentVoiceService.cs b/Core/Resgrid.Services/IncidentVoiceService.cs
new file mode 100644
index 000000000..4d6106a80
--- /dev/null
+++ b/Core/Resgrid.Services/IncidentVoiceService.cs
@@ -0,0 +1,135 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Resgrid.Model;
+using Resgrid.Model.Events;
+using Resgrid.Model.Providers;
+using Resgrid.Model.Repositories;
+using Resgrid.Model.Services;
+
+namespace Resgrid.Services
+{
+ ///
+ /// Orchestrates on-demand incident tactical voice channels on top of the existing department voice addon.
+ /// Logs open/close to the command timeline via repositories (does not depend on IIncidentCommandService, so
+ /// IncidentCommandService can call this on command-close without a circular dependency).
+ ///
+ public class IncidentVoiceService : IIncidentVoiceService
+ {
+ private readonly IVoiceService _voiceService;
+ private readonly IDepartmentsService _departmentsService;
+ private readonly ICommandLogEntryRepository _commandLogEntryRepository;
+ private readonly IIncidentCommandRepository _incidentCommandRepository;
+ private readonly IEventAggregator _eventAggregator;
+ private readonly ICoreEventService _coreEventService;
+
+ public IncidentVoiceService(
+ IVoiceService voiceService,
+ IDepartmentsService departmentsService,
+ ICommandLogEntryRepository commandLogEntryRepository,
+ IIncidentCommandRepository incidentCommandRepository,
+ IEventAggregator eventAggregator,
+ ICoreEventService coreEventService)
+ {
+ _voiceService = voiceService;
+ _departmentsService = departmentsService;
+ _commandLogEntryRepository = commandLogEntryRepository;
+ _incidentCommandRepository = incidentCommandRepository;
+ _eventAggregator = eventAggregator;
+ _coreEventService = coreEventService;
+ }
+
+ public async Task CanUseVoiceAsync(int departmentId)
+ {
+ return await _voiceService.CanDepartmentUseVoiceAsync(departmentId);
+ }
+
+ public async Task CreateIncidentChannelAsync(int departmentId, int callId, string name, string userId, CancellationToken cancellationToken = default(CancellationToken))
+ {
+ if (!await _voiceService.CanDepartmentUseVoiceAsync(departmentId))
+ return null;
+
+ // The call must have an active command owned by the caller's department. CallId is an auto-increment
+ // integer (guessable), so this prevents opening a channel against another department's call.
+ var commands = await _incidentCommandRepository.GetAllByDepartmentIdAsync(departmentId);
+ var command = commands?.FirstOrDefault(x => x.CallId == callId && x.Status == (int)IncidentCommandStatus.Active);
+ if (command == null)
+ return null;
+
+ var department = await _departmentsService.GetDepartmentByIdAsync(departmentId);
+ if (department == null)
+ return null;
+
+ var channelName = string.IsNullOrWhiteSpace(name) ? $"Incident {callId}" : name;
+
+ var channel = await _voiceService.SaveChannelToVoipProviderAsync(department, channelName, cancellationToken);
+ if (channel == null)
+ return null;
+
+ channel.CallId = callId;
+ channel.IsOnDemand = true;
+ channel.ClosedOn = null;
+ channel = await _voiceService.SaveOrUpdateVoiceChannelAsync(channel, departmentId, cancellationToken);
+
+ await WriteLogAsync(departmentId, callId, CommandLogEntryType.ChannelOpened, $"Tactical channel '{channelName}' opened", userId, cancellationToken);
+
+ _eventAggregator.SendMessage(new IncidentChannelOpenedEvent { DepartmentId = departmentId, CallId = callId, DepartmentVoiceChannelId = channel.DepartmentVoiceChannelId, Name = channelName });
+ return channel;
+ }
+
+ public async Task> GetChannelsForCallAsync(int departmentId, int callId)
+ {
+ var voice = await _voiceService.GetVoiceSettingsForDepartmentAsync(departmentId);
+ if (voice?.Channels == null)
+ return new List();
+
+ return voice.Channels.Where(c => c.IsOnDemand && c.CallId == callId && c.ClosedOn == null).ToList();
+ }
+
+ public async Task CloseIncidentChannelsForCallAsync(int departmentId, int callId, string userId, CancellationToken cancellationToken = default(CancellationToken))
+ {
+ var channels = await GetChannelsForCallAsync(departmentId, callId);
+ if (channels == null || !channels.Any())
+ return false;
+
+ foreach (var channel in channels)
+ {
+ channel.ClosedOn = DateTime.UtcNow;
+ await _voiceService.SaveOrUpdateVoiceChannelAsync(channel, departmentId, cancellationToken);
+ }
+
+ await WriteLogAsync(departmentId, callId, CommandLogEntryType.ChannelClosed, $"{channels.Count} tactical channel(s) closed", userId, cancellationToken);
+ return true;
+ }
+
+ private async Task WriteLogAsync(int departmentId, int callId, CommandLogEntryType type, string description, string userId, CancellationToken cancellationToken)
+ {
+ var commands = await _incidentCommandRepository.GetAllByDepartmentIdAsync(departmentId);
+ // Match the call's command regardless of status (most-recent first): the close-command flow sets the
+ // command to Closed before auto-closing its channels, so an Active-only filter would silently drop the
+ // channel-closed timeline entry and its real-time board update.
+ var command = commands?.Where(x => x.CallId == callId).OrderByDescending(x => x.EstablishedOn).FirstOrDefault();
+ if (command == null)
+ return;
+
+ var entry = new CommandLogEntry
+ {
+ CommandLogEntryId = Guid.NewGuid().ToString(),
+ IncidentCommandId = command.IncidentCommandId,
+ DepartmentId = departmentId,
+ CallId = callId,
+ EntryType = (int)type,
+ Description = description,
+ UserId = userId,
+ OccurredOn = DateTime.UtcNow
+ };
+
+ await _commandLogEntryRepository.SaveOrUpdateAsync(entry, cancellationToken);
+
+ // Real-time: channel open/close is a board change.
+ await _coreEventService.IncidentCommandUpdatedAsync(departmentId, callId);
+ }
+ }
+}
diff --git a/Core/Resgrid.Services/MutualAidService.cs b/Core/Resgrid.Services/MutualAidService.cs
new file mode 100644
index 000000000..cd5bda7ba
--- /dev/null
+++ b/Core/Resgrid.Services/MutualAidService.cs
@@ -0,0 +1,116 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Resgrid.Model;
+using Resgrid.Model.Services;
+
+namespace Resgrid.Services
+{
+ ///
+ /// Aggregates assignable resources across the own department and its accepted mutual-aid links. Read-only;
+ /// honors the per-direction DepartmentLink share flags and never exposes personnel from a link that
+ /// does not share personnel toward this department.
+ ///
+ public class MutualAidService : IMutualAidService
+ {
+ private readonly IDepartmentLinksService _departmentLinksService;
+ private readonly IUnitsService _unitsService;
+ private readonly IDepartmentsService _departmentsService;
+
+ public MutualAidService(
+ IDepartmentLinksService departmentLinksService,
+ IUnitsService unitsService,
+ IDepartmentsService departmentsService)
+ {
+ _departmentLinksService = departmentLinksService;
+ _unitsService = unitsService;
+ _departmentsService = departmentsService;
+ }
+
+ public async Task> GetAssignableResourcesForIncidentAsync(int departmentId)
+ {
+ var resources = new List();
+
+ // Own department resources.
+ await AddDepartmentResourcesAsync(resources, departmentId, isMutualAid: false, color: null, includeUnits: true, includePersonnel: true);
+
+ // Linked (mutual-aid) department resources shared toward us.
+ var links = await _departmentLinksService.GetAllLinksForDepartmentAsync(departmentId);
+ if (links != null)
+ {
+ foreach (var link in links)
+ {
+ if (!link.LinkEnabled || link.LinkAccepted == null)
+ continue;
+
+ int otherDepartmentId;
+ bool sharesUnits;
+ bool sharesPersonnel;
+ string color;
+
+ // Determine the other party and what THEY share toward us (flags are per-direction).
+ if (link.DepartmentId == departmentId)
+ {
+ otherDepartmentId = link.LinkedDepartmentId;
+ sharesUnits = link.LinkedDepartmentShareUnits;
+ sharesPersonnel = link.LinkedDepartmentSharePersonnel;
+ color = link.LinkedDepartmentColor;
+ }
+ else
+ {
+ otherDepartmentId = link.DepartmentId;
+ sharesUnits = link.DepartmentShareUnits;
+ sharesPersonnel = link.DepartmentSharePersonnel;
+ color = link.DepartmentColor;
+ }
+
+ await AddDepartmentResourcesAsync(resources, otherDepartmentId, isMutualAid: true, color: color, includeUnits: sharesUnits, includePersonnel: sharesPersonnel);
+ }
+ }
+
+ return resources;
+ }
+
+ private async Task AddDepartmentResourcesAsync(List resources, int departmentId, bool isMutualAid, string color, bool includeUnits, bool includePersonnel)
+ {
+ if (includeUnits)
+ {
+ var units = await _unitsService.GetUnitsForDepartmentAsync(departmentId);
+ if (units != null)
+ {
+ foreach (var unit in units)
+ {
+ resources.Add(new AssignableResource
+ {
+ ResourceKind = (int)(isMutualAid ? ResourceAssignmentKind.LinkedDeptUnit : ResourceAssignmentKind.RealUnit),
+ ResourceId = unit.UnitId.ToString(),
+ Name = unit.Name,
+ DepartmentId = departmentId,
+ IsMutualAid = isMutualAid,
+ Color = color
+ });
+ }
+ }
+ }
+
+ if (includePersonnel)
+ {
+ var names = await _departmentsService.GetAllPersonnelNamesForDepartmentAsync(departmentId);
+ if (names != null)
+ {
+ foreach (var person in names)
+ {
+ resources.Add(new AssignableResource
+ {
+ ResourceKind = (int)(isMutualAid ? ResourceAssignmentKind.LinkedDeptPersonnel : ResourceAssignmentKind.RealPersonnel),
+ ResourceId = person.UserId,
+ Name = $"{person.FirstName} {person.LastName}".Trim(),
+ DepartmentId = departmentId,
+ IsMutualAid = isMutualAid,
+ Color = color
+ });
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs
index 95aa5fc44..d2cbab64c 100644
--- a/Core/Resgrid.Services/ServicesModule.cs
+++ b/Core/Resgrid.Services/ServicesModule.cs
@@ -16,6 +16,11 @@ 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();
builder.RegisterType().As().InstancePerLifetimeScope();
diff --git a/Providers/Resgrid.Providers.Bus/WorkflowEventProvider.cs b/Providers/Resgrid.Providers.Bus/WorkflowEventProvider.cs
index d3a8e174d..a94bcd99c 100644
--- a/Providers/Resgrid.Providers.Bus/WorkflowEventProvider.cs
+++ b/Providers/Resgrid.Providers.Bus/WorkflowEventProvider.cs
@@ -94,6 +94,18 @@ private void RegisterListeners()
_eventAggregator.AddListener(e => HandleEvent(e.DepartmentId, WorkflowTriggerEventType.PersonnelRoleChanged, e));
_eventAggregator.AddListener(e => HandleEvent(e.DepartmentId, WorkflowTriggerEventType.GroupAdded, e));
_eventAggregator.AddListener(e => HandleEvent(e.DepartmentId, WorkflowTriggerEventType.GroupUpdated, e));
+
+ // Incident Command (§3.12)
+ _eventAggregator.AddListener(e => HandleEvent(e.DepartmentId, WorkflowTriggerEventType.CommandEstablished, e));
+ _eventAggregator.AddListener(e => HandleEvent(e.DepartmentId, WorkflowTriggerEventType.CommandTransferred, e));
+ _eventAggregator.AddListener(e => HandleEvent(e.DepartmentId, WorkflowTriggerEventType.ObjectiveCompleted, e));
+ _eventAggregator.AddListener(e => HandleEvent(e.DepartmentId, WorkflowTriggerEventType.IncidentClosed, e));
+ _eventAggregator.AddListener(e => HandleEvent(e.DepartmentId, WorkflowTriggerEventType.ResourceAssigned, e));
+ _eventAggregator.AddListener(e => HandleEvent(e.DepartmentId, WorkflowTriggerEventType.ResourceReleased, e));
+ _eventAggregator.AddListener(e => HandleEvent(e.DepartmentId, WorkflowTriggerEventType.IncidentRoleAssigned, e));
+ _eventAggregator.AddListener(e => HandleEvent(e.DepartmentId, WorkflowTriggerEventType.AdHocResourceCreated, e));
+ _eventAggregator.AddListener(e => HandleEvent(e.DepartmentId, WorkflowTriggerEventType.IncidentChannelOpened, e));
+ _eventAggregator.AddListener(e => HandleEvent(e.DepartmentId, WorkflowTriggerEventType.CriticalParDetected, e));
}
private static async void HandleEvent(int departmentId, WorkflowTriggerEventType eventType, object eventObj)
diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0076_AddCommandDefinitionRoleLaneColumns.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0076_AddCommandDefinitionRoleLaneColumns.cs
new file mode 100644
index 000000000..cf5d2c49a
--- /dev/null
+++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0076_AddCommandDefinitionRoleLaneColumns.cs
@@ -0,0 +1,29 @@
+using FluentMigrator;
+
+namespace Resgrid.Providers.Migrations.Migrations
+{
+ [Migration(76)]
+ public class M0076_AddCommandDefinitionRoleLaneColumns : Migration
+ {
+ public override void Up()
+ {
+ if (!Schema.Table("CommandDefinitionRoles").Column("LaneType").Exists())
+ {
+ Alter.Table("CommandDefinitionRoles")
+ .AddColumn("LaneType").AsInt32().NotNullable().WithDefaultValue(0);
+ }
+
+ if (!Schema.Table("CommandDefinitionRoles").Column("SortOrder").Exists())
+ {
+ Alter.Table("CommandDefinitionRoles")
+ .AddColumn("SortOrder").AsInt32().NotNullable().WithDefaultValue(0);
+ }
+ }
+
+ public override void Down()
+ {
+ Delete.Column("LaneType").FromTable("CommandDefinitionRoles");
+ Delete.Column("SortOrder").FromTable("CommandDefinitionRoles");
+ }
+ }
+}
diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0077_AddIncidentCommandTables.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0077_AddIncidentCommandTables.cs
new file mode 100644
index 000000000..0b66faf4b
--- /dev/null
+++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0077_AddIncidentCommandTables.cs
@@ -0,0 +1,198 @@
+using FluentMigrator;
+
+namespace Resgrid.Providers.Migrations.Migrations
+{
+ [Migration(77)]
+ public class M0077_AddIncidentCommandTables : Migration
+ {
+ public override void Up()
+ {
+ if (!Schema.Table("IncidentCommands").Exists())
+ {
+ Create.Table("IncidentCommands")
+ .WithColumn("IncidentCommandId").AsString(128).NotNullable().PrimaryKey()
+ .WithColumn("DepartmentId").AsInt32().NotNullable()
+ .WithColumn("CallId").AsInt32().NotNullable()
+ .WithColumn("SourceCommandDefinitionId").AsInt32().Nullable()
+ .WithColumn("EstablishedByUserId").AsString(450).Nullable()
+ .WithColumn("EstablishedOn").AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime)
+ .WithColumn("CurrentCommanderUserId").AsString(450).Nullable()
+ .WithColumn("CommandPostLatitude").AsString(int.MaxValue).Nullable()
+ .WithColumn("CommandPostLongitude").AsString(int.MaxValue).Nullable()
+ .WithColumn("IncidentActionPlan").AsString(int.MaxValue).Nullable()
+ .WithColumn("IcsLevel").AsInt32().NotNullable().WithDefaultValue(0)
+ .WithColumn("Status").AsInt32().NotNullable().WithDefaultValue(0)
+ .WithColumn("ClosedOn").AsDateTime2().Nullable();
+
+ Create.Index("IX_IncidentCommands_Department_Call")
+ .OnTable("IncidentCommands")
+ .OnColumn("DepartmentId").Ascending()
+ .OnColumn("CallId").Ascending();
+
+ // At most one ACTIVE command per (department, call). Filtered so a closed command and a
+ // re-established active command can coexist. Backstops the check-then-insert race in
+ // IncidentCommandService.EstablishCommandAsync (which adopts the winner on violation).
+ Execute.Sql("CREATE UNIQUE NONCLUSTERED INDEX UX_IncidentCommands_Department_Call_Active ON IncidentCommands (DepartmentId, CallId) WHERE Status = 0;");
+ }
+
+ if (!Schema.Table("CommandStructureNodes").Exists())
+ {
+ Create.Table("CommandStructureNodes")
+ .WithColumn("CommandStructureNodeId").AsString(128).NotNullable().PrimaryKey()
+ .WithColumn("IncidentCommandId").AsString(128).NotNullable()
+ .WithColumn("DepartmentId").AsInt32().NotNullable()
+ .WithColumn("CallId").AsInt32().NotNullable()
+ .WithColumn("NodeType").AsInt32().NotNullable().WithDefaultValue(0)
+ .WithColumn("Name").AsString(int.MaxValue).Nullable()
+ .WithColumn("ParentNodeId").AsString(128).Nullable()
+ .WithColumn("SupervisorUserId").AsString(450).Nullable()
+ .WithColumn("SupervisorUnitId").AsInt32().Nullable()
+ .WithColumn("SortOrder").AsInt32().NotNullable().WithDefaultValue(0)
+ .WithColumn("SourceRoleId").AsInt32().Nullable();
+
+ Create.Index("IX_CommandStructureNodes_Department_Call")
+ .OnTable("CommandStructureNodes")
+ .OnColumn("DepartmentId").Ascending()
+ .OnColumn("CallId").Ascending();
+ }
+
+ if (!Schema.Table("ResourceAssignments").Exists())
+ {
+ Create.Table("ResourceAssignments")
+ .WithColumn("ResourceAssignmentId").AsString(128).NotNullable().PrimaryKey()
+ .WithColumn("IncidentCommandId").AsString(128).NotNullable()
+ .WithColumn("DepartmentId").AsInt32().NotNullable()
+ .WithColumn("CallId").AsInt32().NotNullable()
+ .WithColumn("CommandStructureNodeId").AsString(128).Nullable()
+ .WithColumn("ResourceKind").AsInt32().NotNullable().WithDefaultValue(0)
+ .WithColumn("ResourceId").AsString(450).Nullable()
+ .WithColumn("AssignedByUserId").AsString(450).Nullable()
+ .WithColumn("AssignedOn").AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime)
+ .WithColumn("ReleasedOn").AsDateTime2().Nullable();
+
+ Create.Index("IX_ResourceAssignments_Department_Call")
+ .OnTable("ResourceAssignments")
+ .OnColumn("DepartmentId").Ascending()
+ .OnColumn("CallId").Ascending();
+ }
+
+ if (!Schema.Table("TacticalObjectives").Exists())
+ {
+ Create.Table("TacticalObjectives")
+ .WithColumn("TacticalObjectiveId").AsString(128).NotNullable().PrimaryKey()
+ .WithColumn("IncidentCommandId").AsString(128).NotNullable()
+ .WithColumn("DepartmentId").AsInt32().NotNullable()
+ .WithColumn("CallId").AsInt32().NotNullable()
+ .WithColumn("Name").AsString(int.MaxValue).Nullable()
+ .WithColumn("ObjectiveType").AsInt32().NotNullable().WithDefaultValue(0)
+ .WithColumn("Status").AsInt32().NotNullable().WithDefaultValue(0)
+ .WithColumn("AutoPopulated").AsBoolean().NotNullable().WithDefaultValue(false)
+ .WithColumn("CompletedByUserId").AsString(450).Nullable()
+ .WithColumn("CompletedOn").AsDateTime2().Nullable()
+ .WithColumn("SortOrder").AsInt32().NotNullable().WithDefaultValue(0);
+
+ Create.Index("IX_TacticalObjectives_Department_Call")
+ .OnTable("TacticalObjectives")
+ .OnColumn("DepartmentId").Ascending()
+ .OnColumn("CallId").Ascending();
+ }
+
+ if (!Schema.Table("IncidentTimers").Exists())
+ {
+ Create.Table("IncidentTimers")
+ .WithColumn("IncidentTimerId").AsString(128).NotNullable().PrimaryKey()
+ .WithColumn("IncidentCommandId").AsString(128).NotNullable()
+ .WithColumn("DepartmentId").AsInt32().NotNullable()
+ .WithColumn("CallId").AsInt32().NotNullable()
+ .WithColumn("TimerType").AsInt32().NotNullable().WithDefaultValue(0)
+ .WithColumn("ScopeType").AsInt32().NotNullable().WithDefaultValue(0)
+ .WithColumn("ScopeId").AsString(128).Nullable()
+ .WithColumn("Name").AsString(int.MaxValue).Nullable()
+ .WithColumn("IntervalSeconds").AsInt32().NotNullable().WithDefaultValue(0)
+ .WithColumn("StartedOn").AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime)
+ .WithColumn("NextDueOn").AsDateTime2().Nullable()
+ .WithColumn("Status").AsInt32().NotNullable().WithDefaultValue(0)
+ .WithColumn("AcknowledgedOn").AsDateTime2().Nullable();
+
+ Create.Index("IX_IncidentTimers_Department_Call")
+ .OnTable("IncidentTimers")
+ .OnColumn("DepartmentId").Ascending()
+ .OnColumn("CallId").Ascending();
+ }
+
+ if (!Schema.Table("IncidentMapAnnotations").Exists())
+ {
+ Create.Table("IncidentMapAnnotations")
+ .WithColumn("IncidentMapAnnotationId").AsString(128).NotNullable().PrimaryKey()
+ .WithColumn("IncidentCommandId").AsString(128).NotNullable()
+ .WithColumn("DepartmentId").AsInt32().NotNullable()
+ .WithColumn("CallId").AsInt32().NotNullable()
+ .WithColumn("AnnotationType").AsInt32().NotNullable().WithDefaultValue(0)
+ .WithColumn("GeoJson").AsString(int.MaxValue).Nullable()
+ .WithColumn("IcsSymbolCode").AsString(int.MaxValue).Nullable()
+ .WithColumn("Label").AsString(int.MaxValue).Nullable()
+ .WithColumn("CreatedByUserId").AsString(450).Nullable()
+ .WithColumn("CreatedOn").AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime)
+ .WithColumn("DeletedOn").AsDateTime2().Nullable();
+
+ Create.Index("IX_IncidentMapAnnotations_Department_Call")
+ .OnTable("IncidentMapAnnotations")
+ .OnColumn("DepartmentId").Ascending()
+ .OnColumn("CallId").Ascending();
+ }
+
+ if (!Schema.Table("CommandLogEntries").Exists())
+ {
+ Create.Table("CommandLogEntries")
+ .WithColumn("CommandLogEntryId").AsString(128).NotNullable().PrimaryKey()
+ .WithColumn("IncidentCommandId").AsString(128).NotNullable()
+ .WithColumn("DepartmentId").AsInt32().NotNullable()
+ .WithColumn("CallId").AsInt32().NotNullable()
+ .WithColumn("EntryType").AsInt32().NotNullable().WithDefaultValue(0)
+ .WithColumn("Description").AsString(int.MaxValue).Nullable()
+ .WithColumn("UserId").AsString(450).Nullable()
+ .WithColumn("Latitude").AsString(int.MaxValue).Nullable()
+ .WithColumn("Longitude").AsString(int.MaxValue).Nullable()
+ .WithColumn("OccurredOn").AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime);
+
+ Create.Index("IX_CommandLogEntries_Department_Call")
+ .OnTable("CommandLogEntries")
+ .OnColumn("DepartmentId").Ascending()
+ .OnColumn("CallId").Ascending();
+ }
+
+ if (!Schema.Table("CommandTransfers").Exists())
+ {
+ Create.Table("CommandTransfers")
+ .WithColumn("CommandTransferId").AsString(128).NotNullable().PrimaryKey()
+ .WithColumn("IncidentCommandId").AsString(128).NotNullable()
+ .WithColumn("DepartmentId").AsInt32().NotNullable()
+ .WithColumn("CallId").AsInt32().NotNullable()
+ .WithColumn("FromUserId").AsString(450).Nullable()
+ .WithColumn("ToUserId").AsString(450).Nullable()
+ .WithColumn("TransferredOn").AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime)
+ .WithColumn("Notes").AsString(int.MaxValue).Nullable();
+
+ Create.Index("IX_CommandTransfers_Department_Call")
+ .OnTable("CommandTransfers")
+ .OnColumn("DepartmentId").Ascending()
+ .OnColumn("CallId").Ascending();
+ }
+ }
+
+ public override void Down()
+ {
+ // Explicit drop (the table drop below would also remove it, but be explicit to mirror the codebase pattern).
+ Execute.Sql("DROP INDEX IF EXISTS UX_IncidentCommands_Department_Call_Active ON IncidentCommands;");
+
+ Delete.Table("CommandTransfers");
+ Delete.Table("CommandLogEntries");
+ Delete.Table("IncidentMapAnnotations");
+ Delete.Table("IncidentTimers");
+ Delete.Table("TacticalObjectives");
+ Delete.Table("ResourceAssignments");
+ Delete.Table("CommandStructureNodes");
+ Delete.Table("IncidentCommands");
+ }
+ }
+}
diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0078_AddIncidentScopeToVoiceChannels.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0078_AddIncidentScopeToVoiceChannels.cs
new file mode 100644
index 000000000..836d8298c
--- /dev/null
+++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0078_AddIncidentScopeToVoiceChannels.cs
@@ -0,0 +1,36 @@
+using FluentMigrator;
+
+namespace Resgrid.Providers.Migrations.Migrations
+{
+ [Migration(78)]
+ public class M0078_AddIncidentScopeToVoiceChannels : Migration
+ {
+ public override void Up()
+ {
+ if (!Schema.Table("DepartmentVoiceChannels").Column("CallId").Exists())
+ {
+ Alter.Table("DepartmentVoiceChannels")
+ .AddColumn("CallId").AsInt32().Nullable();
+ }
+
+ if (!Schema.Table("DepartmentVoiceChannels").Column("IsOnDemand").Exists())
+ {
+ Alter.Table("DepartmentVoiceChannels")
+ .AddColumn("IsOnDemand").AsBoolean().NotNullable().WithDefaultValue(false);
+ }
+
+ if (!Schema.Table("DepartmentVoiceChannels").Column("ClosedOn").Exists())
+ {
+ Alter.Table("DepartmentVoiceChannels")
+ .AddColumn("ClosedOn").AsDateTime2().Nullable();
+ }
+ }
+
+ public override void Down()
+ {
+ Delete.Column("CallId").FromTable("DepartmentVoiceChannels");
+ Delete.Column("IsOnDemand").FromTable("DepartmentVoiceChannels");
+ Delete.Column("ClosedOn").FromTable("DepartmentVoiceChannels");
+ }
+ }
+}
diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0079_AddIncidentAdHocResourceTables.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0079_AddIncidentAdHocResourceTables.cs
new file mode 100644
index 000000000..5df15ac79
--- /dev/null
+++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0079_AddIncidentAdHocResourceTables.cs
@@ -0,0 +1,59 @@
+using FluentMigrator;
+
+namespace Resgrid.Providers.Migrations.Migrations
+{
+ [Migration(79)]
+ public class M0079_AddIncidentAdHocResourceTables : Migration
+ {
+ public override void Up()
+ {
+ if (!Schema.Table("IncidentAdHocUnits").Exists())
+ {
+ Create.Table("IncidentAdHocUnits")
+ .WithColumn("IncidentAdHocUnitId").AsString(128).NotNullable().PrimaryKey()
+ .WithColumn("DepartmentId").AsInt32().NotNullable()
+ .WithColumn("CallId").AsInt32().NotNullable()
+ .WithColumn("Name").AsString(int.MaxValue).Nullable()
+ .WithColumn("UnitTypeId").AsInt32().Nullable()
+ .WithColumn("Type").AsString(int.MaxValue).Nullable()
+ .WithColumn("ExternalAgencyName").AsString(int.MaxValue).Nullable()
+ .WithColumn("CreatedByUserId").AsString(450).Nullable()
+ .WithColumn("CreatedOn").AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime)
+ .WithColumn("ReleasedOn").AsDateTime2().Nullable();
+
+ Create.Index("IX_IncidentAdHocUnits_Department_Call")
+ .OnTable("IncidentAdHocUnits")
+ .OnColumn("DepartmentId").Ascending()
+ .OnColumn("CallId").Ascending();
+ }
+
+ if (!Schema.Table("IncidentAdHocPersonnel").Exists())
+ {
+ Create.Table("IncidentAdHocPersonnel")
+ .WithColumn("IncidentAdHocPersonnelId").AsString(128).NotNullable().PrimaryKey()
+ .WithColumn("DepartmentId").AsInt32().NotNullable()
+ .WithColumn("CallId").AsInt32().NotNullable()
+ .WithColumn("Name").AsString(int.MaxValue).Nullable()
+ .WithColumn("Role").AsString(int.MaxValue).Nullable()
+ .WithColumn("ExternalAgencyName").AsString(int.MaxValue).Nullable()
+ .WithColumn("Contact").AsString(int.MaxValue).Nullable()
+ .WithColumn("RidingResourceKind").AsInt32().NotNullable().WithDefaultValue(0)
+ .WithColumn("RidingResourceId").AsString(450).Nullable()
+ .WithColumn("CreatedByUserId").AsString(450).Nullable()
+ .WithColumn("CreatedOn").AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime)
+ .WithColumn("ReleasedOn").AsDateTime2().Nullable();
+
+ Create.Index("IX_IncidentAdHocPersonnel_Department_Call")
+ .OnTable("IncidentAdHocPersonnel")
+ .OnColumn("DepartmentId").Ascending()
+ .OnColumn("CallId").Ascending();
+ }
+ }
+
+ public override void Down()
+ {
+ Delete.Table("IncidentAdHocPersonnel");
+ Delete.Table("IncidentAdHocUnits");
+ }
+ }
+}
diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0080_AddIncidentRoleAssignmentTable.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0080_AddIncidentRoleAssignmentTable.cs
new file mode 100644
index 000000000..5881a6061
--- /dev/null
+++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0080_AddIncidentRoleAssignmentTable.cs
@@ -0,0 +1,36 @@
+using FluentMigrator;
+
+namespace Resgrid.Providers.Migrations.Migrations
+{
+ [Migration(80)]
+ public class M0080_AddIncidentRoleAssignmentTable : Migration
+ {
+ public override void Up()
+ {
+ if (!Schema.Table("IncidentRoleAssignments").Exists())
+ {
+ Create.Table("IncidentRoleAssignments")
+ .WithColumn("IncidentRoleAssignmentId").AsString(128).NotNullable().PrimaryKey()
+ .WithColumn("IncidentCommandId").AsString(128).NotNullable()
+ .WithColumn("DepartmentId").AsInt32().NotNullable()
+ .WithColumn("CallId").AsInt32().NotNullable()
+ .WithColumn("UserId").AsString(450).Nullable()
+ .WithColumn("RoleType").AsInt32().NotNullable().WithDefaultValue(0)
+ .WithColumn("ScopeNodeId").AsString(128).Nullable()
+ .WithColumn("AssignedByUserId").AsString(450).Nullable()
+ .WithColumn("AssignedOn").AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime)
+ .WithColumn("RemovedOn").AsDateTime2().Nullable();
+
+ Create.Index("IX_IncidentRoleAssignments_Department_Call")
+ .OnTable("IncidentRoleAssignments")
+ .OnColumn("DepartmentId").Ascending()
+ .OnColumn("CallId").Ascending();
+ }
+ }
+
+ public override void Down()
+ {
+ Delete.Table("IncidentRoleAssignments");
+ }
+ }
+}
diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0076_AddCommandDefinitionRoleLaneColumnsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0076_AddCommandDefinitionRoleLaneColumnsPg.cs
new file mode 100644
index 000000000..04445e187
--- /dev/null
+++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0076_AddCommandDefinitionRoleLaneColumnsPg.cs
@@ -0,0 +1,29 @@
+using FluentMigrator;
+
+namespace Resgrid.Providers.MigrationsPg.Migrations
+{
+ [Migration(76)]
+ public class M0076_AddCommandDefinitionRoleLaneColumnsPg : Migration
+ {
+ public override void Up()
+ {
+ if (!Schema.Table("commanddefinitionroles").Column("lanetype").Exists())
+ {
+ Alter.Table("commanddefinitionroles")
+ .AddColumn("lanetype").AsInt32().NotNullable().WithDefaultValue(0);
+ }
+
+ if (!Schema.Table("commanddefinitionroles").Column("sortorder").Exists())
+ {
+ Alter.Table("commanddefinitionroles")
+ .AddColumn("sortorder").AsInt32().NotNullable().WithDefaultValue(0);
+ }
+ }
+
+ public override void Down()
+ {
+ Delete.Column("lanetype").FromTable("commanddefinitionroles");
+ Delete.Column("sortorder").FromTable("commanddefinitionroles");
+ }
+ }
+}
diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0077_AddIncidentCommandTablesPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0077_AddIncidentCommandTablesPg.cs
new file mode 100644
index 000000000..e4b152d8c
--- /dev/null
+++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0077_AddIncidentCommandTablesPg.cs
@@ -0,0 +1,198 @@
+using FluentMigrator;
+
+namespace Resgrid.Providers.MigrationsPg.Migrations
+{
+ [Migration(77)]
+ public class M0077_AddIncidentCommandTablesPg : Migration
+ {
+ public override void Up()
+ {
+ if (!Schema.Table("IncidentCommands".ToLower()).Exists())
+ {
+ Create.Table("IncidentCommands".ToLower())
+ .WithColumn("IncidentCommandId".ToLower()).AsCustom("citext").NotNullable().PrimaryKey()
+ .WithColumn("DepartmentId".ToLower()).AsInt32().NotNullable()
+ .WithColumn("CallId".ToLower()).AsInt32().NotNullable()
+ .WithColumn("SourceCommandDefinitionId".ToLower()).AsInt32().Nullable()
+ .WithColumn("EstablishedByUserId".ToLower()).AsCustom("citext").Nullable()
+ .WithColumn("EstablishedOn".ToLower()).AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime)
+ .WithColumn("CurrentCommanderUserId".ToLower()).AsCustom("citext").Nullable()
+ .WithColumn("CommandPostLatitude".ToLower()).AsCustom("citext").Nullable()
+ .WithColumn("CommandPostLongitude".ToLower()).AsCustom("citext").Nullable()
+ .WithColumn("IncidentActionPlan".ToLower()).AsCustom("text").Nullable()
+ .WithColumn("IcsLevel".ToLower()).AsInt32().NotNullable().WithDefaultValue(0)
+ .WithColumn("Status".ToLower()).AsInt32().NotNullable().WithDefaultValue(0)
+ .WithColumn("ClosedOn".ToLower()).AsDateTime2().Nullable();
+
+ Create.Index("IX_IncidentCommands_Department_Call".ToLower())
+ .OnTable("IncidentCommands".ToLower())
+ .OnColumn("DepartmentId".ToLower()).Ascending()
+ .OnColumn("CallId".ToLower()).Ascending();
+
+ // At most one ACTIVE command per (department, call). Partial so a closed command and a
+ // re-established active command can coexist. Backstops the check-then-insert race in
+ // IncidentCommandService.EstablishCommandAsync (which adopts the winner on violation).
+ Execute.Sql("CREATE UNIQUE INDEX IF NOT EXISTS ux_incidentcommands_department_call_active ON incidentcommands (departmentid, callid) WHERE status = 0;");
+ }
+
+ if (!Schema.Table("CommandStructureNodes".ToLower()).Exists())
+ {
+ Create.Table("CommandStructureNodes".ToLower())
+ .WithColumn("CommandStructureNodeId".ToLower()).AsCustom("citext").NotNullable().PrimaryKey()
+ .WithColumn("IncidentCommandId".ToLower()).AsCustom("citext").NotNullable()
+ .WithColumn("DepartmentId".ToLower()).AsInt32().NotNullable()
+ .WithColumn("CallId".ToLower()).AsInt32().NotNullable()
+ .WithColumn("NodeType".ToLower()).AsInt32().NotNullable().WithDefaultValue(0)
+ .WithColumn("Name".ToLower()).AsCustom("citext").Nullable()
+ .WithColumn("ParentNodeId".ToLower()).AsCustom("citext").Nullable()
+ .WithColumn("SupervisorUserId".ToLower()).AsCustom("citext").Nullable()
+ .WithColumn("SupervisorUnitId".ToLower()).AsInt32().Nullable()
+ .WithColumn("SortOrder".ToLower()).AsInt32().NotNullable().WithDefaultValue(0)
+ .WithColumn("SourceRoleId".ToLower()).AsInt32().Nullable();
+
+ Create.Index("IX_CommandStructureNodes_Department_Call".ToLower())
+ .OnTable("CommandStructureNodes".ToLower())
+ .OnColumn("DepartmentId".ToLower()).Ascending()
+ .OnColumn("CallId".ToLower()).Ascending();
+ }
+
+ if (!Schema.Table("ResourceAssignments".ToLower()).Exists())
+ {
+ Create.Table("ResourceAssignments".ToLower())
+ .WithColumn("ResourceAssignmentId".ToLower()).AsCustom("citext").NotNullable().PrimaryKey()
+ .WithColumn("IncidentCommandId".ToLower()).AsCustom("citext").NotNullable()
+ .WithColumn("DepartmentId".ToLower()).AsInt32().NotNullable()
+ .WithColumn("CallId".ToLower()).AsInt32().NotNullable()
+ .WithColumn("CommandStructureNodeId".ToLower()).AsCustom("citext").Nullable()
+ .WithColumn("ResourceKind".ToLower()).AsInt32().NotNullable().WithDefaultValue(0)
+ .WithColumn("ResourceId".ToLower()).AsCustom("citext").Nullable()
+ .WithColumn("AssignedByUserId".ToLower()).AsCustom("citext").Nullable()
+ .WithColumn("AssignedOn".ToLower()).AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime)
+ .WithColumn("ReleasedOn".ToLower()).AsDateTime2().Nullable();
+
+ Create.Index("IX_ResourceAssignments_Department_Call".ToLower())
+ .OnTable("ResourceAssignments".ToLower())
+ .OnColumn("DepartmentId".ToLower()).Ascending()
+ .OnColumn("CallId".ToLower()).Ascending();
+ }
+
+ if (!Schema.Table("TacticalObjectives".ToLower()).Exists())
+ {
+ Create.Table("TacticalObjectives".ToLower())
+ .WithColumn("TacticalObjectiveId".ToLower()).AsCustom("citext").NotNullable().PrimaryKey()
+ .WithColumn("IncidentCommandId".ToLower()).AsCustom("citext").NotNullable()
+ .WithColumn("DepartmentId".ToLower()).AsInt32().NotNullable()
+ .WithColumn("CallId".ToLower()).AsInt32().NotNullable()
+ .WithColumn("Name".ToLower()).AsCustom("citext").Nullable()
+ .WithColumn("ObjectiveType".ToLower()).AsInt32().NotNullable().WithDefaultValue(0)
+ .WithColumn("Status".ToLower()).AsInt32().NotNullable().WithDefaultValue(0)
+ .WithColumn("AutoPopulated".ToLower()).AsBoolean().NotNullable().WithDefaultValue(false)
+ .WithColumn("CompletedByUserId".ToLower()).AsCustom("citext").Nullable()
+ .WithColumn("CompletedOn".ToLower()).AsDateTime2().Nullable()
+ .WithColumn("SortOrder".ToLower()).AsInt32().NotNullable().WithDefaultValue(0);
+
+ Create.Index("IX_TacticalObjectives_Department_Call".ToLower())
+ .OnTable("TacticalObjectives".ToLower())
+ .OnColumn("DepartmentId".ToLower()).Ascending()
+ .OnColumn("CallId".ToLower()).Ascending();
+ }
+
+ if (!Schema.Table("IncidentTimers".ToLower()).Exists())
+ {
+ Create.Table("IncidentTimers".ToLower())
+ .WithColumn("IncidentTimerId".ToLower()).AsCustom("citext").NotNullable().PrimaryKey()
+ .WithColumn("IncidentCommandId".ToLower()).AsCustom("citext").NotNullable()
+ .WithColumn("DepartmentId".ToLower()).AsInt32().NotNullable()
+ .WithColumn("CallId".ToLower()).AsInt32().NotNullable()
+ .WithColumn("TimerType".ToLower()).AsInt32().NotNullable().WithDefaultValue(0)
+ .WithColumn("ScopeType".ToLower()).AsInt32().NotNullable().WithDefaultValue(0)
+ .WithColumn("ScopeId".ToLower()).AsCustom("citext").Nullable()
+ .WithColumn("Name".ToLower()).AsCustom("citext").Nullable()
+ .WithColumn("IntervalSeconds".ToLower()).AsInt32().NotNullable().WithDefaultValue(0)
+ .WithColumn("StartedOn".ToLower()).AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime)
+ .WithColumn("NextDueOn".ToLower()).AsDateTime2().Nullable()
+ .WithColumn("Status".ToLower()).AsInt32().NotNullable().WithDefaultValue(0)
+ .WithColumn("AcknowledgedOn".ToLower()).AsDateTime2().Nullable();
+
+ Create.Index("IX_IncidentTimers_Department_Call".ToLower())
+ .OnTable("IncidentTimers".ToLower())
+ .OnColumn("DepartmentId".ToLower()).Ascending()
+ .OnColumn("CallId".ToLower()).Ascending();
+ }
+
+ if (!Schema.Table("IncidentMapAnnotations".ToLower()).Exists())
+ {
+ Create.Table("IncidentMapAnnotations".ToLower())
+ .WithColumn("IncidentMapAnnotationId".ToLower()).AsCustom("citext").NotNullable().PrimaryKey()
+ .WithColumn("IncidentCommandId".ToLower()).AsCustom("citext").NotNullable()
+ .WithColumn("DepartmentId".ToLower()).AsInt32().NotNullable()
+ .WithColumn("CallId".ToLower()).AsInt32().NotNullable()
+ .WithColumn("AnnotationType".ToLower()).AsInt32().NotNullable().WithDefaultValue(0)
+ .WithColumn("GeoJson".ToLower()).AsCustom("text").Nullable()
+ .WithColumn("IcsSymbolCode".ToLower()).AsCustom("citext").Nullable()
+ .WithColumn("Label".ToLower()).AsCustom("citext").Nullable()
+ .WithColumn("CreatedByUserId".ToLower()).AsCustom("citext").Nullable()
+ .WithColumn("CreatedOn".ToLower()).AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime)
+ .WithColumn("DeletedOn".ToLower()).AsDateTime2().Nullable();
+
+ Create.Index("IX_IncidentMapAnnotations_Department_Call".ToLower())
+ .OnTable("IncidentMapAnnotations".ToLower())
+ .OnColumn("DepartmentId".ToLower()).Ascending()
+ .OnColumn("CallId".ToLower()).Ascending();
+ }
+
+ if (!Schema.Table("CommandLogEntries".ToLower()).Exists())
+ {
+ Create.Table("CommandLogEntries".ToLower())
+ .WithColumn("CommandLogEntryId".ToLower()).AsCustom("citext").NotNullable().PrimaryKey()
+ .WithColumn("IncidentCommandId".ToLower()).AsCustom("citext").NotNullable()
+ .WithColumn("DepartmentId".ToLower()).AsInt32().NotNullable()
+ .WithColumn("CallId".ToLower()).AsInt32().NotNullable()
+ .WithColumn("EntryType".ToLower()).AsInt32().NotNullable().WithDefaultValue(0)
+ .WithColumn("Description".ToLower()).AsCustom("text").Nullable()
+ .WithColumn("UserId".ToLower()).AsCustom("citext").Nullable()
+ .WithColumn("Latitude".ToLower()).AsCustom("citext").Nullable()
+ .WithColumn("Longitude".ToLower()).AsCustom("citext").Nullable()
+ .WithColumn("OccurredOn".ToLower()).AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime);
+
+ Create.Index("IX_CommandLogEntries_Department_Call".ToLower())
+ .OnTable("CommandLogEntries".ToLower())
+ .OnColumn("DepartmentId".ToLower()).Ascending()
+ .OnColumn("CallId".ToLower()).Ascending();
+ }
+
+ if (!Schema.Table("CommandTransfers".ToLower()).Exists())
+ {
+ Create.Table("CommandTransfers".ToLower())
+ .WithColumn("CommandTransferId".ToLower()).AsCustom("citext").NotNullable().PrimaryKey()
+ .WithColumn("IncidentCommandId".ToLower()).AsCustom("citext").NotNullable()
+ .WithColumn("DepartmentId".ToLower()).AsInt32().NotNullable()
+ .WithColumn("CallId".ToLower()).AsInt32().NotNullable()
+ .WithColumn("FromUserId".ToLower()).AsCustom("citext").Nullable()
+ .WithColumn("ToUserId".ToLower()).AsCustom("citext").Nullable()
+ .WithColumn("TransferredOn".ToLower()).AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime)
+ .WithColumn("Notes".ToLower()).AsCustom("text").Nullable();
+
+ Create.Index("IX_CommandTransfers_Department_Call".ToLower())
+ .OnTable("CommandTransfers".ToLower())
+ .OnColumn("DepartmentId".ToLower()).Ascending()
+ .OnColumn("CallId".ToLower()).Ascending();
+ }
+ }
+
+ public override void Down()
+ {
+ // Explicit drop (the table drop below would also remove it, but be explicit to mirror the codebase pattern).
+ Execute.Sql("DROP INDEX IF EXISTS ux_incidentcommands_department_call_active;");
+
+ Delete.Table("CommandTransfers".ToLower());
+ Delete.Table("CommandLogEntries".ToLower());
+ Delete.Table("IncidentMapAnnotations".ToLower());
+ Delete.Table("IncidentTimers".ToLower());
+ Delete.Table("TacticalObjectives".ToLower());
+ Delete.Table("ResourceAssignments".ToLower());
+ Delete.Table("CommandStructureNodes".ToLower());
+ Delete.Table("IncidentCommands".ToLower());
+ }
+ }
+}
diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0078_AddIncidentScopeToVoiceChannelsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0078_AddIncidentScopeToVoiceChannelsPg.cs
new file mode 100644
index 000000000..a51aa8dd9
--- /dev/null
+++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0078_AddIncidentScopeToVoiceChannelsPg.cs
@@ -0,0 +1,36 @@
+using FluentMigrator;
+
+namespace Resgrid.Providers.MigrationsPg.Migrations
+{
+ [Migration(78)]
+ public class M0078_AddIncidentScopeToVoiceChannelsPg : Migration
+ {
+ public override void Up()
+ {
+ if (!Schema.Table("departmentvoicechannels").Column("callid").Exists())
+ {
+ Alter.Table("departmentvoicechannels")
+ .AddColumn("callid").AsInt32().Nullable();
+ }
+
+ if (!Schema.Table("departmentvoicechannels").Column("isondemand").Exists())
+ {
+ Alter.Table("departmentvoicechannels")
+ .AddColumn("isondemand").AsBoolean().NotNullable().WithDefaultValue(false);
+ }
+
+ if (!Schema.Table("departmentvoicechannels").Column("closedon").Exists())
+ {
+ Alter.Table("departmentvoicechannels")
+ .AddColumn("closedon").AsDateTime2().Nullable();
+ }
+ }
+
+ public override void Down()
+ {
+ Delete.Column("callid").FromTable("departmentvoicechannels");
+ Delete.Column("isondemand").FromTable("departmentvoicechannels");
+ Delete.Column("closedon").FromTable("departmentvoicechannels");
+ }
+ }
+}
diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0079_AddIncidentAdHocResourceTablesPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0079_AddIncidentAdHocResourceTablesPg.cs
new file mode 100644
index 000000000..9e9da9026
--- /dev/null
+++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0079_AddIncidentAdHocResourceTablesPg.cs
@@ -0,0 +1,59 @@
+using FluentMigrator;
+
+namespace Resgrid.Providers.MigrationsPg.Migrations
+{
+ [Migration(79)]
+ public class M0079_AddIncidentAdHocResourceTablesPg : Migration
+ {
+ public override void Up()
+ {
+ if (!Schema.Table("IncidentAdHocUnits".ToLower()).Exists())
+ {
+ Create.Table("IncidentAdHocUnits".ToLower())
+ .WithColumn("IncidentAdHocUnitId".ToLower()).AsCustom("citext").NotNullable().PrimaryKey()
+ .WithColumn("DepartmentId".ToLower()).AsInt32().NotNullable()
+ .WithColumn("CallId".ToLower()).AsInt32().NotNullable()
+ .WithColumn("Name".ToLower()).AsCustom("citext").Nullable()
+ .WithColumn("UnitTypeId".ToLower()).AsInt32().Nullable()
+ .WithColumn("Type".ToLower()).AsCustom("citext").Nullable()
+ .WithColumn("ExternalAgencyName".ToLower()).AsCustom("citext").Nullable()
+ .WithColumn("CreatedByUserId".ToLower()).AsCustom("citext").Nullable()
+ .WithColumn("CreatedOn".ToLower()).AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime)
+ .WithColumn("ReleasedOn".ToLower()).AsDateTime2().Nullable();
+
+ Create.Index("IX_IncidentAdHocUnits_Department_Call".ToLower())
+ .OnTable("IncidentAdHocUnits".ToLower())
+ .OnColumn("DepartmentId".ToLower()).Ascending()
+ .OnColumn("CallId".ToLower()).Ascending();
+ }
+
+ if (!Schema.Table("IncidentAdHocPersonnel".ToLower()).Exists())
+ {
+ Create.Table("IncidentAdHocPersonnel".ToLower())
+ .WithColumn("IncidentAdHocPersonnelId".ToLower()).AsCustom("citext").NotNullable().PrimaryKey()
+ .WithColumn("DepartmentId".ToLower()).AsInt32().NotNullable()
+ .WithColumn("CallId".ToLower()).AsInt32().NotNullable()
+ .WithColumn("Name".ToLower()).AsCustom("citext").Nullable()
+ .WithColumn("Role".ToLower()).AsCustom("citext").Nullable()
+ .WithColumn("ExternalAgencyName".ToLower()).AsCustom("citext").Nullable()
+ .WithColumn("Contact".ToLower()).AsCustom("citext").Nullable()
+ .WithColumn("RidingResourceKind".ToLower()).AsInt32().NotNullable().WithDefaultValue(0)
+ .WithColumn("RidingResourceId".ToLower()).AsCustom("citext").Nullable()
+ .WithColumn("CreatedByUserId".ToLower()).AsCustom("citext").Nullable()
+ .WithColumn("CreatedOn".ToLower()).AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime)
+ .WithColumn("ReleasedOn".ToLower()).AsDateTime2().Nullable();
+
+ Create.Index("IX_IncidentAdHocPersonnel_Department_Call".ToLower())
+ .OnTable("IncidentAdHocPersonnel".ToLower())
+ .OnColumn("DepartmentId".ToLower()).Ascending()
+ .OnColumn("CallId".ToLower()).Ascending();
+ }
+ }
+
+ public override void Down()
+ {
+ Delete.Table("IncidentAdHocPersonnel".ToLower());
+ Delete.Table("IncidentAdHocUnits".ToLower());
+ }
+ }
+}
diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0080_AddIncidentRoleAssignmentTablePg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0080_AddIncidentRoleAssignmentTablePg.cs
new file mode 100644
index 000000000..c3e9ea8b3
--- /dev/null
+++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0080_AddIncidentRoleAssignmentTablePg.cs
@@ -0,0 +1,36 @@
+using FluentMigrator;
+
+namespace Resgrid.Providers.MigrationsPg.Migrations
+{
+ [Migration(80)]
+ public class M0080_AddIncidentRoleAssignmentTablePg : Migration
+ {
+ public override void Up()
+ {
+ if (!Schema.Table("IncidentRoleAssignments".ToLower()).Exists())
+ {
+ Create.Table("IncidentRoleAssignments".ToLower())
+ .WithColumn("IncidentRoleAssignmentId".ToLower()).AsCustom("citext").NotNullable().PrimaryKey()
+ .WithColumn("IncidentCommandId".ToLower()).AsCustom("citext").NotNullable()
+ .WithColumn("DepartmentId".ToLower()).AsInt32().NotNullable()
+ .WithColumn("CallId".ToLower()).AsInt32().NotNullable()
+ .WithColumn("UserId".ToLower()).AsCustom("citext").Nullable()
+ .WithColumn("RoleType".ToLower()).AsInt32().NotNullable().WithDefaultValue(0)
+ .WithColumn("ScopeNodeId".ToLower()).AsCustom("citext").Nullable()
+ .WithColumn("AssignedByUserId".ToLower()).AsCustom("citext").Nullable()
+ .WithColumn("AssignedOn".ToLower()).AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime)
+ .WithColumn("RemovedOn".ToLower()).AsDateTime2().Nullable();
+
+ Create.Index("IX_IncidentRoleAssignments_Department_Call".ToLower())
+ .OnTable("IncidentRoleAssignments".ToLower())
+ .OnColumn("DepartmentId".ToLower()).Ascending()
+ .OnColumn("CallId".ToLower()).Ascending();
+ }
+ }
+
+ public override void Down()
+ {
+ Delete.Table("IncidentRoleAssignments".ToLower());
+ }
+ }
+}
diff --git a/Repositories/Resgrid.Repositories.DataRepository/ClaimsRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/ClaimsRepository.cs
index 4dab9e235..607a104f5 100644
--- a/Repositories/Resgrid.Repositories.DataRepository/ClaimsRepository.cs
+++ b/Repositories/Resgrid.Repositories.DataRepository/ClaimsRepository.cs
@@ -1,7 +1,7 @@
using System.Collections.Generic;
using System.Configuration;
using System.Data;
-using System.Data.SqlClient;
+using Microsoft.Data.SqlClient;
using System.Linq;
using System.Threading.Tasks;
using Dapper;
diff --git a/Repositories/Resgrid.Repositories.DataRepository/DeleteRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/DeleteRepository.cs
index b0983d7ca..b4e5dca1c 100644
--- a/Repositories/Resgrid.Repositories.DataRepository/DeleteRepository.cs
+++ b/Repositories/Resgrid.Repositories.DataRepository/DeleteRepository.cs
@@ -1,7 +1,7 @@
using System;
using System.Configuration;
using System.Data;
-using System.Data.SqlClient;
+using Microsoft.Data.SqlClient;
using System.Linq;
using System.Collections.Generic;
using System.Threading.Tasks;
diff --git a/Repositories/Resgrid.Repositories.DataRepository/DepartmentGroupsRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/DepartmentGroupsRepository.cs
index 1990886c3..f424e4e0f 100644
--- a/Repositories/Resgrid.Repositories.DataRepository/DepartmentGroupsRepository.cs
+++ b/Repositories/Resgrid.Repositories.DataRepository/DepartmentGroupsRepository.cs
@@ -3,7 +3,7 @@
using System.Configuration;
using System.Data;
using System.Data.Common;
-using System.Data.SqlClient;
+using Microsoft.Data.SqlClient;
using System.Linq;
using System.Threading.Tasks;
using Dapper;
diff --git a/Repositories/Resgrid.Repositories.DataRepository/HealthRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/HealthRepository.cs
index 32d43cea5..1df6a6592 100644
--- a/Repositories/Resgrid.Repositories.DataRepository/HealthRepository.cs
+++ b/Repositories/Resgrid.Repositories.DataRepository/HealthRepository.cs
@@ -1,6 +1,6 @@
using System;
using System.Data;
-using System.Data.SqlClient;
+using Microsoft.Data.SqlClient;
using System.Linq;
using System.Threading.Tasks;
using Dapper;
diff --git a/Repositories/Resgrid.Repositories.DataRepository/IdentityRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/IdentityRepository.cs
index a85a86206..e7ef1e883 100644
--- a/Repositories/Resgrid.Repositories.DataRepository/IdentityRepository.cs
+++ b/Repositories/Resgrid.Repositories.DataRepository/IdentityRepository.cs
@@ -1,7 +1,7 @@
using System;
using System.Configuration;
using System.Data;
-using System.Data.SqlClient;
+using Microsoft.Data.SqlClient;
using System.Linq;
using System.Collections.Generic;
using System.Threading.Tasks;
diff --git a/Repositories/Resgrid.Repositories.DataRepository/IncidentAdHocResourceRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/IncidentAdHocResourceRepositories.cs
new file mode 100644
index 000000000..19fa7f4e3
--- /dev/null
+++ b/Repositories/Resgrid.Repositories.DataRepository/IncidentAdHocResourceRepositories.cs
@@ -0,0 +1,24 @@
+using Resgrid.Model;
+using Resgrid.Model.Repositories;
+using Resgrid.Model.Repositories.Connection;
+using Resgrid.Model.Repositories.Queries;
+using Resgrid.Repositories.DataRepository.Configs;
+
+namespace Resgrid.Repositories.DataRepository
+{
+ public class IncidentAdHocUnitRepository : RepositoryBase, IIncidentAdHocUnitRepository
+ {
+ public IncidentAdHocUnitRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory)
+ : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory)
+ {
+ }
+ }
+
+ public class IncidentAdHocPersonnelRepository : RepositoryBase, IIncidentAdHocPersonnelRepository
+ {
+ public IncidentAdHocPersonnelRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory)
+ : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory)
+ {
+ }
+ }
+}
diff --git a/Repositories/Resgrid.Repositories.DataRepository/IncidentCommandRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/IncidentCommandRepositories.cs
new file mode 100644
index 000000000..5f58c5ff9
--- /dev/null
+++ b/Repositories/Resgrid.Repositories.DataRepository/IncidentCommandRepositories.cs
@@ -0,0 +1,72 @@
+using Resgrid.Model;
+using Resgrid.Model.Repositories;
+using Resgrid.Model.Repositories.Connection;
+using Resgrid.Model.Repositories.Queries;
+using Resgrid.Repositories.DataRepository.Configs;
+
+namespace Resgrid.Repositories.DataRepository
+{
+ public class IncidentCommandRepository : RepositoryBase, IIncidentCommandRepository
+ {
+ public IncidentCommandRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory)
+ : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory)
+ {
+ }
+ }
+
+ public class CommandStructureNodeRepository : RepositoryBase, ICommandStructureNodeRepository
+ {
+ public CommandStructureNodeRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory)
+ : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory)
+ {
+ }
+ }
+
+ public class ResourceAssignmentRepository : RepositoryBase, IResourceAssignmentRepository
+ {
+ public ResourceAssignmentRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory)
+ : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory)
+ {
+ }
+ }
+
+ public class TacticalObjectiveRepository : RepositoryBase, ITacticalObjectiveRepository
+ {
+ public TacticalObjectiveRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory)
+ : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory)
+ {
+ }
+ }
+
+ public class IncidentTimerRepository : RepositoryBase, IIncidentTimerRepository
+ {
+ public IncidentTimerRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory)
+ : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory)
+ {
+ }
+ }
+
+ public class IncidentMapAnnotationRepository : RepositoryBase, IIncidentMapAnnotationRepository
+ {
+ public IncidentMapAnnotationRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory)
+ : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory)
+ {
+ }
+ }
+
+ public class CommandLogEntryRepository : RepositoryBase, ICommandLogEntryRepository
+ {
+ public CommandLogEntryRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory)
+ : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory)
+ {
+ }
+ }
+
+ public class CommandTransferRepository : RepositoryBase, ICommandTransferRepository
+ {
+ public CommandTransferRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory)
+ : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory)
+ {
+ }
+ }
+}
diff --git a/Repositories/Resgrid.Repositories.DataRepository/IncidentRoleAssignmentRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/IncidentRoleAssignmentRepository.cs
new file mode 100644
index 000000000..f268cecac
--- /dev/null
+++ b/Repositories/Resgrid.Repositories.DataRepository/IncidentRoleAssignmentRepository.cs
@@ -0,0 +1,16 @@
+using Resgrid.Model;
+using Resgrid.Model.Repositories;
+using Resgrid.Model.Repositories.Connection;
+using Resgrid.Model.Repositories.Queries;
+using Resgrid.Repositories.DataRepository.Configs;
+
+namespace Resgrid.Repositories.DataRepository
+{
+ public class IncidentRoleAssignmentRepository : RepositoryBase, IIncidentRoleAssignmentRepository
+ {
+ public IncidentRoleAssignmentRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory)
+ : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory)
+ {
+ }
+ }
+}
diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs
index 4c770908c..08eb07c3c 100644
--- a/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs
+++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs
@@ -102,6 +102,17 @@ 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();
+ builder.RegisterType().As().InstancePerLifetimeScope();
+ builder.RegisterType