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().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/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs index b3fce716a..ab5e67b3e 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs @@ -106,6 +106,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().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/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs index e252f5a1b..63ba660e6 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs @@ -101,6 +101,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().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/Repositories/Resgrid.Repositories.DataRepository/OidcRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/OidcRepository.cs index fa3cb7b8e..0559b2eb3 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/OidcRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/OidcRepository.cs @@ -1,5 +1,5 @@ using System.Data; -using System.Data.SqlClient; +using Microsoft.Data.SqlClient; using Dapper; using Resgrid.Model.Repositories; using Resgrid.Config; diff --git a/Repositories/Resgrid.Repositories.DataRepository/PushUriRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/PushUriRepository.cs index 951f383ec..eba19f9cb 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/PushUriRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/PushUriRepository.cs @@ -1,6 +1,6 @@ using System; using System.Collections.Generic; -using System.Data.SqlClient; +using Microsoft.Data.SqlClient; using System.Linq; using Resgrid.Model; using Resgrid.Model.Repositories; diff --git a/Repositories/Resgrid.Repositories.DataRepository/Resgrid.Repositories.DataRepository.csproj b/Repositories/Resgrid.Repositories.DataRepository/Resgrid.Repositories.DataRepository.csproj index 992c02512..11506915d 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Resgrid.Repositories.DataRepository.csproj +++ b/Repositories/Resgrid.Repositories.DataRepository/Resgrid.Repositories.DataRepository.csproj @@ -9,7 +9,7 @@ - + diff --git a/Repositories/Resgrid.Repositories.DataRepository/ScheduledTaskLogsRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/ScheduledTaskLogsRepository.cs index c58d4df1f..1cab19e32 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/ScheduledTaskLogsRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/ScheduledTaskLogsRepository.cs @@ -4,7 +4,7 @@ using System; 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/ScheduledTasksRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/ScheduledTasksRepository.cs index c6b4fa3d1..f22d2fc7c 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/ScheduledTasksRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/ScheduledTasksRepository.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; using System.Data; -using System.Data.SqlClient; +using Microsoft.Data.SqlClient; using System.Linq; using Resgrid.Model; using Resgrid.Model.Repositories; diff --git a/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConnectionProvider.cs b/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConnectionProvider.cs index 0b047ccc7..7b6c13ef3 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConnectionProvider.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConnectionProvider.cs @@ -1,7 +1,7 @@ using Resgrid.Model.Repositories.Connection; using System.Configuration; using System.Data.Common; -using System.Data.SqlClient; +using Microsoft.Data.SqlClient; using System.Linq; namespace Resgrid.Repositories.DataRepository.Servers.SqlServer diff --git a/Tests/Resgrid.Tests/Services/CheckInTimerServiceTests.cs b/Tests/Resgrid.Tests/Services/CheckInTimerServiceTests.cs index a64e707fe..22b9f7bee 100644 --- a/Tests/Resgrid.Tests/Services/CheckInTimerServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/CheckInTimerServiceTests.cs @@ -22,6 +22,7 @@ public class CheckInTimerServiceTests private Mock _actionLogsService; private Mock _unitsService; private Mock _callsService; + private Mock _coreEventService; private CheckInTimerService _service; [SetUp] @@ -33,8 +34,9 @@ public void SetUp() _actionLogsService = new Mock(); _unitsService = new Mock(); _callsService = new Mock(); + _coreEventService = new Mock(); _service = new CheckInTimerService(_configRepo.Object, _overrideRepo.Object, _recordRepo.Object, - _actionLogsService.Object, _unitsService.Object, _callsService.Object); + _actionLogsService.Object, _unitsService.Object, _callsService.Object, _coreEventService.Object); } #region Timer Resolution diff --git a/Tests/Resgrid.Tests/Services/IncidentCommandServiceParTests.cs b/Tests/Resgrid.Tests/Services/IncidentCommandServiceParTests.cs new file mode 100644 index 000000000..b859a0e9d --- /dev/null +++ b/Tests/Resgrid.Tests/Services/IncidentCommandServiceParTests.cs @@ -0,0 +1,334 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Events; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + /// + /// Covers the PAR (personnel accountability) firing point: + /// raises once per member each time they transition into Critical, deduped + /// via a timeline marker, and re-fires after a fresh check-in. + /// + [TestFixture] + public class IncidentCommandServiceParTests + { + private const int Dept = 10; + private const int CallId = 1; + + private Mock _commandRepo; + private Mock _nodeRepo; + private Mock _assignmentRepo; + private Mock _objectiveRepo; + private Mock _timerRepo; + private Mock _annotationRepo; + private Mock _logRepo; + private Mock _transferRepo; + private Mock _commandsService; + private Mock _callsService; + private Mock _checkInTimerService; + private Mock _voiceService; + private Mock _roleRepo; + private Mock _eventAggregator; + private Mock _coreEventService; + private IncidentCommandService _service; + + [SetUp] + public void SetUp() + { + _commandRepo = new Mock(); + _nodeRepo = new Mock(); + _assignmentRepo = new Mock(); + _objectiveRepo = new Mock(); + _timerRepo = new Mock(); + _annotationRepo = new Mock(); + _logRepo = new Mock(); + _transferRepo = new Mock(); + _commandsService = new Mock(); + _callsService = new Mock(); + _checkInTimerService = new Mock(); + _voiceService = new Mock(); + _roleRepo = new Mock(); + _eventAggregator = new Mock(); + _coreEventService = new Mock(); + + // The marker write echoes back the entry so WriteLogAsync resolves a non-null result. + _logRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((CommandLogEntry e, CancellationToken ct, bool b) => e); + + _service = new IncidentCommandService(_commandRepo.Object, _nodeRepo.Object, _assignmentRepo.Object, + _objectiveRepo.Object, _timerRepo.Object, _annotationRepo.Object, _logRepo.Object, _transferRepo.Object, + _commandsService.Object, _callsService.Object, _checkInTimerService.Object, _voiceService.Object, + _roleRepo.Object, _eventAggregator.Object, _coreEventService.Object); + } + + private void ArrangeCall(bool checkInTimersEnabled = true, int departmentId = Dept) + { + _callsService.Setup(x => x.GetCallByIdAsync(CallId, It.IsAny())) + .ReturnsAsync(new Call { CallId = CallId, DepartmentId = departmentId, CheckInTimersEnabled = checkInTimersEnabled, State = (int)CallStates.Active }); + } + + private void ArrangeActiveCommand(DateTime? establishedOn = null) + { + _commandRepo.Setup(x => x.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List + { + new IncidentCommand + { + IncidentCommandId = "ic1", + DepartmentId = Dept, + CallId = CallId, + Status = (int)IncidentCommandStatus.Active, + EstablishedOn = establishedOn ?? DateTime.UtcNow.AddMinutes(-30) + } + }); + } + + private void ArrangeStatuses(params PersonnelCallCheckInStatus[] statuses) + { + _checkInTimerService.Setup(x => x.GetCallPersonnelCheckInStatusesAsync(It.IsAny())) + .ReturnsAsync(new List(statuses)); + } + + private void ArrangeTimeline(params CommandLogEntry[] entries) + { + _logRepo.Setup(x => x.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List(entries)); + } + + private static PersonnelCallCheckInStatus Critical(string userId, DateTime? lastCheckIn = null) => new PersonnelCallCheckInStatus + { + UserId = userId, + FullName = "Firefighter " + userId, + Status = "Critical", + NeedsCheckIn = true, + MinutesRemaining = -3, + LastCheckIn = lastCheckIn + }; + + [Test] + public async Task EvaluateCriticalParAsync_RaisesEventAndWritesMarker_OnFirstTransitionToCritical() + { + ArrangeCall(); + ArrangeActiveCommand(); + ArrangeStatuses(Critical("user1")); + ArrangeTimeline(); // no prior markers + + var result = await _service.EvaluateCriticalParAsync(Dept, CallId); + + result.Should().ContainSingle().Which.Should().Be("user1"); + _eventAggregator.Verify(x => x.SendMessage(It.Is( + e => e.UserId == "user1" && e.CallId == CallId && e.DepartmentId == Dept)), Times.Once); + _logRepo.Verify(x => x.SaveOrUpdateAsync( + It.Is(e => e.EntryType == (int)CommandLogEntryType.ParCritical && e.UserId == "user1"), + It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task EvaluateCriticalParAsync_Deduped_WhenMarkerAlreadyExistsForEpisode() + { + ArrangeCall(); + ArrangeActiveCommand(establishedOn: DateTime.UtcNow.AddMinutes(-30)); + ArrangeStatuses(Critical("user1")); // never checked in -> baseline is EstablishedOn + // A marker newer than the baseline means this episode was already alerted. + ArrangeTimeline(new CommandLogEntry + { + EntryType = (int)CommandLogEntryType.ParCritical, + UserId = "user1", + CallId = CallId, + OccurredOn = DateTime.UtcNow.AddMinutes(-5) + }); + + var result = await _service.EvaluateCriticalParAsync(Dept, CallId); + + result.Should().BeEmpty(); + _eventAggregator.Verify(x => x.SendMessage(It.IsAny()), Times.Never); + _logRepo.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task EvaluateCriticalParAsync_Refires_AfterMemberChecksInAgain() + { + ArrangeCall(); + ArrangeActiveCommand(); + // The member checked in 1 min ago (newer than the old marker) and has lapsed Critical again. + ArrangeStatuses(Critical("user1", lastCheckIn: DateTime.UtcNow.AddMinutes(-1))); + ArrangeTimeline(new CommandLogEntry + { + EntryType = (int)CommandLogEntryType.ParCritical, + UserId = "user1", + CallId = CallId, + OccurredOn = DateTime.UtcNow.AddMinutes(-10) // older than the latest check-in + }); + + var result = await _service.EvaluateCriticalParAsync(Dept, CallId); + + result.Should().ContainSingle().Which.Should().Be("user1"); + _eventAggregator.Verify(x => x.SendMessage(It.IsAny()), Times.Once); + } + + [Test] + public async Task EvaluateCriticalParAsync_NoEvent_WhenNobodyIsCritical() + { + ArrangeCall(); + ArrangeActiveCommand(); + ArrangeStatuses(new PersonnelCallCheckInStatus { UserId = "user1", Status = "Green", MinutesRemaining = 12 }); + ArrangeTimeline(); + + var result = await _service.EvaluateCriticalParAsync(Dept, CallId); + + result.Should().BeEmpty(); + _eventAggregator.Verify(x => x.SendMessage(It.IsAny()), Times.Never); + } + + [Test] + public async Task EvaluateCriticalParAsync_OnlyFlagsCriticalMembers_WhenMixed() + { + ArrangeCall(); + ArrangeActiveCommand(); + ArrangeStatuses( + Critical("user1"), + new PersonnelCallCheckInStatus { UserId = "user2", Status = "Warning", MinutesRemaining = 2 }, + Critical("user3")); + ArrangeTimeline(); + + var result = await _service.EvaluateCriticalParAsync(Dept, CallId); + + result.Should().BeEquivalentTo(new[] { "user1", "user3" }); + _eventAggregator.Verify(x => x.SendMessage(It.IsAny()), Times.Exactly(2)); + } + + [Test] + public async Task EvaluateCriticalParAsync_ReturnsEmpty_WhenNoActiveCommand() + { + ArrangeCall(); + _commandRepo.Setup(x => x.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List()); + ArrangeStatuses(Critical("user1")); + + var result = await _service.EvaluateCriticalParAsync(Dept, CallId); + + result.Should().BeEmpty(); + _eventAggregator.Verify(x => x.SendMessage(It.IsAny()), Times.Never); + } + + [Test] + public async Task EvaluateCriticalParAsync_ReturnsEmpty_WhenCallNotOwnedByDepartment() + { + ArrangeCall(departmentId: 99); // belongs to another department + ArrangeActiveCommand(); + ArrangeStatuses(Critical("user1")); + + var result = await _service.EvaluateCriticalParAsync(Dept, CallId); + + result.Should().BeEmpty(); + _eventAggregator.Verify(x => x.SendMessage(It.IsAny()), Times.Never); + } + + [Test] + public async Task EvaluateCriticalParAsync_ReturnsEmpty_WhenCheckInTimersDisabled() + { + ArrangeCall(checkInTimersEnabled: false); + ArrangeActiveCommand(); + ArrangeStatuses(Critical("user1")); + + var result = await _service.EvaluateCriticalParAsync(Dept, CallId); + + result.Should().BeEmpty(); + _eventAggregator.Verify(x => x.SendMessage(It.IsAny()), Times.Never); + } + + // Child-mutation CallId stamping: the authoritative CallId comes from the parent command, never the caller. + + [Test] + public async Task SaveNodeAsync_StampsCallId_FromParentCommand_NotCallerSupplied() + { + // Parent command 'ic1' lives on CallId; the caller supplies a mismatched CallId (999). + _commandRepo.Setup(x => x.GetByIdAsync("ic1")).ReturnsAsync(new IncidentCommand + { + IncidentCommandId = "ic1", DepartmentId = Dept, CallId = CallId, Status = (int)IncidentCommandStatus.Active + }); + _nodeRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((CommandStructureNode n, CancellationToken ct, bool b) => n); + + var node = new CommandStructureNode { IncidentCommandId = "ic1", DepartmentId = Dept, CallId = 999, Name = "Staging" }; + var saved = await _service.SaveNodeAsync(node, "user1"); + + saved.Should().NotBeNull(); + saved.CallId.Should().Be(CallId); // stamped from the parent command, not the caller-supplied 999 + } + + [Test] + public async Task SaveNodeAsync_ReturnsNull_WhenParentCommandBelongsToAnotherDepartment() + { + _commandRepo.Setup(x => x.GetByIdAsync("ic1")).ReturnsAsync(new IncidentCommand + { + IncidentCommandId = "ic1", DepartmentId = 99, CallId = CallId // another department's command + }); + + var node = new CommandStructureNode { IncidentCommandId = "ic1", DepartmentId = Dept, CallId = CallId, Name = "Staging" }; + var saved = await _service.SaveNodeAsync(node, "user1"); + + saved.Should().BeNull(); + } + + [Test] + public async Task MoveResourceAsync_Moves_WhenTargetNodeOnSameDeptAndCall() + { + _assignmentRepo.Setup(x => x.GetByIdAsync("ra1")).ReturnsAsync(new ResourceAssignment + { + ResourceAssignmentId = "ra1", DepartmentId = Dept, CallId = CallId, IncidentCommandId = "ic1" + }); + _nodeRepo.Setup(x => x.GetByIdAsync("node-1")).ReturnsAsync(new CommandStructureNode + { + CommandStructureNodeId = "node-1", DepartmentId = Dept, CallId = CallId + }); + _assignmentRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((ResourceAssignment a, CancellationToken ct, bool b) => a); + + var result = await _service.MoveResourceAsync(Dept, "ra1", "node-1", "user1"); + + result.Should().NotBeNull(); + result.CommandStructureNodeId.Should().Be("node-1"); + } + + [Test] + public async Task MoveResourceAsync_ReturnsNull_WhenTargetNodeOnDifferentCall() + { + _assignmentRepo.Setup(x => x.GetByIdAsync("ra1")).ReturnsAsync(new ResourceAssignment + { + ResourceAssignmentId = "ra1", DepartmentId = Dept, CallId = CallId, IncidentCommandId = "ic1" + }); + // Same department, but the lane lives on a different call — must be rejected. + _nodeRepo.Setup(x => x.GetByIdAsync("node-other")).ReturnsAsync(new CommandStructureNode + { + CommandStructureNodeId = "node-other", DepartmentId = Dept, CallId = 999 + }); + + var result = await _service.MoveResourceAsync(Dept, "ra1", "node-other", "user1"); + + result.Should().BeNull(); + _assignmentRepo.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task MoveResourceAsync_ReturnsNull_WhenTargetNodeMissing() + { + _assignmentRepo.Setup(x => x.GetByIdAsync("ra1")).ReturnsAsync(new ResourceAssignment + { + ResourceAssignmentId = "ra1", DepartmentId = Dept, CallId = CallId, IncidentCommandId = "ic1" + }); + _nodeRepo.Setup(x => x.GetByIdAsync("ghost")).ReturnsAsync((CommandStructureNode)null); + + var result = await _service.MoveResourceAsync(Dept, "ra1", "ghost", "user1"); + + result.Should().BeNull(); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/IncidentExportTests.cs b/Tests/Resgrid.Tests/Services/IncidentExportTests.cs index 738454dbb..9d94dfa38 100644 --- a/Tests/Resgrid.Tests/Services/IncidentExportTests.cs +++ b/Tests/Resgrid.Tests/Services/IncidentExportTests.cs @@ -1,9 +1,15 @@ using System; +using System.Collections.Generic; using System.Text; +using System.Threading; +using System.Threading.Tasks; using FluentAssertions; +using Moq; using NUnit.Framework; using Resgrid.Model; using Resgrid.Model.Reporting; +using Resgrid.Model.Services; +using Resgrid.Services; using Resgrid.Services.Reporting; namespace Resgrid.Tests.Services @@ -91,5 +97,42 @@ public void BuildCsv_nfirs_emits_full_schema_header_with_empty_gap_cells() csv.Should().Contain("FDID"); csv.Should().Contain("IncidentTypeCode"); } + + private static string TimelineCsvWithDescription(string description) + { + var commandService = new Mock(); + commandService.Setup(x => x.GetTimelineForCallAsync(10, 7)).ReturnsAsync(new List + { + new CommandLogEntry + { + OccurredOn = new DateTime(2026, 6, 1, 12, 0, 0, DateTimeKind.Utc), + EntryType = (int)CommandLogEntryType.Note, + Description = description, + UserId = "user1" + } + }); + var service = new IncidentReportingService(commandService.Object); + return service.ExportTimelineCsvAsync(10, 7).GetAwaiter().GetResult(); + } + + [Test] + public void ExportTimelineCsv_neutralizes_leading_formula_characters() + { + var csv = TimelineCsvWithDescription("@SUM(A1:A9)"); + + // The formula-leading description is neutralized with a single-quote prefix. + csv.Should().Contain("'@SUM(A1:A9)"); + csv.Should().NotContain(",@SUM"); + } + + [Test] + public void ExportTimelineCsv_exempts_plain_negative_numbers() + { + var csv = TimelineCsvWithDescription("-42.5"); + + // Plain negative numbers are NOT neutralized (coordinates/numbers survive intact). + csv.Should().Contain(",-42.5"); + csv.Should().NotContain("'-42.5"); + } } } diff --git a/Tests/Resgrid.Tests/Services/IncidentVoiceServiceTests.cs b/Tests/Resgrid.Tests/Services/IncidentVoiceServiceTests.cs new file mode 100644 index 000000000..46756504a --- /dev/null +++ b/Tests/Resgrid.Tests/Services/IncidentVoiceServiceTests.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class IncidentVoiceServiceTests + { + [Test] + public async Task CloseIncidentChannelsForCallAsync_WritesChannelClosedLog_EvenWhenCommandAlreadyClosed() + { + var voiceService = new Mock(); + var departmentsService = new Mock(); + var logRepo = new Mock(); + var commandRepo = new Mock(); + var eventAggregator = new Mock(); + var coreEventService = new Mock(); + + // One open on-demand channel on call 7. + voiceService.Setup(x => x.GetVoiceSettingsForDepartmentAsync(10)).ReturnsAsync(new DepartmentVoice + { + Channels = new List + { + new DepartmentVoiceChannel { DepartmentVoiceChannelId = "ch1", IsOnDemand = true, CallId = 7, ClosedOn = null } + } + }); + voiceService.Setup(x => x.SaveOrUpdateVoiceChannelAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((DepartmentVoiceChannel c, int d, CancellationToken ct) => c); + + // The parent command is already CLOSED — the close-command flow closes it before auto-closing channels. + commandRepo.Setup(x => x.GetAllByDepartmentIdAsync(10)).ReturnsAsync(new List + { + new IncidentCommand + { + IncidentCommandId = "ic1", DepartmentId = 10, CallId = 7, + Status = (int)IncidentCommandStatus.Closed, EstablishedOn = DateTime.UtcNow.AddHours(-1) + } + }); + logRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((CommandLogEntry e, CancellationToken ct, bool b) => e); + + var service = new IncidentVoiceService(voiceService.Object, departmentsService.Object, logRepo.Object, + commandRepo.Object, eventAggregator.Object, coreEventService.Object); + + var result = await service.CloseIncidentChannelsForCallAsync(10, 7, "user1"); + + result.Should().BeTrue(); + // The channel-closed entry is logged against the (now Closed) command, not silently dropped. + logRepo.Verify(x => x.SaveOrUpdateAsync( + It.Is(e => e.EntryType == (int)CommandLogEntryType.ChannelClosed && e.IncidentCommandId == "ic1"), + It.IsAny(), It.IsAny()), Times.Once); + coreEventService.Verify(x => x.IncidentCommandUpdatedAsync(10, 7), Times.Once); + } + } +} diff --git a/Tools/Resgrid.Console/Commands/DbUpdateCommand.cs b/Tools/Resgrid.Console/Commands/DbUpdateCommand.cs index 7c4c0dbce..8bcf93d91 100644 --- a/Tools/Resgrid.Console/Commands/DbUpdateCommand.cs +++ b/Tools/Resgrid.Console/Commands/DbUpdateCommand.cs @@ -1,6 +1,6 @@ using Resgrid.Console.Args; using System; -using System.Data.SqlClient; +using Microsoft.Data.SqlClient; using System.IO; using System.Text; using System.Threading; diff --git a/Tools/Resgrid.Console/Commands/OidcUpdateCommand.cs b/Tools/Resgrid.Console/Commands/OidcUpdateCommand.cs index 96a07fe6b..f8c17a3b9 100644 --- a/Tools/Resgrid.Console/Commands/OidcUpdateCommand.cs +++ b/Tools/Resgrid.Console/Commands/OidcUpdateCommand.cs @@ -4,7 +4,7 @@ using Resgrid.Workers.Framework; using Resgrid.Model.Repositories; using Autofac; -using System.Data.SqlClient; +using Microsoft.Data.SqlClient; using System.IO; using System.Text; using System.Threading; diff --git a/Tools/Resgrid.Console/Resgrid.Console.csproj b/Tools/Resgrid.Console/Resgrid.Console.csproj index 67f17683c..de4898322 100644 --- a/Tools/Resgrid.Console/Resgrid.Console.csproj +++ b/Tools/Resgrid.Console/Resgrid.Console.csproj @@ -30,7 +30,7 @@ - + diff --git a/Web/Resgrid.Web.Services/Controllers/v4/CommandsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/CommandsController.cs new file mode 100644 index 000000000..0e1d47a34 --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/CommandsController.cs @@ -0,0 +1,253 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Services.Helpers; +using Resgrid.Web.Services.Models.v4.Commands; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// + /// Command definitions: predefined incident-command templates (swimlanes) per call type, used to + /// seed the runtime command board. House Fire vs Vehicle Incident, etc. + /// + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + public class CommandsController : V4AuthenticatedApiControllerbase + { + #region Members and Constructors + private readonly ICommandsService _commandsService; + + public CommandsController(ICommandsService commandsService) + { + _commandsService = commandsService; + } + #endregion Members and Constructors + + /// + /// Gets all command definitions for the department. + /// + [HttpGet("GetAllCommands")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_View)] + public async Task> GetAllCommands() + { + var result = new CommandsResult(); + + var commands = await _commandsService.GetAllCommandsForDepartmentAsync(DepartmentId); + + if (commands != null && commands.Any()) + { + foreach (var command in commands) + result.Data.Add(ConvertCommandData(command)); + + result.PageSize = result.Data.Count; + result.Status = ResponseHelper.Success; + } + else + { + result.PageSize = 0; + result.Status = ResponseHelper.NotFound; + } + + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// + /// Gets a single command definition by identifier. + /// + [HttpGet("GetCommand/{commandDefinitionId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_View)] + public async Task> GetCommand(int commandDefinitionId) + { + var result = new CommandResult(); + + var command = await _commandsService.GetCommandByIdAsync(commandDefinitionId); + + if (command == null || command.DepartmentId != DepartmentId) + { + result.Status = ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + result.Data = ConvertCommandData(command); + result.PageSize = 1; + result.Status = ResponseHelper.Success; + + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// + /// Resolves the command definition (template) for a call type, falling back to the + /// "Any Call Type" definition. Pass 0 to request the "Any Call Type" template directly. + /// + [HttpGet("GetCommandForCallType/{callTypeId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_View)] + public async Task> GetCommandForCallType(int callTypeId) + { + var result = new CommandResult(); + + int? typeId = callTypeId > 0 ? callTypeId : (int?)null; + var command = await _commandsService.GetCommandForCallTypeAsync(DepartmentId, typeId); + + if (command == null) + { + result.Status = ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + result.Data = ConvertCommandData(command); + result.PageSize = 1; + result.Status = ResponseHelper.Success; + + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// + /// Creates or updates a command definition (including its lanes). + /// + [HttpPost("SaveCommand")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_Create)] + public async Task> SaveCommand([FromBody] SaveCommandInput input) + { + var result = new CommandResult(); + + if (input == null || string.IsNullOrWhiteSpace(input.Name)) + return BadRequest(); + + CommandDefinition command; + + if (input.CommandDefinitionId.HasValue && input.CommandDefinitionId.Value > 0) + { + command = await _commandsService.GetCommandByIdAsync(input.CommandDefinitionId.Value); + + if (command == null || command.DepartmentId != DepartmentId) + { + result.Status = ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + } + else + { + command = new CommandDefinition { DepartmentId = DepartmentId }; + } + + command.Name = input.Name; + command.Description = input.Description; + command.CallTypeId = input.CallTypeId; + command.Timer = input.Timer; + command.TimerMinutes = input.TimerMinutes; + + command.Assignments = new List(); + if (input.Lanes != null) + { + foreach (var lane in input.Lanes) + { + command.Assignments.Add(new CommandDefinitionRole + { + CommandDefinitionRoleId = lane.CommandDefinitionRoleId ?? 0, + Name = lane.Name, + Description = lane.Description, + LaneType = lane.LaneType, + SortOrder = lane.SortOrder, + MinUnitPersonnel = lane.MinUnitPersonnel, + MaxUnitPersonnel = lane.MaxUnitPersonnel, + MaxUnits = lane.MaxUnits, + MinTimeInRole = lane.MinTimeInRole, + MaxTimeInRole = lane.MaxTimeInRole, + ForceRequirements = lane.ForceRequirements + }); + } + } + + var saved = await _commandsService.Save(command, CancellationToken.None); + + result.Data = ConvertCommandData(saved); + result.PageSize = 1; + result.Status = ResponseHelper.Success; + + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// + /// Deletes a command definition. + /// + [HttpDelete("DeleteCommand/{commandDefinitionId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_Delete)] + public async Task> DeleteCommand(int commandDefinitionId) + { + var result = new CommandResult(); + + var command = await _commandsService.GetCommandByIdAsync(commandDefinitionId); + + if (command == null || command.DepartmentId != DepartmentId) + { + result.Status = ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + await _commandsService.DeleteAsync(command, CancellationToken.None); + + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + #region Private Helpers + private static CommandResultData ConvertCommandData(CommandDefinition command) + { + var data = new CommandResultData + { + CommandDefinitionId = command.CommandDefinitionId, + CallTypeId = command.CallTypeId, + Name = command.Name, + Description = command.Description, + Timer = command.Timer, + TimerMinutes = command.TimerMinutes + }; + + if (command.Assignments != null) + { + foreach (var lane in command.Assignments.OrderBy(x => x.SortOrder)) + { + data.Lanes.Add(new CommandRoleResultData + { + CommandDefinitionRoleId = lane.CommandDefinitionRoleId, + Name = lane.Name, + Description = lane.Description, + LaneType = lane.LaneType, + SortOrder = lane.SortOrder, + MinUnitPersonnel = lane.MinUnitPersonnel, + MaxUnitPersonnel = lane.MaxUnitPersonnel, + MaxUnits = lane.MaxUnits, + MinTimeInRole = lane.MinTimeInRole, + MaxTimeInRole = lane.MaxTimeInRole, + ForceRequirements = lane.ForceRequirements + }); + } + } + + return data; + } + #endregion Private Helpers + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/IncidentCommandController.cs b/Web/Resgrid.Web.Services/Controllers/v4/IncidentCommandController.cs new file mode 100644 index 000000000..20e2a871b --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/IncidentCommandController.cs @@ -0,0 +1,483 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Services.Helpers; +using System.Threading; +using System.Threading.Tasks; +using ICModels = Resgrid.Web.Services.Models.v4.IncidentCommand; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// + /// Live incident command: establish/transfer/close command, edit the command structure (lanes), assign + /// resources, manage objectives, timers, map annotations, and read the action timeline for a Call. + /// + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + public class IncidentCommandController : V4AuthenticatedApiControllerbase + { + #region Members and Constructors + private readonly IIncidentCommandService _incidentCommandService; + + public IncidentCommandController(IIncidentCommandService incidentCommandService) + { + _incidentCommandService = incidentCommandService; + } + #endregion Members and Constructors + + #region Command lifecycle + + /// Establishes command on a call, optionally seeding lanes from a command definition. + [HttpPost("EstablishCommand")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_Create)] + public async Task> EstablishCommand([FromBody] ICModels.EstablishCommandInput input) + { + if (input == null || input.CallId <= 0) + return BadRequest(); + + var result = new ICModels.IncidentCommandResult(); + var command = await _incidentCommandService.EstablishCommandAsync(DepartmentId, input.CallId, UserId, input.CommandDefinitionId, CancellationToken.None); + + if (command == null) + { + // Call not found / not owned by the caller's department. + result.Status = ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + result.Data = command; + result.PageSize = 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// Gets the full live command board snapshot for a call. + [HttpGet("GetCommandBoard/{callId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_View)] + public async Task> GetCommandBoard(int callId) + { + var result = new ICModels.IncidentCommandBoardResult(); + var board = await _incidentCommandService.GetCommandBoardAsync(DepartmentId, callId); + + if (board == null) + { + result.Status = ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + result.Data = board; + result.PageSize = 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// Transfers command to another user. + [HttpPost("TransferCommand")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_Update)] + public async Task> TransferCommand([FromBody] ICModels.TransferCommandInput input) + { + if (input == null || string.IsNullOrWhiteSpace(input.IncidentCommandId) || string.IsNullOrWhiteSpace(input.ToUserId)) + return BadRequest(); + + var result = new ICModels.CommandTransferResult(); + var transfer = await _incidentCommandService.TransferCommandAsync(DepartmentId, input.IncidentCommandId, UserId, input.ToUserId, input.Notes, CancellationToken.None); + + if (transfer == null) + { + result.Status = ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + result.Data = transfer; + result.PageSize = 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// Closes command on an incident. + [HttpPut("CloseCommand/{incidentCommandId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_Update)] + public async Task> CloseCommand(string incidentCommandId) + { + var result = new ICModels.IncidentCommandResult(); + var command = await _incidentCommandService.CloseCommandAsync(DepartmentId, incidentCommandId, UserId, CancellationToken.None); + + if (command == null) + { + result.Status = ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + result.Data = command; + result.PageSize = 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// Updates the incident action plan. + [HttpPut("UpdateActionPlan")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_Update)] + public async Task> UpdateActionPlan([FromBody] ICModels.UpdateActionPlanInput input) + { + if (input == null || string.IsNullOrWhiteSpace(input.IncidentCommandId)) + return BadRequest(); + + var result = new ICModels.IncidentCommandResult(); + var command = await _incidentCommandService.UpdateActionPlanAsync(DepartmentId, input.IncidentCommandId, input.ActionPlan, UserId, CancellationToken.None); + + if (command == null) + { + result.Status = ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + result.Data = command; + result.PageSize = 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// Gets the personnel accountability / PAR status (Green/Warning/Critical) for the incident. + [HttpGet("GetAccountability/{callId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_View)] + public async Task> GetAccountability(int callId) + { + var result = new ICModels.CommandAccountabilityResult(); + result.Data = await _incidentCommandService.GetAccountabilityForCallAsync(DepartmentId, callId); + result.PageSize = result.Data.Count; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// + /// Runs a personnel accountability (PAR) sweep for the call, raising workflow + real-time alerts for any + /// member newly overdue (Critical). Returns the user ids flagged this pass. Idempotent — repeated calls + /// only re-alert after a member checks in and lapses again. + /// + [HttpPost("EvaluateAccountability/{callId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_Update)] + public async Task> EvaluateAccountability(int callId) + { + var result = new ICModels.EvaluateAccountabilityResult(); + result.Data = await _incidentCommandService.EvaluateCriticalParAsync(DepartmentId, callId, CancellationToken.None); + result.PageSize = result.Data.Count; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + #endregion Command lifecycle + + #region Structure (lanes) + + /// Creates or updates a command structure lane. + [HttpPost("SaveNode")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_Update)] + public async Task> SaveNode([FromBody] CommandStructureNode node) + { + if (node == null || string.IsNullOrWhiteSpace(node.IncidentCommandId)) + return BadRequest(); + + node.DepartmentId = DepartmentId; + + var result = new ICModels.CommandNodeResult(); + var saved = await _incidentCommandService.SaveNodeAsync(node, UserId, CancellationToken.None); + + if (saved == null) + { + // Parent incident command not found / not owned by the caller's department. + result.Status = ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + result.Data = saved; + result.PageSize = 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// Removes a command structure lane. + [HttpDelete("DeleteNode/{commandStructureNodeId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_Update)] + public async Task> DeleteNode(string commandStructureNodeId) + { + var result = new ICModels.IncidentCommandActionResult(); + result.Data = await _incidentCommandService.DeleteNodeAsync(DepartmentId, commandStructureNodeId, UserId, CancellationToken.None); + result.Status = result.Data ? ResponseHelper.Success : ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + #endregion Structure (lanes) + + #region Resource assignments + + /// Assigns a resource (own/mutual-aid/ad-hoc unit or person) to a lane. + [HttpPost("AssignResource")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_Update)] + public async Task> AssignResource([FromBody] ResourceAssignment assignment) + { + if (assignment == null || string.IsNullOrWhiteSpace(assignment.IncidentCommandId)) + return BadRequest(); + + assignment.DepartmentId = DepartmentId; + + var result = new ICModels.ResourceAssignmentResult(); + var saved = await _incidentCommandService.AssignResourceAsync(assignment, UserId, CancellationToken.None); + + if (saved == null) + { + result.Status = ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + result.Data = saved; + result.PageSize = 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// Moves a resource assignment to a different lane. + [HttpPost("MoveResource")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_Update)] + public async Task> MoveResource([FromBody] ICModels.MoveResourceInput input) + { + if (input == null || string.IsNullOrWhiteSpace(input.ResourceAssignmentId) || string.IsNullOrWhiteSpace(input.TargetNodeId)) + return BadRequest(); + + var result = new ICModels.ResourceAssignmentResult(); + var assignment = await _incidentCommandService.MoveResourceAsync(DepartmentId, input.ResourceAssignmentId, input.TargetNodeId, UserId, CancellationToken.None); + + if (assignment == null) + { + result.Status = ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + result.Data = assignment; + result.PageSize = 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// Releases a resource assignment. + [HttpPost("ReleaseResource/{resourceAssignmentId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_Update)] + public async Task> ReleaseResource(string resourceAssignmentId) + { + var result = new ICModels.IncidentCommandActionResult(); + result.Data = await _incidentCommandService.ReleaseResourceAsync(DepartmentId, resourceAssignmentId, UserId, CancellationToken.None); + result.Status = result.Data ? ResponseHelper.Success : ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + #endregion Resource assignments + + #region Objectives + + /// Creates or updates a tactical objective / benchmark. + [HttpPost("SaveObjective")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_Update)] + public async Task> SaveObjective([FromBody] TacticalObjective objective) + { + if (objective == null || string.IsNullOrWhiteSpace(objective.IncidentCommandId)) + return BadRequest(); + + objective.DepartmentId = DepartmentId; + + var result = new ICModels.TacticalObjectiveResult(); + var saved = await _incidentCommandService.SaveObjectiveAsync(objective, UserId, CancellationToken.None); + + if (saved == null) + { + result.Status = ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + result.Data = saved; + result.PageSize = 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// Marks a tactical objective complete. + [HttpPost("CompleteObjective/{tacticalObjectiveId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_Update)] + public async Task> CompleteObjective(string tacticalObjectiveId) + { + var result = new ICModels.TacticalObjectiveResult(); + var objective = await _incidentCommandService.CompleteObjectiveAsync(DepartmentId, tacticalObjectiveId, UserId, CancellationToken.None); + + if (objective == null) + { + result.Status = ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + result.Data = objective; + result.PageSize = 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + #endregion Objectives + + #region Timers + + /// Starts a scene/benchmark/role timer. + [HttpPost("StartTimer")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_Update)] + public async Task> StartTimer([FromBody] IncidentTimer timer) + { + if (timer == null || string.IsNullOrWhiteSpace(timer.IncidentCommandId)) + return BadRequest(); + + timer.DepartmentId = DepartmentId; + + var result = new ICModels.IncidentTimerResult(); + var saved = await _incidentCommandService.StartTimerAsync(timer, UserId, CancellationToken.None); + + if (saved == null) + { + result.Status = ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + result.Data = saved; + result.PageSize = 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// Acknowledges a timer (resets its next-due time). + [HttpPost("AcknowledgeTimer/{incidentTimerId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_Update)] + public async Task> AcknowledgeTimer(string incidentTimerId) + { + var result = new ICModels.IncidentTimerResult(); + var timer = await _incidentCommandService.AcknowledgeTimerAsync(DepartmentId, incidentTimerId, UserId, CancellationToken.None); + + if (timer == null) + { + result.Status = ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + result.Data = timer; + result.PageSize = 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + #endregion Timers + + #region Map annotations + + /// Creates or updates a real-time map annotation. + [HttpPost("SaveAnnotation")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_Update)] + public async Task> SaveAnnotation([FromBody] IncidentMapAnnotation annotation) + { + if (annotation == null || string.IsNullOrWhiteSpace(annotation.IncidentCommandId)) + return BadRequest(); + + annotation.DepartmentId = DepartmentId; + + var result = new ICModels.IncidentMapAnnotationResult(); + var saved = await _incidentCommandService.SaveAnnotationAsync(annotation, UserId, CancellationToken.None); + + if (saved == null) + { + result.Status = ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + result.Data = saved; + result.PageSize = 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// Removes a map annotation. + [HttpDelete("DeleteAnnotation/{incidentMapAnnotationId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_Update)] + public async Task> DeleteAnnotation(string incidentMapAnnotationId) + { + var result = new ICModels.IncidentCommandActionResult(); + result.Data = await _incidentCommandService.DeleteAnnotationAsync(DepartmentId, incidentMapAnnotationId, UserId, CancellationToken.None); + result.Status = result.Data ? ResponseHelper.Success : ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + #endregion Map annotations + + #region Timeline + + /// Gets the append-only command (ICS-201) timeline for a call. + [HttpGet("GetTimeline/{callId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_View)] + public async Task> GetTimeline(int callId) + { + var result = new ICModels.CommandTimelineResult(); + result.Data = await _incidentCommandService.GetTimelineForCallAsync(DepartmentId, callId); + result.PageSize = result.Data.Count; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + #endregion Timeline + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/IncidentReportingController.cs b/Web/Resgrid.Web.Services/Controllers/v4/IncidentReportingController.cs new file mode 100644 index 000000000..84d0316e5 --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/IncidentReportingController.cs @@ -0,0 +1,88 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Services.Helpers; +using System.Text; +using System.Threading.Tasks; +using ICModels = Resgrid.Web.Services.Models.v4.IncidentCommand; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// + /// Per-incident reporting & analytics (§3.13): incident status summary (ICS-201/209), after-action bundle, + /// and timeline export. + /// + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + public class IncidentReportingController : V4AuthenticatedApiControllerbase + { + #region Members and Constructors + private readonly IIncidentReportingService _incidentReportingService; + + public IncidentReportingController(IIncidentReportingService incidentReportingService) + { + _incidentReportingService = incidentReportingService; + } + #endregion Members and Constructors + + /// Gets the ICS-201/209-style incident status summary. + [HttpGet("GetIncidentSummary/{callId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_View)] + public async Task> GetIncidentSummary(int callId) + { + var result = new ICModels.IncidentReportSummaryResult(); + var summary = await _incidentReportingService.GetIncidentSummaryAsync(DepartmentId, callId); + + if (summary == null) + { + result.Status = ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + result.Data = summary; + result.PageSize = 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// Gets the complete after-action report bundle for an incident. + [HttpGet("GetAfterActionReport/{callId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_View)] + public async Task> GetAfterActionReport(int callId) + { + var result = new ICModels.IncidentAfterActionReportResult(); + var report = await _incidentReportingService.GetAfterActionReportAsync(DepartmentId, callId); + + if (report == null) + { + result.Status = ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + result.Data = report; + result.PageSize = 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// Exports the incident command timeline as a CSV file. + [HttpGet("ExportIncident/{callId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_View)] + public async Task ExportIncident(int callId) + { + var csv = await _incidentReportingService.ExportTimelineCsvAsync(DepartmentId, callId); + var bytes = Encoding.UTF8.GetBytes(csv); + return File(bytes, "text/csv", $"incident-{callId}-timeline.csv"); + } + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/IncidentResourcesController.cs b/Web/Resgrid.Web.Services/Controllers/v4/IncidentResourcesController.cs new file mode 100644 index 000000000..4227cb44e --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/IncidentResourcesController.cs @@ -0,0 +1,217 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Services.Helpers; +using System.Threading; +using System.Threading.Tasks; +using ICModels = Resgrid.Web.Services.Models.v4.IncidentCommand; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// + /// Incident-scoped ad-hoc resources: create units/personnel on the fly for non-Resgrid resources, build + /// rosters, and form a unit from on-scene personnel (§3.10). + /// + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + public class IncidentResourcesController : V4AuthenticatedApiControllerbase + { + #region Members and Constructors + private readonly IIncidentResourcesService _incidentResourcesService; + + public IncidentResourcesController(IIncidentResourcesService incidentResourcesService) + { + _incidentResourcesService = incidentResourcesService; + } + #endregion Members and Constructors + + #region Ad-hoc units + + /// Creates an ad-hoc unit for a non-Resgrid resource. + [HttpPost("CreateAdHocUnit")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_Update)] + public async Task> CreateAdHocUnit([FromBody] IncidentAdHocUnit unit) + { + if (unit == null || unit.CallId <= 0) + return BadRequest(); + + unit.DepartmentId = DepartmentId; + + var result = new ICModels.AdHocUnitResult(); + var saved = await _incidentResourcesService.CreateAdHocUnitAsync(unit, UserId, CancellationToken.None); + + if (saved == null) + { + // No active command owned by the caller's department for this call. + result.Status = ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + result.Data = saved; + result.PageSize = 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// Gets the active ad-hoc units for a call. + [HttpGet("GetAdHocUnits/{callId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_View)] + public async Task> GetAdHocUnits(int callId) + { + var result = new ICModels.AdHocUnitsResult(); + result.Data = await _incidentResourcesService.GetAdHocUnitsForCallAsync(DepartmentId, callId); + result.PageSize = result.Data.Count; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// Releases an ad-hoc unit. + [HttpPost("ReleaseAdHocUnit/{incidentAdHocUnitId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_Update)] + public async Task> ReleaseAdHocUnit(string incidentAdHocUnitId) + { + var result = new ICModels.IncidentCommandActionResult(); + result.Data = await _incidentResourcesService.ReleaseAdHocUnitAsync(DepartmentId, incidentAdHocUnitId, UserId, CancellationToken.None); + result.Status = result.Data ? ResponseHelper.Success : ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + #endregion Ad-hoc units + + #region Ad-hoc personnel + + /// Creates an ad-hoc person for a non-Resgrid resource. + [HttpPost("CreateAdHocPersonnel")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_Update)] + public async Task> CreateAdHocPersonnel([FromBody] IncidentAdHocPersonnel personnel) + { + if (personnel == null || personnel.CallId <= 0) + return BadRequest(); + + personnel.DepartmentId = DepartmentId; + + var result = new ICModels.AdHocPersonnelResult(); + var saved = await _incidentResourcesService.CreateAdHocPersonnelAsync(personnel, UserId, CancellationToken.None); + + if (saved == null) + { + result.Status = ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + result.Data = saved; + result.PageSize = 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// Gets the active ad-hoc personnel for a call. + [HttpGet("GetAdHocPersonnel/{callId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_View)] + public async Task> GetAdHocPersonnel(int callId) + { + var result = new ICModels.AdHocPersonnelListResult(); + result.Data = await _incidentResourcesService.GetAdHocPersonnelForCallAsync(DepartmentId, callId); + result.PageSize = result.Data.Count; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// Releases an ad-hoc person. + [HttpPost("ReleaseAdHocPersonnel/{incidentAdHocPersonnelId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_Update)] + public async Task> ReleaseAdHocPersonnel(string incidentAdHocPersonnelId) + { + var result = new ICModels.IncidentCommandActionResult(); + result.Data = await _incidentResourcesService.ReleaseAdHocPersonnelAsync(DepartmentId, incidentAdHocPersonnelId, UserId, CancellationToken.None); + result.Status = result.Data ? ResponseHelper.Success : ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + #endregion Ad-hoc personnel + + #region Roster building + + /// Adds an ad-hoc person to a unit roster for accountability. + [HttpPost("AssignPersonnelToUnit")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_Update)] + public async Task> AssignPersonnelToUnit([FromBody] ICModels.AssignPersonnelToUnitInput input) + { + if (input == null || string.IsNullOrWhiteSpace(input.IncidentAdHocPersonnelId)) + return BadRequest(); + + var result = new ICModels.AdHocPersonnelResult(); + var personnel = await _incidentResourcesService.AssignPersonnelToUnitAsync(DepartmentId, input.IncidentAdHocPersonnelId, input.RidingResourceKind, input.RidingResourceId, UserId, CancellationToken.None); + + if (personnel == null) + { + result.Status = ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + result.Data = personnel; + result.PageSize = 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// Forms a new ad-hoc unit from on-scene ad-hoc personnel. + [HttpPost("FormUnit")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_Update)] + public async Task> FormUnit([FromBody] ICModels.FormUnitInput input) + { + if (input == null || input.CallId <= 0 || string.IsNullOrWhiteSpace(input.Name)) + return BadRequest(); + + var unit = new IncidentAdHocUnit + { + DepartmentId = DepartmentId, + CallId = input.CallId, + Name = input.Name, + Type = input.Type, + UnitTypeId = input.UnitTypeId, + ExternalAgencyName = input.ExternalAgencyName + }; + + var result = new ICModels.AdHocUnitResult(); + var saved = await _incidentResourcesService.FormUnitFromPersonnelAsync(unit, input.AdHocPersonnelIds, UserId, CancellationToken.None); + + if (saved == null) + { + result.Status = ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + result.Data = saved; + result.PageSize = 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + #endregion Roster building + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/IncidentRolesController.cs b/Web/Resgrid.Web.Services/Controllers/v4/IncidentRolesController.cs new file mode 100644 index 000000000..b298849f0 --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/IncidentRolesController.cs @@ -0,0 +1,115 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Services.Helpers; +using System; +using System.Threading; +using System.Threading.Tasks; +using ICModels = Resgrid.Web.Services.Models.v4.IncidentCommand; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// + /// Incident-scoped functional command roles (§3.11). Assign Resgrid users to ICS positions (Staging Officer, + /// Rehab Officer, Section Chiefs, ...) which drive each user's specialized app view and capabilities. + /// + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + public class IncidentRolesController : V4AuthenticatedApiControllerbase + { + #region Members and Constructors + private readonly IIncidentCommandService _incidentCommandService; + + public IncidentRolesController(IIncidentCommandService incidentCommandService) + { + _incidentCommandService = incidentCommandService; + } + #endregion Members and Constructors + + /// Assigns a Resgrid user to a functional incident-command role. + [HttpPost("AssignRole")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_Update)] + public async Task> AssignRole([FromBody] IncidentRoleAssignment assignment) + { + if (assignment == null || string.IsNullOrWhiteSpace(assignment.IncidentCommandId) || string.IsNullOrWhiteSpace(assignment.UserId)) + return BadRequest(); + + assignment.DepartmentId = DepartmentId; + + var result = new ICModels.IncidentRoleResult(); + var saved = await _incidentCommandService.AssignIncidentRoleAsync(assignment, UserId, CancellationToken.None); + + if (saved == null) + { + // Parent incident command not found / not owned by the caller's department. + result.Status = ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + result.Data = saved; + result.PageSize = 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// Removes a functional incident-command role assignment. + [HttpPost("RemoveRole/{incidentRoleAssignmentId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_Update)] + public async Task> RemoveRole(string incidentRoleAssignmentId) + { + var result = new ICModels.IncidentCommandActionResult(); + result.Data = await _incidentCommandService.RemoveIncidentRoleAsync(DepartmentId, incidentRoleAssignmentId, UserId, CancellationToken.None); + result.Status = result.Data ? ResponseHelper.Success : ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// Gets the active functional role assignments for a call. + [HttpGet("GetRoles/{callId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_View)] + public async Task> GetRoles(int callId) + { + var result = new ICModels.IncidentRolesResult(); + result.Data = await _incidentCommandService.GetIncidentRolesAsync(DepartmentId, callId); + result.PageSize = result.Data.Count; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// Gets the current user's effective capabilities for an incident (drives the app's view gating). + [HttpGet("GetMyCapabilities/{callId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_View)] + public async Task> GetMyCapabilities(int callId) + { + var caps = await _incidentCommandService.GetCapabilitiesForUserAsync(DepartmentId, callId, UserId); + + var result = new ICModels.IncidentCapabilitiesResult(); + result.Value = (int)caps; + + foreach (IncidentCapabilities cap in Enum.GetValues(typeof(IncidentCapabilities))) + { + if (cap == IncidentCapabilities.None || cap == IncidentCapabilities.All) + continue; + + if (caps.HasFlag(cap)) + result.Capabilities.Add(cap.ToString()); + } + + result.PageSize = result.Capabilities.Count; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/IncidentVoiceController.cs b/Web/Resgrid.Web.Services/Controllers/v4/IncidentVoiceController.cs new file mode 100644 index 000000000..ceaa8be57 --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/IncidentVoiceController.cs @@ -0,0 +1,84 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Services.Helpers; +using System.Threading; +using System.Threading.Tasks; +using ICModels = Resgrid.Web.Services.Models.v4.IncidentCommand; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// + /// On-demand PTT tactical voice channels scoped to an incident (§3.4). Requires the department PTT voice addon. + /// + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + public class IncidentVoiceController : V4AuthenticatedApiControllerbase + { + #region Members and Constructors + private readonly IIncidentVoiceService _incidentVoiceService; + + public IncidentVoiceController(IIncidentVoiceService incidentVoiceService) + { + _incidentVoiceService = incidentVoiceService; + } + #endregion Members and Constructors + + /// Creates an on-demand tactical channel scoped to a call (requires the voice addon). + [HttpPost("CreateIncidentChannel")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_Update)] + public async Task> CreateIncidentChannel([FromBody] ICModels.CreateIncidentChannelInput input) + { + if (input == null || input.CallId <= 0) + return BadRequest(); + + var result = new ICModels.IncidentVoiceChannelResult(); + var channel = await _incidentVoiceService.CreateIncidentChannelAsync(DepartmentId, input.CallId, input.Name, UserId, CancellationToken.None); + + if (channel == null) + { + // Voice addon not enabled for the department, or the channel could not be provisioned. + result.Status = ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + result.Data = channel; + result.PageSize = 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// Gets the open on-demand tactical channels for a call (visible to assigned units/users). + [HttpGet("GetChannelsForCall/{callId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_View)] + public async Task> GetChannelsForCall(int callId) + { + var result = new ICModels.IncidentVoiceChannelsResult(); + result.Data = await _incidentVoiceService.GetChannelsForCallAsync(DepartmentId, callId); + result.PageSize = result.Data.Count; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// Closes all open on-demand tactical channels for a call. + [HttpPost("CloseIncidentChannels/{callId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_Update)] + public async Task> CloseIncidentChannels(int callId) + { + var result = new ICModels.IncidentCommandActionResult(); + result.Data = await _incidentVoiceService.CloseIncidentChannelsForCallAsync(DepartmentId, callId, UserId, CancellationToken.None); + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/MutualAidController.cs b/Web/Resgrid.Web.Services/Controllers/v4/MutualAidController.cs new file mode 100644 index 000000000..264572a55 --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/MutualAidController.cs @@ -0,0 +1,47 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Services.Helpers; +using System.Threading.Tasks; +using ICModels = Resgrid.Web.Services.Models.v4.IncidentCommand; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// + /// Mutual-aid resource aggregation for incident command: own-department + linked-department units/personnel, + /// color-coded, for the IC resource picker (§3.9). + /// + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + public class MutualAidController : V4AuthenticatedApiControllerbase + { + #region Members and Constructors + private readonly IMutualAidService _mutualAidService; + + public MutualAidController(IMutualAidService mutualAidService) + { + _mutualAidService = mutualAidService; + } + #endregion Members and Constructors + + /// + /// Gets all resources the commander can assign to lanes: own-department units/personnel plus those + /// shared toward this department by accepted mutual-aid links, color-coded by link. + /// + [HttpGet("GetAssignableResources")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_View)] + public async Task> GetAssignableResources() + { + var result = new ICModels.MutualAidResourcesResult(); + result.Data = await _mutualAidService.GetAssignableResourcesForIncidentAsync(DepartmentId); + result.PageSize = result.Data.Count; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/Commands/CommandModels.cs b/Web/Resgrid.Web.Services/Models/v4/Commands/CommandModels.cs new file mode 100644 index 000000000..ee1c66af0 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/Commands/CommandModels.cs @@ -0,0 +1,97 @@ +using System.Collections.Generic; + +namespace Resgrid.Web.Services.Models.v4.Commands +{ + /// + /// A command definition (predefined incident-command template / set of swimlanes for a call type). + /// + public class CommandResultData + { + /// Identifier of the command definition. + public int CommandDefinitionId { get; set; } + + /// The call type this template applies to, or null for "Any Call Type". + public int? CallTypeId { get; set; } + + /// Name of the command definition. + public string Name { get; set; } + + /// Description of the command definition. + public string Description { get; set; } + + /// Whether a default timer is enabled for this definition. + public bool Timer { get; set; } + + /// Default timer length in minutes. + public int TimerMinutes { get; set; } + + /// The predefined lanes (roles) for this command definition. + public List Lanes { get; set; } = new List(); + } + + /// + /// A predefined lane (role) within a command definition. + /// + public class CommandRoleResultData + { + public int CommandDefinitionRoleId { get; set; } + public string Name { get; set; } + public string Description { get; set; } + public int LaneType { get; set; } + public int SortOrder { get; set; } + public int MinUnitPersonnel { get; set; } + public int MaxUnitPersonnel { get; set; } + public int MaxUnits { get; set; } + public int MinTimeInRole { get; set; } + public int MaxTimeInRole { get; set; } + public bool ForceRequirements { get; set; } + } + + /// + /// Result wrapper for a collection of command definitions. + /// + public class CommandsResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + } + + /// + /// Result wrapper for a single command definition. + /// + public class CommandResult : StandardApiResponseV4Base + { + public CommandResultData Data { get; set; } + } + + /// + /// Input payload to create or update a command definition. + /// + public class SaveCommandInput + { + public int? CommandDefinitionId { get; set; } + public int? CallTypeId { get; set; } + public string Name { get; set; } + public string Description { get; set; } + public bool Timer { get; set; } + public int TimerMinutes { get; set; } + public List Lanes { get; set; } = new List(); + } + + /// + /// Input payload for a single lane within a command definition. + /// + public class SaveCommandLaneInput + { + public int? CommandDefinitionRoleId { get; set; } + public string Name { get; set; } + public string Description { get; set; } + public int LaneType { get; set; } + public int SortOrder { get; set; } + public int MinUnitPersonnel { get; set; } + public int MaxUnitPersonnel { get; set; } + public int MaxUnits { get; set; } + public int MinTimeInRole { get; set; } + public int MaxTimeInRole { get; set; } + public bool ForceRequirements { get; set; } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/IncidentCommand/IncidentCommandModels.cs b/Web/Resgrid.Web.Services/Models/v4/IncidentCommand/IncidentCommandModels.cs new file mode 100644 index 000000000..568242922 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/IncidentCommand/IncidentCommandModels.cs @@ -0,0 +1,187 @@ +using System.Collections.Generic; + +namespace Resgrid.Web.Services.Models.v4.IncidentCommand +{ + /// Input to establish command on a call. + public class EstablishCommandInput + { + public int CallId { get; set; } + public int? CommandDefinitionId { get; set; } + } + + /// Input to transfer command to another user. + public class TransferCommandInput + { + public string IncidentCommandId { get; set; } + public string ToUserId { get; set; } + public string Notes { get; set; } + } + + /// Input to update the incident action plan. + public class UpdateActionPlanInput + { + public string IncidentCommandId { get; set; } + public string ActionPlan { get; set; } + } + + /// Input to move a resource assignment to a different node. + public class MoveResourceInput + { + public string ResourceAssignmentId { get; set; } + public string TargetNodeId { get; set; } + } + + public class IncidentCommandResult : StandardApiResponseV4Base + { + public Resgrid.Model.IncidentCommand Data { get; set; } + } + + public class IncidentCommandBoardResult : StandardApiResponseV4Base + { + public Resgrid.Model.IncidentCommandBoard Data { get; set; } + } + + public class CommandTransferResult : StandardApiResponseV4Base + { + public Resgrid.Model.CommandTransfer Data { get; set; } + } + + public class CommandNodeResult : StandardApiResponseV4Base + { + public Resgrid.Model.CommandStructureNode Data { get; set; } + } + + public class ResourceAssignmentResult : StandardApiResponseV4Base + { + public Resgrid.Model.ResourceAssignment Data { get; set; } + } + + public class TacticalObjectiveResult : StandardApiResponseV4Base + { + public Resgrid.Model.TacticalObjective Data { get; set; } + } + + public class IncidentTimerResult : StandardApiResponseV4Base + { + public Resgrid.Model.IncidentTimer Data { get; set; } + } + + public class IncidentMapAnnotationResult : StandardApiResponseV4Base + { + public Resgrid.Model.IncidentMapAnnotation Data { get; set; } + } + + public class CommandTimelineResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + } + + /// Simple boolean action result (delete/release operations). + public class IncidentCommandActionResult : StandardApiResponseV4Base + { + public bool Data { get; set; } + } + + /// Per-person accountability / PAR status for the incident. + public class CommandAccountabilityResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + } + + /// User ids newly flagged Critical (PAR overdue) by an accountability sweep — same shape used by the manual endpoint and the recurring PAR worker. + public class EvaluateAccountabilityResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + } + + /// Input to create an on-demand incident tactical voice channel. + public class CreateIncidentChannelInput + { + public int CallId { get; set; } + public string Name { get; set; } + } + + /// Result wrapper for a single incident voice channel. + public class IncidentVoiceChannelResult : StandardApiResponseV4Base + { + public Resgrid.Model.DepartmentVoiceChannel Data { get; set; } + } + + /// Result wrapper for a collection of incident voice channels. + public class IncidentVoiceChannelsResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + } + + /// Assignable resources (own + mutual-aid) for the incident resource picker. + public class MutualAidResourcesResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + } + + /// Input to add an ad-hoc person to a unit roster. + public class AssignPersonnelToUnitInput + { + public string IncidentAdHocPersonnelId { get; set; } + public int RidingResourceKind { get; set; } + public string RidingResourceId { get; set; } + } + + /// Input to form an ad-hoc unit from on-scene ad-hoc personnel. + public class FormUnitInput + { + public int CallId { get; set; } + public string Name { get; set; } + public string Type { get; set; } + public int? UnitTypeId { get; set; } + public string ExternalAgencyName { get; set; } + public List AdHocPersonnelIds { get; set; } = new List(); + } + + public class AdHocUnitResult : StandardApiResponseV4Base + { + public Resgrid.Model.IncidentAdHocUnit Data { get; set; } + } + + public class AdHocUnitsResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + } + + public class AdHocPersonnelResult : StandardApiResponseV4Base + { + public Resgrid.Model.IncidentAdHocPersonnel Data { get; set; } + } + + public class AdHocPersonnelListResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + } + + public class IncidentRoleResult : StandardApiResponseV4Base + { + public Resgrid.Model.IncidentRoleAssignment Data { get; set; } + } + + public class IncidentRolesResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + } + + /// The current user's effective incident capabilities (raw flags value + granted names). + public class IncidentCapabilitiesResult : StandardApiResponseV4Base + { + public int Value { get; set; } + public List Capabilities { get; set; } = new List(); + } + + public class IncidentReportSummaryResult : StandardApiResponseV4Base + { + public Resgrid.Model.IncidentReportSummary Data { get; set; } + } + + public class IncidentAfterActionReportResult : StandardApiResponseV4Base + { + public Resgrid.Model.IncidentAfterActionReport Data { get; set; } + } +} diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index 33f0b86ef..4cb874c91 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -517,6 +517,38 @@ The call identifier to inspect. + + + Command definitions: predefined incident-command templates (swimlanes) per call type, used to + seed the runtime command board. House Fire vs Vehicle Incident, etc. + + + + + Gets all command definitions for the department. + + + + + Gets a single command definition by identifier. + + + + + Resolves the command definition (template) for a call type, falling back to the + "Any Call Type" definition. Pass 0 to request the "Any Call Type" template directly. + + + + + Creates or updates a command definition (including its lanes). + + + + + Deletes a command definition. + + Public endpoints for communication test responses (email confirm, voice webhook) @@ -975,6 +1007,145 @@ + + + Live incident command: establish/transfer/close command, edit the command structure (lanes), assign + resources, manage objectives, timers, map annotations, and read the action timeline for a Call. + + + + Establishes command on a call, optionally seeding lanes from a command definition. + + + Gets the full live command board snapshot for a call. + + + Transfers command to another user. + + + Closes command on an incident. + + + Updates the incident action plan. + + + Gets the personnel accountability / PAR status (Green/Warning/Critical) for the incident. + + + + Runs a personnel accountability (PAR) sweep for the call, raising workflow + real-time alerts for any + member newly overdue (Critical). Returns the user ids flagged this pass. Idempotent — repeated calls + only re-alert after a member checks in and lapses again. + + + + Creates or updates a command structure lane. + + + Removes a command structure lane. + + + Assigns a resource (own/mutual-aid/ad-hoc unit or person) to a lane. + + + Moves a resource assignment to a different lane. + + + Releases a resource assignment. + + + Creates or updates a tactical objective / benchmark. + + + Marks a tactical objective complete. + + + Starts a scene/benchmark/role timer. + + + Acknowledges a timer (resets its next-due time). + + + Creates or updates a real-time map annotation. + + + Removes a map annotation. + + + Gets the append-only command (ICS-201) timeline for a call. + + + + Gets the ICS-201/209-style incident status summary. + + + Gets the complete after-action report bundle for an incident. + + + Exports the incident command timeline as a CSV file. + + + + Incident-scoped ad-hoc resources: create units/personnel on the fly for non-Resgrid resources, build + rosters, and form a unit from on-scene personnel (§3.10). + + + + Creates an ad-hoc unit for a non-Resgrid resource. + + + Gets the active ad-hoc units for a call. + + + Releases an ad-hoc unit. + + + Creates an ad-hoc person for a non-Resgrid resource. + + + Gets the active ad-hoc personnel for a call. + + + Releases an ad-hoc person. + + + Adds an ad-hoc person to a unit roster for accountability. + + + Forms a new ad-hoc unit from on-scene ad-hoc personnel. + + + + Incident-scoped functional command roles (§3.11). Assign Resgrid users to ICS positions (Staging Officer, + Rehab Officer, Section Chiefs, ...) which drive each user's specialized app view and capabilities. + + + + Assigns a Resgrid user to a functional incident-command role. + + + Removes a functional incident-command role assignment. + + + Gets the active functional role assignments for a call. + + + Gets the current user's effective capabilities for an incident (drives the app's view gating). + + + + On-demand PTT tactical voice channels scoped to an incident (§3.4). Requires the department PTT voice addon. + + + + Creates an on-demand tactical channel scoped to a call (requires the voice addon). + + + Gets the open on-demand tactical channels for a call (visible to assigned units/users). + + + Closes all open on-demand tactical channels for a call. + Mapping operations @@ -1163,6 +1334,18 @@ MessageId of the message to delete Returns OK status code if successful + + + Mutual-aid resource aggregation for incident command: own-department + linked-department units/personnel, + color-coded, for the IC resource picker (§3.9). + + + + + Gets all resources the commander can assign to lanes: own-department units/personnel plus those + shared toward this department by accepted mutual-aid links, color-coded by link. + + The options for Notes in the Resgrid system @@ -6209,6 +6392,57 @@ Colour-coded status: "Green", "Warning", or "Critical". + + + A command definition (predefined incident-command template / set of swimlanes for a call type). + + + + Identifier of the command definition. + + + The call type this template applies to, or null for "Any Call Type". + + + Name of the command definition. + + + Description of the command definition. + + + Whether a default timer is enabled for this definition. + + + Default timer length in minutes. + + + The predefined lanes (roles) for this command definition. + + + + A predefined lane (role) within a command definition. + + + + + Result wrapper for a collection of command definitions. + + + + + Result wrapper for a single command definition. + + + + + Input payload to create or update a command definition. + + + + + Input payload for a single lane within a command definition. + + Result of getting all communication tests for a department @@ -7166,6 +7400,48 @@ Can the API services talk to the cache + + Input to establish command on a call. + + + Input to transfer command to another user. + + + Input to update the incident action plan. + + + Input to move a resource assignment to a different node. + + + Simple boolean action result (delete/release operations). + + + Per-person accountability / PAR status for the incident. + + + User ids newly flagged Critical (PAR overdue) by an accountability sweep — same shape used by the manual endpoint and the recurring PAR worker. + + + Input to create an on-demand incident tactical voice channel. + + + Result wrapper for a single incident voice channel. + + + Result wrapper for a collection of incident voice channels. + + + Assignable resources (own + mutual-aid) for the incident resource picker. + + + Input to add an ad-hoc person to a unit roster. + + + Input to form an ad-hoc unit from on-scene ad-hoc personnel. + + + The current user's effective incident capabilities (raw flags value + granted names). + GeoJSON FeatureCollection string ready for direct rnmapbox ShapeSource consumption diff --git a/Workers/Resgrid.Workers.Console/Commands/ParEvaluationCommand.cs b/Workers/Resgrid.Workers.Console/Commands/ParEvaluationCommand.cs new file mode 100644 index 000000000..aa41a9737 --- /dev/null +++ b/Workers/Resgrid.Workers.Console/Commands/ParEvaluationCommand.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using Quidjibo.Commands; + +namespace Resgrid.Workers.Console.Commands +{ + /// Scheduled command that runs the periodic personnel accountability (PAR) sweep. + public class ParEvaluationCommand : IQuidjiboCommand + { + public int Id { get; } + public Guid? CorrelationId { get; set; } + public Dictionary Metadata { get; set; } + + public ParEvaluationCommand(int id) + { + Id = id; + } + } +} diff --git a/Workers/Resgrid.Workers.Console/Program.cs b/Workers/Resgrid.Workers.Console/Program.cs index f319a208e..4e7ffa675 100644 --- a/Workers/Resgrid.Workers.Console/Program.cs +++ b/Workers/Resgrid.Workers.Console/Program.cs @@ -421,6 +421,15 @@ await Client.ScheduleAsync("Reporting Rollup", Cron.Daily(3, 30), stoppingToken); + // Frequent on purpose: PAR (personnel accountability) is safety-critical, so the backstop + // sweep runs every minute. Each call's evaluation short-circuits cheaply when there's no + // active incident command, and the alert is idempotent (timeline-deduped). + _logger.Log(LogLevel.Information, "Scheduling PAR Evaluation"); + await Client.ScheduleAsync("PAR Evaluation", + new Commands.ParEvaluationCommand(23), + Cron.MinuteIntervals(1), + stoppingToken); + if (SystemBehaviorConfig.Utf8CleanupEnabled) { var utf8CleanupHour = SystemBehaviorConfig.Utf8CleanupHourUtc >= 0 && SystemBehaviorConfig.Utf8CleanupHourUtc <= 23 diff --git a/Workers/Resgrid.Workers.Console/Tasks/ParEvaluationTask.cs b/Workers/Resgrid.Workers.Console/Tasks/ParEvaluationTask.cs new file mode 100644 index 000000000..49772facb --- /dev/null +++ b/Workers/Resgrid.Workers.Console/Tasks/ParEvaluationTask.cs @@ -0,0 +1,46 @@ +using Microsoft.Extensions.Logging; +using Quidjibo.Handlers; +using Quidjibo.Misc; +using Resgrid.Workers.Console.Commands; +using Resgrid.Workers.Framework.Logic; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Workers.Console.Tasks +{ + public class ParEvaluationTask : IQuidjiboHandler + { + public string Name => "PAR Evaluation"; + public int Priority => 1; + public ILogger _logger; + + public ParEvaluationTask(ILogger logger) + { + _logger = logger; + } + + public async Task ProcessAsync(ParEvaluationCommand command, IQuidjiboProgress progress, CancellationToken cancellationToken) + { + try + { + progress.Report(1, $"Starting the {Name} Task"); + + var logic = new ParEvaluationLogic(); + var result = await logic.Process(cancellationToken); + + if (result.Item1) + _logger.LogInformation($"ParEvaluation::{result.Item2}"); + else + _logger.LogInformation($"ParEvaluation::Failed to sweep accountability. {result.Item2}"); + + progress.Report(100, $"Finishing the {Name} Task"); + } + catch (Exception ex) + { + Resgrid.Framework.Logging.LogException(ex); + _logger.LogError(ex.ToString()); + } + } + } +} diff --git a/Workers/Resgrid.Workers.Framework/Logic/ParEvaluationLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/ParEvaluationLogic.cs new file mode 100644 index 000000000..f2cb24296 --- /dev/null +++ b/Workers/Resgrid.Workers.Framework/Logic/ParEvaluationLogic.cs @@ -0,0 +1,71 @@ +using Resgrid.Model.Services; +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Autofac; + +namespace Resgrid.Workers.Framework.Logic +{ + /// + /// Periodic personnel accountability (PAR) sweep. For every active call that has check-in timers enabled, + /// asks the incident-command service to raise CriticalParDetectedEvent for any member newly overdue. + /// The service is idempotent (timeline-deduped), so running this frequently only alerts on real transitions + /// into Critical. This worker is the backstop for when nobody is viewing the board — the board read path runs + /// the same sweep on demand. The per-call evaluation short-circuits cheaply when a call has no active command. + /// + public class ParEvaluationLogic + { + private readonly IDepartmentsService _departmentsService; + private readonly ICallsService _callsService; + private readonly IIncidentCommandService _incidentCommandService; + + public ParEvaluationLogic() + { + _departmentsService = Bootstrapper.GetKernel().Resolve(); + _callsService = Bootstrapper.GetKernel().Resolve(); + _incidentCommandService = Bootstrapper.GetKernel().Resolve(); + } + + public async Task> Process(CancellationToken cancellationToken = default) + { + try + { + var departments = await _departmentsService.GetAllAsync(); + if (departments == null) + return new Tuple(true, "No departments to sweep."); + + int callsSwept = 0; + int membersFlagged = 0; + + foreach (var department in departments) + { + if (cancellationToken.IsCancellationRequested) + break; + + var activeCalls = await _callsService.GetActiveCallsByDepartmentAsync(department.DepartmentId); + if (activeCalls == null) + continue; + + foreach (var call in activeCalls.Where(c => c.CheckInTimersEnabled)) + { + // EvaluateCriticalParAsync no-ops cheaply when the call has no active incident command, + // so we can sweep every check-in-enabled active call without pre-filtering by command. + var flagged = await _incidentCommandService.EvaluateCriticalParAsync( + department.DepartmentId, call.CallId, cancellationToken); + + callsSwept++; + membersFlagged += flagged?.Count ?? 0; + } + } + + return new Tuple(true, $"Swept {callsSwept} call(s); flagged {membersFlagged} member(s) critical."); + } + catch (Exception ex) + { + Resgrid.Framework.Logging.LogException(ex); + return new Tuple(false, ex.ToString()); + } + } + } +}