diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 8421b6b62..1cbf1d54b 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -143,6 +143,7 @@ jobs: if (pr) { const body = (pr.body || '') .replace(/##\s*Summary by CodeRabbit[\s\S]*/i, '') + .replace(/Pull Request Description/gi, 'Summary') .trim() || 'No release notes provided.'; fs.writeFileSync('release_notes.md', body); } else { diff --git a/Core/Resgrid.Model/CommandDefinitionRole.cs b/Core/Resgrid.Model/CommandDefinitionRole.cs index 001a90d74..ccc1f5dfb 100644 --- a/Core/Resgrid.Model/CommandDefinitionRole.cs +++ b/Core/Resgrid.Model/CommandDefinitionRole.cs @@ -48,8 +48,6 @@ public class CommandDefinitionRole : IEntity public virtual ICollection RequiredUnitTypes { get; set; } - public virtual ICollection RequiredCerts { get; set; } - public virtual ICollection RequiredRoles { get; set; } [NotMapped] @@ -70,6 +68,6 @@ public object IdValue public int IdType => 0; [NotMapped] - public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName", "Command", "RequiredUnitTypes", "RequiredCerts", "RequiredRoles" }; + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName", "Command", "RequiredUnitTypes", "RequiredRoles" }; } } diff --git a/Core/Resgrid.Model/CommandDefinitionRoleCert.cs b/Core/Resgrid.Model/CommandDefinitionRoleCert.cs deleted file mode 100644 index 2bd2ac599..000000000 --- a/Core/Resgrid.Model/CommandDefinitionRoleCert.cs +++ /dev/null @@ -1,48 +0,0 @@ -using Newtonsoft.Json; -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace Resgrid.Model -{ - [Table("CommandDefinitionRoleCerts")] - public class CommandDefinitionRoleCert : IEntity - { - [Key] - [Required] - [DatabaseGenerated(DatabaseGeneratedOption.Identity)] - public int CommandDefinitionRoleCertId { get; set; } - - [Required] - [ForeignKey("CommandRole"), DatabaseGenerated(DatabaseGeneratedOption.None)] - public int CommandDefinitionRoleId { get; set; } - - public virtual CommandDefinitionRole CommandRole { get; set; } - - [Required] - [ForeignKey("Certification"), DatabaseGenerated(DatabaseGeneratedOption.None)] - public int DepartmentCertificationTypeId { get; set; } - - public virtual DepartmentCertificationType Certification { get; set; } - - [NotMapped] - [JsonIgnore] - public object IdValue - { - get { return CommandDefinitionRoleCertId; } - set { CommandDefinitionRoleCertId = (int)value; } - } - - [NotMapped] - public string TableName => "CommandDefinitionRoleCerts"; - - [NotMapped] - public string IdName => "CommandDefinitionRoleCertId"; - - [NotMapped] - public int IdType => 0; - - [NotMapped] - public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName", "CommandRole", "Certification" }; - } -} diff --git a/Core/Resgrid.Model/IncidentCommand/CommandRequirementsNotMetException.cs b/Core/Resgrid.Model/IncidentCommand/CommandRequirementsNotMetException.cs new file mode 100644 index 000000000..97e63c9dd --- /dev/null +++ b/Core/Resgrid.Model/IncidentCommand/CommandRequirementsNotMetException.cs @@ -0,0 +1,16 @@ +using System; + +namespace Resgrid.Model +{ + /// + /// Thrown when a resource assignment (or move) targets a command board lane whose source template + /// role has ForceRequirements enabled and the resource does not satisfy the lane's required + /// unit types / personnel roles. The message is safe to surface to the caller. + /// + public class CommandRequirementsNotMetException : Exception + { + public CommandRequirementsNotMetException(string message) : base(message) + { + } + } +} diff --git a/Core/Resgrid.Model/IncidentCommand/CommandStructureNode.cs b/Core/Resgrid.Model/IncidentCommand/CommandStructureNode.cs index 485dd235f..abcd72b53 100644 --- a/Core/Resgrid.Model/IncidentCommand/CommandStructureNode.cs +++ b/Core/Resgrid.Model/IncidentCommand/CommandStructureNode.cs @@ -91,6 +91,16 @@ public class ResourceAssignment : IEntity, IChangeTracked public DateTime? ReleasedOn { get; set; } + /// + /// True when the resource does not satisfy the target lane's template requirements but the lane + /// does NOT force them (advisory). The IC app renders this as a warning outline on the resource + /// chip. Recomputed on every assign/move; false when compliant or when requirements don't apply. + /// + public bool RequirementsWarning { get; set; } + + /// Human-readable reason for ; null when no warning. + public string RequirementsWarningMessage { get; set; } + /// Change cursor for offline delta sync + last-write-wins; stamped on every write. public DateTime? ModifiedOn { get; set; } diff --git a/Core/Resgrid.Model/Message.cs b/Core/Resgrid.Model/Message.cs index 9a818d256..942aa27f6 100644 --- a/Core/Resgrid.Model/Message.cs +++ b/Core/Resgrid.Model/Message.cs @@ -68,6 +68,14 @@ public class Message : IEntity [ProtoMember(14)] public virtual ICollection MessageRecipients { get; set; } + /// + /// Optional subtitle for push/inbox notifications; not persisted. When set, delivery + /// uses this instead of the generic "Msg from ..." subtitle. + /// + [NotMapped] + [JsonIgnore] + public string PushSubTitle { get; set; } + [NotMapped] [JsonIgnore] public object IdValue @@ -86,7 +94,7 @@ public object IdValue public int IdType => 0; [NotMapped] - public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName", "SendingUser", "ReceivingUser", "MessageRecipients" }; + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName", "SendingUser", "ReceivingUser", "MessageRecipients", "PushSubTitle" }; public List GetRecipients() { diff --git a/Core/Resgrid.Model/MessageTypes.cs b/Core/Resgrid.Model/MessageTypes.cs index b13e00cb2..73c51480e 100644 --- a/Core/Resgrid.Model/MessageTypes.cs +++ b/Core/Resgrid.Model/MessageTypes.cs @@ -4,6 +4,7 @@ public enum MessageTypes { Normal = 0, Callback = 1, - Poll = 2 + Poll = 2, + WeatherAlert = 3 } } \ No newline at end of file diff --git a/Core/Resgrid.Model/Repositories/ICommandDefinitionRepository.cs b/Core/Resgrid.Model/Repositories/ICommandDefinitionRepository.cs index c145966ec..83b011bb4 100644 --- a/Core/Resgrid.Model/Repositories/ICommandDefinitionRepository.cs +++ b/Core/Resgrid.Model/Repositories/ICommandDefinitionRepository.cs @@ -1,4 +1,7 @@ -namespace Resgrid.Model.Repositories +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Resgrid.Model.Repositories { /// /// Interface ICommandDefinitionRepository @@ -8,4 +11,49 @@ public interface ICommandDefinitionRepository: IRepository { } + + /// + /// Repository for the lanes/assignments () of a command definition template. + /// + public interface ICommandDefinitionRoleRepository : IRepository + { + /// + /// Gets all lanes/assignments for a command definition template. + /// + /// The command definition identifier. + /// Task<IEnumerable<CommandDefinitionRole>>. + Task> GetRolesByCommandDefinitionIdAsync(int commandDefinitionId); + } + + /// + /// Repository for a lane's required unit types (). + /// + public interface ICommandDefinitionRoleUnitTypeRepository : IRepository + { + /// + /// Gets the required unit types for every lane of a command definition template. + /// + Task> GetUnitTypesByCommandDefinitionIdAsync(int commandDefinitionId); + + /// + /// Gets the required unit types for a single lane. + /// + Task> GetUnitTypesByRoleIdAsync(int commandDefinitionRoleId); + } + + /// + /// Repository for a lane's required personnel roles (). + /// + public interface ICommandDefinitionRolePersonnelRoleRepository : IRepository + { + /// + /// Gets the required personnel roles for every lane of a command definition template. + /// + Task> GetPersonnelRolesByCommandDefinitionIdAsync(int commandDefinitionId); + + /// + /// Gets the required personnel roles for a single lane. + /// + Task> GetPersonnelRolesByRoleIdAsync(int commandDefinitionRoleId); + } } diff --git a/Core/Resgrid.Model/Services/ICommandsService.cs b/Core/Resgrid.Model/Services/ICommandsService.cs index c15fc9982..0d5ebd0f3 100644 --- a/Core/Resgrid.Model/Services/ICommandsService.cs +++ b/Core/Resgrid.Model/Services/ICommandsService.cs @@ -36,6 +36,14 @@ public interface ICommandsService /// Task<CommandDefinition>. Task GetCommandForCallTypeAsync(int departmentId, int? callTypeId); + /// + /// Gets a single lane (role) with its requirement sets (RequiredUnitTypes/RequiredRoles) hydrated. + /// Used to enforce lane requirements when assigning resources on the live command board. + /// + /// The lane (command definition role) identifier. + /// Task<CommandDefinitionRole>. + Task GetRoleWithRequirementsAsync(int commandDefinitionRoleId); + /// /// Deletes the specified command definition. /// diff --git a/Core/Resgrid.Services/CommandsService.cs b/Core/Resgrid.Services/CommandsService.cs index fb9bf89b7..2c100f17e 100644 --- a/Core/Resgrid.Services/CommandsService.cs +++ b/Core/Resgrid.Services/CommandsService.cs @@ -1,9 +1,10 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using Resgrid.Model; using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Queries; using Resgrid.Model.Services; namespace Resgrid.Services @@ -11,10 +12,22 @@ namespace Resgrid.Services public class CommandsService: ICommandsService { private readonly ICommandDefinitionRepository _commandDefinitionRepository; + private readonly ICommandDefinitionRoleRepository _commandDefinitionRoleRepository; + private readonly ICommandDefinitionRoleUnitTypeRepository _commandDefinitionRoleUnitTypeRepository; + private readonly ICommandDefinitionRolePersonnelRoleRepository _commandDefinitionRolePersonnelRoleRepository; + private readonly IUnitOfWork _unitOfWork; - public CommandsService(ICommandDefinitionRepository commandDefinitionRepository) + public CommandsService(ICommandDefinitionRepository commandDefinitionRepository, + ICommandDefinitionRoleRepository commandDefinitionRoleRepository, + ICommandDefinitionRoleUnitTypeRepository commandDefinitionRoleUnitTypeRepository, + ICommandDefinitionRolePersonnelRoleRepository commandDefinitionRolePersonnelRoleRepository, + IUnitOfWork unitOfWork) { _commandDefinitionRepository = commandDefinitionRepository; + _commandDefinitionRoleRepository = commandDefinitionRoleRepository; + _commandDefinitionRoleUnitTypeRepository = commandDefinitionRoleUnitTypeRepository; + _commandDefinitionRolePersonnelRoleRepository = commandDefinitionRolePersonnelRoleRepository; + _unitOfWork = unitOfWork; } public async Task> GetAllCommandsForDepartmentAsync(int departmentId) @@ -22,19 +35,81 @@ public async Task> GetAllCommandsForDepartmentAsync(int var items = await _commandDefinitionRepository.GetAllByDepartmentIdAsync(departmentId); if (items != null && items.Any()) - return items.ToList(); + { + var list = items.ToList(); + + foreach (var command in list) + await HydrateAssignmentsAsync(command); + + return list; + } return new List(); } public async Task Save(CommandDefinition command, CancellationToken cancellationToken = default(CancellationToken)) { - return await _commandDefinitionRepository.SaveOrUpdateAsync(command, cancellationToken); + // Reconcile the definition and all of its lanes/requirements on one shared connection and + // transaction so a mid-flight failure can't leave a half-reconciled board, and so the + // existing-lane snapshot below is read under the same transaction that mutates it (a + // concurrent save can't reconcile against a snapshot this one is changing out from under it). + _unitOfWork.CreateOrGetConnection(); + + try + { + // SaveOrUpdateAsync only persists the parent row (Assignments is in IgnoredProperties), + // so the lanes are reconciled explicitly below. + var incomingAssignments = command.Assignments; + var saved = await _commandDefinitionRepository.SaveOrUpdateAsync(command, cancellationToken); + + // null = caller didn't load/touch the lanes; leave them alone. An empty list clears them. + if (incomingAssignments != null) + { + var existing = (await _commandDefinitionRoleRepository.GetRolesByCommandDefinitionIdAsync(saved.CommandDefinitionId))?.ToList() + ?? new List(); + + var incomingIds = incomingAssignments + .Where(x => x.CommandDefinitionRoleId > 0) + .Select(x => x.CommandDefinitionRoleId) + .ToHashSet(); + + foreach (var removed in existing.Where(x => !incomingIds.Contains(x.CommandDefinitionRoleId))) + await DeleteRoleWithRequirementsAsync(removed, cancellationToken); + + var savedAssignments = new List(); + foreach (var assignment in incomingAssignments) + { + // A role id from another definition can't be hijacked to move/overwrite that row. + if (assignment.CommandDefinitionRoleId > 0 && existing.All(x => x.CommandDefinitionRoleId != assignment.CommandDefinitionRoleId)) + assignment.CommandDefinitionRoleId = 0; + + var incomingUnitTypes = assignment.RequiredUnitTypes; + var incomingRoles = assignment.RequiredRoles; + + assignment.CommandDefinitionId = saved.CommandDefinitionId; + var savedAssignment = await _commandDefinitionRoleRepository.SaveOrUpdateAsync(assignment, cancellationToken); + + await ReconcileRoleRequirementsAsync(savedAssignment, incomingUnitTypes, incomingRoles, cancellationToken); + savedAssignments.Add(savedAssignment); + } + + saved.Assignments = savedAssignments.OrderBy(x => x.SortOrder).ToList(); + } + + _unitOfWork.CommitChanges(); + return saved; + } + catch + { + _unitOfWork.DiscardChanges(); + throw; + } } public async Task GetCommandByIdAsync(int commandDefinitionId) { - return await _commandDefinitionRepository.GetByIdAsync(commandDefinitionId); + var command = await _commandDefinitionRepository.GetByIdAsync(commandDefinitionId); + return await HydrateAssignmentsAsync(command); } public async Task GetCommandForCallTypeAsync(int departmentId, int? callTypeId) @@ -50,16 +125,151 @@ public async Task GetCommandForCallTypeAsync(int departmentId { var match = list.FirstOrDefault(x => x.CallTypeId == callTypeId.Value); if (match != null) - return match; + return await HydrateAssignmentsAsync(match); } // Fall back to the department's "Any Call Type" definition (CallTypeId == null) - return list.FirstOrDefault(x => !x.CallTypeId.HasValue); + return await HydrateAssignmentsAsync(list.FirstOrDefault(x => !x.CallTypeId.HasValue)); + } + + public async Task GetRoleWithRequirementsAsync(int commandDefinitionRoleId) + { + var role = await _commandDefinitionRoleRepository.GetByIdAsync(commandDefinitionRoleId); + if (role == null) + return null; + + var unitTypes = await _commandDefinitionRoleUnitTypeRepository.GetUnitTypesByRoleIdAsync(commandDefinitionRoleId); + var personnelRoles = await _commandDefinitionRolePersonnelRoleRepository.GetPersonnelRolesByRoleIdAsync(commandDefinitionRoleId); + + role.RequiredUnitTypes = unitTypes?.ToList() ?? new List(); + role.RequiredRoles = personnelRoles?.ToList() ?? new List(); + + return role; } public async Task DeleteAsync(CommandDefinition command, CancellationToken cancellationToken = default(CancellationToken)) { - return await _commandDefinitionRepository.DeleteAsync(command, cancellationToken); + // Delete the lanes, their requirement rows, and the parent definition atomically so a failure + // partway through can't strip a definition's requirements while leaving the definition itself. + _unitOfWork.CreateOrGetConnection(); + + try + { + var roles = await _commandDefinitionRoleRepository.GetRolesByCommandDefinitionIdAsync(command.CommandDefinitionId); + if (roles != null) + { + foreach (var role in roles) + await DeleteRoleWithRequirementsAsync(role, cancellationToken); + } + + var result = await _commandDefinitionRepository.DeleteAsync(command, cancellationToken); + + _unitOfWork.CommitChanges(); + return result; + } + catch + { + _unitOfWork.DiscardChanges(); + throw; + } + } + + private async Task HydrateAssignmentsAsync(CommandDefinition command) + { + if (command == null) + return null; + + var roles = await _commandDefinitionRoleRepository.GetRolesByCommandDefinitionIdAsync(command.CommandDefinitionId); + command.Assignments = roles?.OrderBy(x => x.SortOrder).ToList() ?? new List(); + + if (command.Assignments.Any()) + { + var unitTypes = (await _commandDefinitionRoleUnitTypeRepository.GetUnitTypesByCommandDefinitionIdAsync(command.CommandDefinitionId))?.ToList() + ?? new List(); + var personnelRoles = (await _commandDefinitionRolePersonnelRoleRepository.GetPersonnelRolesByCommandDefinitionIdAsync(command.CommandDefinitionId))?.ToList() + ?? new List(); + + foreach (var role in command.Assignments) + { + role.RequiredUnitTypes = unitTypes.Where(x => x.CommandDefinitionRoleId == role.CommandDefinitionRoleId).ToList(); + role.RequiredRoles = personnelRoles.Where(x => x.CommandDefinitionRoleId == role.CommandDefinitionRoleId).ToList(); + } + } + + return command; + } + + /// + /// Full-replace reconcile of a lane's requirement rows. A null incoming collection means the caller + /// didn't touch that requirement set (leave it alone); an empty collection clears it. + /// + private async Task ReconcileRoleRequirementsAsync(CommandDefinitionRole savedRole, + ICollection incomingUnitTypes, + ICollection incomingRoles, + CancellationToken cancellationToken) + { + if (incomingUnitTypes != null) + { + var existing = await _commandDefinitionRoleUnitTypeRepository.GetUnitTypesByRoleIdAsync(savedRole.CommandDefinitionRoleId); + if (existing != null) + { + foreach (var row in existing) + await _commandDefinitionRoleUnitTypeRepository.DeleteAsync(row, cancellationToken); + } + + var savedUnitTypes = new List(); + foreach (var unitTypeId in incomingUnitTypes.Select(x => x.UnitTypeId).Distinct()) + { + savedUnitTypes.Add(await _commandDefinitionRoleUnitTypeRepository.SaveOrUpdateAsync(new CommandDefinitionRoleUnitType + { + CommandDefinitionRoleId = savedRole.CommandDefinitionRoleId, + UnitTypeId = unitTypeId + }, cancellationToken)); + } + + savedRole.RequiredUnitTypes = savedUnitTypes; + } + + if (incomingRoles != null) + { + var existing = await _commandDefinitionRolePersonnelRoleRepository.GetPersonnelRolesByRoleIdAsync(savedRole.CommandDefinitionRoleId); + if (existing != null) + { + foreach (var row in existing) + await _commandDefinitionRolePersonnelRoleRepository.DeleteAsync(row, cancellationToken); + } + + var savedRoles = new List(); + foreach (var personnelRoleId in incomingRoles.Select(x => x.PersonnelRoleId).Distinct()) + { + savedRoles.Add(await _commandDefinitionRolePersonnelRoleRepository.SaveOrUpdateAsync(new CommandDefinitionRolePersonnelRole + { + CommandDefinitionRoleId = savedRole.CommandDefinitionRoleId, + PersonnelRoleId = personnelRoleId + }, cancellationToken)); + } + + savedRole.RequiredRoles = savedRoles; + } + } + + private async Task DeleteRoleWithRequirementsAsync(CommandDefinitionRole role, CancellationToken cancellationToken) + { + var unitTypes = await _commandDefinitionRoleUnitTypeRepository.GetUnitTypesByRoleIdAsync(role.CommandDefinitionRoleId); + if (unitTypes != null) + { + foreach (var row in unitTypes) + await _commandDefinitionRoleUnitTypeRepository.DeleteAsync(row, cancellationToken); + } + + var personnelRoles = await _commandDefinitionRolePersonnelRoleRepository.GetPersonnelRolesByRoleIdAsync(role.CommandDefinitionRoleId); + if (personnelRoles != null) + { + foreach (var row in personnelRoles) + await _commandDefinitionRolePersonnelRoleRepository.DeleteAsync(row, cancellationToken); + } + + await _commandDefinitionRoleRepository.DeleteAsync(role, cancellationToken); } } } diff --git a/Core/Resgrid.Services/CommunicationService.cs b/Core/Resgrid.Services/CommunicationService.cs index 998bb9caf..dbec0bef3 100644 --- a/Core/Resgrid.Services/CommunicationService.cs +++ b/Core/Resgrid.Services/CommunicationService.cs @@ -93,12 +93,26 @@ public async Task SendMessageAsync(Message message, string sendersName, st spm.DepartmentCode = department?.Code; spm.DepartmentId = departmentId; - if (message.SystemGenerated) - spm.SubTitle = "Msg from System"; + if (message.Type == (int)MessageTypes.WeatherAlert) + { + // Weather alerts carry their own descriptive subject (e.g. "[Severe] Weather Alert: Tornado Warning") + // and headline, so skip the generic "Msg:" prefix and "Msg from System" subtitle. + spm.Title = message.Subject.Truncate(200); + + if (!String.IsNullOrWhiteSpace(message.PushSubTitle)) + spm.SubTitle = message.PushSubTitle.Truncate(200); + else + spm.SubTitle = "Weather Alert"; + } else - spm.SubTitle = string.Format("Msg from {0}", sendersName); + { + if (message.SystemGenerated) + spm.SubTitle = "Msg from System"; + else + spm.SubTitle = string.Format("Msg from {0}", sendersName); - spm.Title = "Msg:" + message.Subject.Truncate(200); + spm.Title = "Msg:" + message.Subject.Truncate(200); + } if (!String.IsNullOrWhiteSpace(message.ReceivingUserId)) diff --git a/Core/Resgrid.Services/IncidentCommandService.cs b/Core/Resgrid.Services/IncidentCommandService.cs index 0356ee10f..665242fd4 100644 --- a/Core/Resgrid.Services/IncidentCommandService.cs +++ b/Core/Resgrid.Services/IncidentCommandService.cs @@ -34,6 +34,8 @@ public class IncidentCommandService : IIncidentCommandService private readonly IIncidentRoleAssignmentRepository _incidentRoleAssignmentRepository; private readonly IEventAggregator _eventAggregator; private readonly ICoreEventService _coreEventService; + private readonly IUnitsService _unitsService; + private readonly IPersonnelRolesService _personnelRolesService; public IncidentCommandService( IIncidentCommandRepository incidentCommandRepository, @@ -50,7 +52,9 @@ public IncidentCommandService( IIncidentVoiceService incidentVoiceService, IIncidentRoleAssignmentRepository incidentRoleAssignmentRepository, IEventAggregator eventAggregator, - ICoreEventService coreEventService) + ICoreEventService coreEventService, + IUnitsService unitsService, + IPersonnelRolesService personnelRolesService) { _incidentCommandRepository = incidentCommandRepository; _commandStructureNodeRepository = commandStructureNodeRepository; @@ -67,6 +71,8 @@ public IncidentCommandService( _incidentRoleAssignmentRepository = incidentRoleAssignmentRepository; _eventAggregator = eventAggregator; _coreEventService = coreEventService; + _unitsService = unitsService; + _personnelRolesService = personnelRolesService; } #region Command lifecycle @@ -84,12 +90,28 @@ public IncidentCommandService( if (existing != null) return existing; + // Resolve the board template. An explicitly supplied id wins (must belong to the caller's + // department — CommandDefinitionId is an auto-increment integer and guessable). Otherwise fall + // back to the department's pre-configured board for the call's type, then to the "Any Call Type" + // definition, so establishing command auto-applies the department's default board layout. + CommandDefinition definition = null; + if (commandDefinitionId.HasValue) + { + definition = await _commandsService.GetCommandByIdAsync(commandDefinitionId.Value); + if (definition == null || definition.DepartmentId != departmentId) + definition = null; + } + else + { + definition = await _commandsService.GetCommandForCallTypeAsync(departmentId, await ResolveCallTypeIdAsync(call)); + } + var command = new IncidentCommand { IncidentCommandId = Guid.NewGuid().ToString(), DepartmentId = departmentId, CallId = callId, - SourceCommandDefinitionId = commandDefinitionId, + SourceCommandDefinitionId = definition?.CommandDefinitionId, EstablishedByUserId = userId, EstablishedOn = DateTime.UtcNow, CurrentCommanderUserId = userId, @@ -120,37 +142,62 @@ public IncidentCommandService( _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) + // Seed lanes from the resolved template so the board opens with the department's + // pre-configured default layout (per-incident editable afterwards). + if (definition?.Assignments != null) { - 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)) { - foreach (var role in definition.Assignments.OrderBy(r => r.SortOrder)) + var node = new CommandStructureNode { - 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.InsertAsync(Touch(node), cancellationToken); - } + 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.InsertAsync(Touch(node), cancellationToken); } } + // Seed the template's interval timer (CommandDefinition.Timer/TimerMinutes) as a running + // incident-scoped benchmark timer. + if (definition != null && definition.Timer && definition.TimerMinutes > 0) + { + await StartTimerAsync(new IncidentTimer + { + IncidentTimerId = Guid.NewGuid().ToString(), + IncidentCommandId = command.IncidentCommandId, + DepartmentId = departmentId, + TimerType = (int)IncidentTimerType.Benchmark, + ScopeType = (int)IncidentTimerScopeType.Incident, + Name = string.IsNullOrWhiteSpace(definition.Name) ? "Command Timer" : $"{definition.Name} Timer", + IntervalSeconds = definition.TimerMinutes * 60 + }, userId, cancellationToken); + } + return command; } + /// + /// Maps a call's free-text Type (calls store the call-type NAME, not the id) back to the department's + /// CallTypeId so the matching command definition template can be resolved. Null when the call has no + /// type or it no longer matches a configured call type. + /// + private async Task ResolveCallTypeIdAsync(Call call) + { + if (string.IsNullOrWhiteSpace(call?.Type)) + return null; + + var types = await _callsService.GetCallTypesForDepartmentAsync(call.DepartmentId); + var match = types?.FirstOrDefault(x => string.Equals(x.Type, call.Type, StringComparison.OrdinalIgnoreCase)); + return match?.CallTypeId; + } + public async Task GetActiveCommandForCallAsync(int departmentId, int callId) { var items = await _incidentCommandRepository.GetAllByDepartmentIdAsync(departmentId); @@ -588,6 +635,27 @@ public async Task> GetNodesForCallAsync(int departmen return null; assignment.CallId = command.CallId; + // Evaluate the target lane's template requirements (unit types / personnel roles). A forced + // violation throws so the API can reject with the reason; an advisory violation is stamped on + // the assignment (RequirementsWarning) for the IC app to render, never blocking the assignment. + assignment.RequirementsWarning = false; + assignment.RequirementsWarningMessage = null; + if (!string.IsNullOrWhiteSpace(assignment.CommandStructureNodeId)) + { + // The lane must live on the SAME incident (department + call) as this assignment; a lane from + // another call is a foreign-incident lane and must not have its requirements applied here. + var node = await _commandStructureNodeRepository.GetByIdAsync(assignment.CommandStructureNodeId); + if (node != null && node.DepartmentId == assignment.DepartmentId && node.CallId == assignment.CallId) + { + var (violation, enforced) = await EvaluateNodeRequirementsAsync(node, assignment.DepartmentId, assignment.ResourceKind, assignment.ResourceId); + if (violation != null && enforced) + throw new CommandRequirementsNotMetException(violation); + + assignment.RequirementsWarning = violation != null; + assignment.RequirementsWarningMessage = violation; + } + } + if (assignment.AssignedOn == default(DateTime)) assignment.AssignedOn = DateTime.UtcNow; assignment.AssignedByUserId = userId; @@ -621,6 +689,15 @@ public async Task> GetNodesForCallAsync(int departmen if (targetNode == null || targetNode.DepartmentId != departmentId || targetNode.CallId != assignment.CallId) return null; + // Evaluate the target lane's template requirements before moving the resource into it: a forced + // violation rejects the move, an advisory one re-stamps the warning for the destination lane. + var (violation, enforced) = await EvaluateNodeRequirementsAsync(targetNode, departmentId, assignment.ResourceKind, assignment.ResourceId); + if (violation != null && enforced) + throw new CommandRequirementsNotMetException(violation); + + assignment.RequirementsWarning = violation != null; + assignment.RequirementsWarningMessage = violation; + assignment.CommandStructureNodeId = targetNodeId; assignment = await _resourceAssignmentRepository.SaveOrUpdateAsync(Touch(assignment), cancellationToken); @@ -643,6 +720,70 @@ public async Task> GetNodesForCallAsync(int departmen return true; } + /// + /// Evaluates a lane's template requirements for a resource: own-department units should match one of + /// the required unit types, and own-department personnel should hold one of the required personnel + /// roles. Returns the violation message (null when compliant / not applicable) and whether the lane + /// FORCES its requirements. Callers throw for forced + /// violations and stamp advisory ones onto the assignment (RequirementsWarning) so the IC app can + /// render a warning on the resource chip. An empty requirement set leaves that resource kind + /// unrestricted; linked-department and ad-hoc resources carry no own-department type/role metadata + /// to validate, so they are never flagged. + /// + private async Task<(string violation, bool enforced)> EvaluateNodeRequirementsAsync(CommandStructureNode node, int departmentId, int resourceKind, string resourceId) + { + if (node == null || !node.SourceRoleId.HasValue) + return (null, false); + + var role = await _commandsService.GetRoleWithRequirementsAsync(node.SourceRoleId.Value); + if (role == null) + return (null, false); + + string violation = null; + + if (resourceKind == (int)ResourceAssignmentKind.RealUnit) + { + var requiredUnitTypes = role.RequiredUnitTypes?.Select(x => x.UnitTypeId).ToList(); + if (requiredUnitTypes == null || requiredUnitTypes.Count == 0) + return (null, role.ForceRequirements); + + Unit unit = null; + if (int.TryParse(resourceId, out var unitId)) + unit = await _unitsService.GetUnitByIdAsync(unitId); + + if (unit == null || unit.DepartmentId != departmentId) + { + // An unresolvable unit cannot be verified against the requirement. + violation = $"Lane '{node.Name}' requires specific unit types and the unit could not be verified."; + } + else + { + // Units store the unit-type NAME, not the id; map it back through the department's unit types. + int? unitTypeId = null; + if (!string.IsNullOrWhiteSpace(unit.Type)) + { + var types = await _unitsService.GetUnitTypesForDepartmentAsync(departmentId); + unitTypeId = types?.FirstOrDefault(x => string.Equals(x.Type, unit.Type, StringComparison.OrdinalIgnoreCase))?.UnitTypeId; + } + + if (!unitTypeId.HasValue || !requiredUnitTypes.Contains(unitTypeId.Value)) + violation = $"Unit '{unit.Name}' does not match the unit types required by lane '{node.Name}'."; + } + } + else if (resourceKind == (int)ResourceAssignmentKind.RealPersonnel) + { + var requiredRoles = role.RequiredRoles?.Select(x => x.PersonnelRoleId).ToList(); + if (requiredRoles == null || requiredRoles.Count == 0) + return (null, role.ForceRequirements); + + var userRoles = await _personnelRolesService.GetRolesForUserAsync(resourceId, departmentId); + if (userRoles == null || !userRoles.Any(x => requiredRoles.Contains(x.PersonnelRoleId))) + violation = $"This member does not hold a personnel role required by lane '{node.Name}'."; + } + + return (violation, role.ForceRequirements); + } + public async Task> GetAssignmentsForCallAsync(int departmentId, int callId) { var items = await _resourceAssignmentRepository.GetAllByDepartmentIdAsync(departmentId); diff --git a/Core/Resgrid.Services/WeatherAlertService.cs b/Core/Resgrid.Services/WeatherAlertService.cs index 56d0e8b7e..1b6751e61 100644 --- a/Core/Resgrid.Services/WeatherAlertService.cs +++ b/Core/Resgrid.Services/WeatherAlertService.cs @@ -433,7 +433,8 @@ public async Task SendPendingNotificationsAsync(CancellationToken ct = default) SentOn = DateTime.UtcNow, SystemGenerated = true, IsBroadcast = true, - Type = 0 + Type = (int)MessageTypes.WeatherAlert, + PushSubTitle = alert.Headline }; await _communicationService.SendMessageAsync(notifyMsg, "Weather Alert System", null, departmentId, null, department); diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0087_RemoveCommandDefinitionRoleCerts.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0087_RemoveCommandDefinitionRoleCerts.cs new file mode 100644 index 000000000..d3852bf8b --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0087_RemoveCommandDefinitionRoleCerts.cs @@ -0,0 +1,25 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Command board lane requirements are unit-type and personnel-role based; the certification + /// requirement concept was removed (never functional). Drops the orphaned link table. + /// + [Migration(87)] + public class M0087_RemoveCommandDefinitionRoleCerts : Migration + { + public override void Up() + { + if (Schema.Table("CommandDefinitionRoleCerts").Exists()) + { + Delete.Table("CommandDefinitionRoleCerts"); + } + } + + public override void Down() + { + // Intentionally not recreated; the certification requirement feature was removed. + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0088_AddResourceAssignmentRequirementsWarning.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0088_AddResourceAssignmentRequirementsWarning.cs new file mode 100644 index 000000000..a8fb8728f --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0088_AddResourceAssignmentRequirementsWarning.cs @@ -0,0 +1,43 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Advisory lane-requirement warnings: when a resource doesn't satisfy a lane's template requirements + /// and the lane does not force them, the violation is stamped on the assignment so the IC app can + /// render a warning indicator on the resource chip (offline sync included). + /// + [Migration(88)] + public class M0088_AddResourceAssignmentRequirementsWarning : Migration + { + public override void Up() + { + if (Schema.Table("ResourceAssignments").Exists()) + { + if (!Schema.Table("ResourceAssignments").Column("RequirementsWarning").Exists()) + { + Alter.Table("ResourceAssignments") + .AddColumn("RequirementsWarning").AsBoolean().NotNullable().WithDefaultValue(false); + } + + if (!Schema.Table("ResourceAssignments").Column("RequirementsWarningMessage").Exists()) + { + Alter.Table("ResourceAssignments") + .AddColumn("RequirementsWarningMessage").AsString(500).Nullable(); + } + } + } + + public override void Down() + { + if (Schema.Table("ResourceAssignments").Exists()) + { + if (Schema.Table("ResourceAssignments").Column("RequirementsWarningMessage").Exists()) + Delete.Column("RequirementsWarningMessage").FromTable("ResourceAssignments"); + + if (Schema.Table("ResourceAssignments").Column("RequirementsWarning").Exists()) + Delete.Column("RequirementsWarning").FromTable("ResourceAssignments"); + } + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Sql/EF0001_PopulateOIDCDb.sql b/Providers/Resgrid.Providers.Migrations/Sql/EF0001_PopulateOIDCDb.sql index 4bd4cbcf8..7316672eb 100644 --- a/Providers/Resgrid.Providers.Migrations/Sql/EF0001_PopulateOIDCDb.sql +++ b/Providers/Resgrid.Providers.Migrations/Sql/EF0001_PopulateOIDCDb.sql @@ -11,6 +11,30 @@ END; BEGIN TRANSACTION; +-- A database provisioned by EF EnsureCreated (Resgrid.Web.Services Worker) has the full schema +-- but no rows in [__EFMigrationsHistory]; stamp the migration row so the guarded CREATE TABLE +-- statements below no-op instead of failing on tables that already exist. Require EVERY table this +-- migration creates to be present (not just [AspNetRoles]) so a partial or legacy schema - e.g. one +-- that already has the ASP.NET Identity tables but not the OpenIddict tables - is NOT falsely stamped +-- as applied, which would skip creating the tables it is still missing. +IF OBJECT_ID(N'[AspNetRoles]') IS NOT NULL + AND OBJECT_ID(N'[AspNetUsers]') IS NOT NULL + AND OBJECT_ID(N'[AspNetRoleClaims]') IS NOT NULL + AND OBJECT_ID(N'[AspNetUserClaims]') IS NOT NULL + AND OBJECT_ID(N'[AspNetUserLogins]') IS NOT NULL + AND OBJECT_ID(N'[AspNetUserRoles]') IS NOT NULL + AND OBJECT_ID(N'[AspNetUserTokens]') IS NOT NULL + AND OBJECT_ID(N'[OpenIddictApplications]') IS NOT NULL + AND OBJECT_ID(N'[OpenIddictScopes]') IS NOT NULL + AND OBJECT_ID(N'[OpenIddictAuthorizations]') IS NOT NULL + AND OBJECT_ID(N'[OpenIddictTokens]') IS NOT NULL + AND NOT EXISTS(SELECT * FROM [__EFMigrationsHistory] WHERE [MigrationId] = N'20210904153137_CreateOpenIddictModels') +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20210904153137_CreateOpenIddictModels', N'5.0.9'); +END; + + IF NOT EXISTS(SELECT * FROM [__EFMigrationsHistory] WHERE [MigrationId] = N'20210904153137_CreateOpenIddictModels') BEGIN CREATE TABLE [AspNetRoles] ( diff --git a/Providers/Resgrid.Providers.Migrations/Sql/EF0003_MigrateOIDCDbToV5.sql b/Providers/Resgrid.Providers.Migrations/Sql/EF0003_MigrateOIDCDbToV5.sql index 9e3d157e9..0a93c3332 100644 --- a/Providers/Resgrid.Providers.Migrations/Sql/EF0003_MigrateOIDCDbToV5.sql +++ b/Providers/Resgrid.Providers.Migrations/Sql/EF0003_MigrateOIDCDbToV5.sql @@ -1,9 +1,13 @@ BEGIN TRANSACTION; +-- Only rename Type -> ClientType when the pre-V5 [Type] column is actually present; a database +-- provisioned by EF EnsureCreated is already V5-shaped (ClientType, ApplicationType, JsonWebKeySet, +-- Settings) and just needs the history row stamped below. IF NOT EXISTS(SELECT * FROM [__EFMigrationsHistory] WHERE [MigrationId] = N'20240412153137_UpdateOpenIddictModelsToV5') + AND COL_LENGTH(N'dbo.OpenIddictApplications', N'Type') IS NOT NULL BEGIN - EXECUTE sp_rename N'dbo.OpenIddictApplications.Type', N'Tmp_ClientType', 'COLUMN' - EXECUTE sp_rename N'dbo.OpenIddictApplications.Tmp_ClientType', N'ClientType', 'COLUMN' + EXECUTE sp_rename N'dbo.OpenIddictApplications.Type', N'Tmp_ClientType', 'COLUMN' + EXECUTE sp_rename N'dbo.OpenIddictApplications.Tmp_ClientType', N'ClientType', 'COLUMN' ALTER TABLE dbo.OpenIddictApplications ADD ApplicationType nvarchar(MAX) NULL, @@ -11,7 +15,15 @@ ALTER TABLE dbo.OpenIddictApplications ADD Settings nvarchar(MAX) NULL END; +-- Only stamp the migration once the schema is genuinely V5: either the rename above just produced the +-- V5 columns (this runs in the same transaction, so the metadata is already visible), or the database +-- was already V5-shaped. Requiring every V5 column here means a database that is neither pre-V5 (it has +-- no [Type] to rename) nor V5 (it is missing these columns) is NOT falsely stamped as migrated. IF NOT EXISTS(SELECT * FROM [__EFMigrationsHistory] WHERE [MigrationId] = N'20240412153137_UpdateOpenIddictModelsToV5') + AND COL_LENGTH(N'dbo.OpenIddictApplications', N'ClientType') IS NOT NULL + AND COL_LENGTH(N'dbo.OpenIddictApplications', N'ApplicationType') IS NOT NULL + AND COL_LENGTH(N'dbo.OpenIddictApplications', N'JsonWebKeySet') IS NOT NULL + AND COL_LENGTH(N'dbo.OpenIddictApplications', N'Settings') IS NOT NULL BEGIN INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) VALUES (N'20240412153137_UpdateOpenIddictModelsToV5', N'5.0.9'); diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0087_RemoveCommandDefinitionRoleCertsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0087_RemoveCommandDefinitionRoleCertsPg.cs new file mode 100644 index 000000000..f93aed154 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0087_RemoveCommandDefinitionRoleCertsPg.cs @@ -0,0 +1,25 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Command board lane requirements are unit-type and personnel-role based; the certification + /// requirement concept was removed (never functional). Drops the orphaned link table. + /// + [Migration(87)] + public class M0087_RemoveCommandDefinitionRoleCertsPg : Migration + { + public override void Up() + { + if (Schema.Table("commanddefinitionrolecerts").Exists()) + { + Delete.Table("commanddefinitionrolecerts"); + } + } + + public override void Down() + { + // Intentionally not recreated; the certification requirement feature was removed. + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0088_AddResourceAssignmentRequirementsWarningPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0088_AddResourceAssignmentRequirementsWarningPg.cs new file mode 100644 index 000000000..05edc4b34 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0088_AddResourceAssignmentRequirementsWarningPg.cs @@ -0,0 +1,43 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Advisory lane-requirement warnings: when a resource doesn't satisfy a lane's template requirements + /// and the lane does not force them, the violation is stamped on the assignment so the IC app can + /// render a warning indicator on the resource chip (offline sync included). + /// + [Migration(88)] + public class M0088_AddResourceAssignmentRequirementsWarningPg : Migration + { + public override void Up() + { + if (Schema.Table("resourceassignments").Exists()) + { + if (!Schema.Table("resourceassignments").Column("requirementswarning").Exists()) + { + Alter.Table("resourceassignments") + .AddColumn("requirementswarning").AsBoolean().NotNullable().WithDefaultValue(false); + } + + if (!Schema.Table("resourceassignments").Column("requirementswarningmessage").Exists()) + { + Alter.Table("resourceassignments") + .AddColumn("requirementswarningmessage").AsString(500).Nullable(); + } + } + } + + public override void Down() + { + if (Schema.Table("resourceassignments").Exists()) + { + if (Schema.Table("resourceassignments").Column("requirementswarningmessage").Exists()) + Delete.Column("requirementswarningmessage").FromTable("resourceassignments"); + + if (Schema.Table("resourceassignments").Column("requirementswarning").Exists()) + Delete.Column("requirementswarning").FromTable("resourceassignments"); + } + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/CommandDefinitionRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/CommandDefinitionRepository.cs index d11c13ba1..e1982b42b 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/CommandDefinitionRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/CommandDefinitionRepository.cs @@ -1,8 +1,16 @@ -using Resgrid.Model; +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Threading.Tasks; +using Dapper; +using Resgrid.Framework; +using Resgrid.Model; using Resgrid.Model.Repositories; +using CommandDefinition = Resgrid.Model.CommandDefinition; using Resgrid.Model.Repositories.Connection; using Resgrid.Model.Repositories.Queries; using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Queries.Commands; namespace Resgrid.Repositories.DataRepository { @@ -22,4 +30,124 @@ public CommandDefinitionRepository(IConnectionProvider connectionProvider, SqlCo _unitOfWork = unitOfWork; } } + + public class CommandDefinitionRoleRepository : RepositoryBase, ICommandDefinitionRoleRepository + { + private readonly IConnectionProvider _connectionProvider; + private readonly SqlConfiguration _sqlConfiguration; + private readonly IQueryFactory _queryFactory; + private readonly IUnitOfWork _unitOfWork; + + public CommandDefinitionRoleRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + { + _connectionProvider = connectionProvider; + _sqlConfiguration = sqlConfiguration; + _queryFactory = queryFactory; + _unitOfWork = unitOfWork; + } + + public async Task> GetRolesByCommandDefinitionIdAsync(int commandDefinitionId) + { + return await SelectAsync( + _connectionProvider, _unitOfWork, _queryFactory, "CommandDefinitionId", commandDefinitionId); + } + + internal static async Task> SelectAsync(IConnectionProvider connectionProvider, + IUnitOfWork unitOfWork, IQueryFactory queryFactory, string parameterName, int parameterValue) + where TQuery : Resgrid.Model.Repositories.Queries.Contracts.ISelectQuery + { + try + { + var selectFunction = new Func>>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + dynamicParameters.Add(parameterName, parameterValue); + + var query = queryFactory.GetQuery(); + + return await x.QueryAsync(sql: query, + param: dynamicParameters, + transaction: unitOfWork?.Transaction); + }); + + DbConnection conn = null; + if (unitOfWork?.Connection == null) + { + using (conn = connectionProvider.Create()) + { + await conn.OpenAsync(); + + return await selectFunction(conn); + } + } + else + { + conn = unitOfWork.CreateOrGetConnection(); + + return await selectFunction(conn); + } + } + catch (Exception ex) + { + Logging.LogException(ex); + + throw; + } + } + } + + public class CommandDefinitionRoleUnitTypeRepository : RepositoryBase, ICommandDefinitionRoleUnitTypeRepository + { + private readonly IConnectionProvider _connectionProvider; + private readonly IQueryFactory _queryFactory; + private readonly IUnitOfWork _unitOfWork; + + public CommandDefinitionRoleUnitTypeRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + { + _connectionProvider = connectionProvider; + _queryFactory = queryFactory; + _unitOfWork = unitOfWork; + } + + public async Task> GetUnitTypesByCommandDefinitionIdAsync(int commandDefinitionId) + { + return await CommandDefinitionRoleRepository.SelectAsync( + _connectionProvider, _unitOfWork, _queryFactory, "CommandDefinitionId", commandDefinitionId); + } + + public async Task> GetUnitTypesByRoleIdAsync(int commandDefinitionRoleId) + { + return await CommandDefinitionRoleRepository.SelectAsync( + _connectionProvider, _unitOfWork, _queryFactory, "CommandDefinitionRoleId", commandDefinitionRoleId); + } + } + + public class CommandDefinitionRolePersonnelRoleRepository : RepositoryBase, ICommandDefinitionRolePersonnelRoleRepository + { + private readonly IConnectionProvider _connectionProvider; + private readonly IQueryFactory _queryFactory; + private readonly IUnitOfWork _unitOfWork; + + public CommandDefinitionRolePersonnelRoleRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + { + _connectionProvider = connectionProvider; + _queryFactory = queryFactory; + _unitOfWork = unitOfWork; + } + + public async Task> GetPersonnelRolesByCommandDefinitionIdAsync(int commandDefinitionId) + { + return await CommandDefinitionRoleRepository.SelectAsync( + _connectionProvider, _unitOfWork, _queryFactory, "CommandDefinitionId", commandDefinitionId); + } + + public async Task> GetPersonnelRolesByRoleIdAsync(int commandDefinitionRoleId) + { + return await CommandDefinitionRoleRepository.SelectAsync( + _connectionProvider, _unitOfWork, _queryFactory, "CommandDefinitionRoleId", commandDefinitionRoleId); + } + } } diff --git a/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs b/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs index c5cf2dce0..1787e2369 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs @@ -572,6 +572,17 @@ protected SqlConfiguration() { } public string SelectCommTestResultByResponseTokenQuery { get; set; } #endregion CommunicationTests + #region Commands + public string CommandDefinitionRolesTable { get; set; } + public string CommandDefinitionRoleUnitTypesTable { get; set; } + public string CommandDefinitionRolePersonnelRolesTable { get; set; } + public string SelectCommandDefinitionRolesByCommandIdQuery { get; set; } + public string SelectCmdRoleUnitTypesByCommandIdQuery { get; set; } + public string SelectCmdRoleUnitTypesByRoleIdQuery { get; set; } + public string SelectCmdRolePersonnelRolesByCommandIdQuery { get; set; } + public string SelectCmdRolePersonnelRolesByRoleIdQuery { get; set; } + #endregion Commands + #region WeatherAlerts public string WeatherAlertSourcesTable { get; set; } public string WeatherAlertsTable { get; set; } diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs index 08eb07c3c..9684c720d 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs @@ -102,6 +102,9 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs index ab5e67b3e..81f1f4360 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs @@ -106,6 +106,9 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs index 21c124471..3fb5623b8 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs @@ -101,6 +101,9 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs index 63ba660e6..18ed1e97d 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs @@ -101,6 +101,9 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Commands/SelectCmdRolePersonnelRolesByCommandIdQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Commands/SelectCmdRolePersonnelRolesByCommandIdQuery.cs new file mode 100644 index 000000000..5f8d74c45 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Commands/SelectCmdRolePersonnelRolesByCommandIdQuery.cs @@ -0,0 +1,35 @@ +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; + +namespace Resgrid.Repositories.DataRepository.Queries.Commands +{ + public class SelectCmdRolePersonnelRolesByCommandIdQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectCmdRolePersonnelRolesByCommandIdQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectCmdRolePersonnelRolesByCommandIdQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + _sqlConfiguration.CommandDefinitionRolePersonnelRolesTable, + _sqlConfiguration.ParameterNotation, + new string[] { "%COMMANDDEFINITIONID%" }, + new string[] { "CommandDefinitionId" }, + new string[] { "%ROLESTABLE%" }, + new string[] { _sqlConfiguration.CommandDefinitionRolesTable }); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + throw new System.NotImplementedException(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Commands/SelectCmdRolePersonnelRolesByRoleIdQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Commands/SelectCmdRolePersonnelRolesByRoleIdQuery.cs new file mode 100644 index 000000000..3c76bde5b --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Commands/SelectCmdRolePersonnelRolesByRoleIdQuery.cs @@ -0,0 +1,33 @@ +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; + +namespace Resgrid.Repositories.DataRepository.Queries.Commands +{ + public class SelectCmdRolePersonnelRolesByRoleIdQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectCmdRolePersonnelRolesByRoleIdQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectCmdRolePersonnelRolesByRoleIdQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + _sqlConfiguration.CommandDefinitionRolePersonnelRolesTable, + _sqlConfiguration.ParameterNotation, + new string[] { "%COMMANDDEFINITIONROLEID%" }, + new string[] { "CommandDefinitionRoleId" }); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + throw new System.NotImplementedException(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Commands/SelectCmdRoleUnitTypesByCommandIdQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Commands/SelectCmdRoleUnitTypesByCommandIdQuery.cs new file mode 100644 index 000000000..d0adbeaa9 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Commands/SelectCmdRoleUnitTypesByCommandIdQuery.cs @@ -0,0 +1,35 @@ +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; + +namespace Resgrid.Repositories.DataRepository.Queries.Commands +{ + public class SelectCmdRoleUnitTypesByCommandIdQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectCmdRoleUnitTypesByCommandIdQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectCmdRoleUnitTypesByCommandIdQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + _sqlConfiguration.CommandDefinitionRoleUnitTypesTable, + _sqlConfiguration.ParameterNotation, + new string[] { "%COMMANDDEFINITIONID%" }, + new string[] { "CommandDefinitionId" }, + new string[] { "%ROLESTABLE%" }, + new string[] { _sqlConfiguration.CommandDefinitionRolesTable }); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + throw new System.NotImplementedException(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Commands/SelectCmdRoleUnitTypesByRoleIdQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Commands/SelectCmdRoleUnitTypesByRoleIdQuery.cs new file mode 100644 index 000000000..1a6f5ffb2 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Commands/SelectCmdRoleUnitTypesByRoleIdQuery.cs @@ -0,0 +1,33 @@ +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; + +namespace Resgrid.Repositories.DataRepository.Queries.Commands +{ + public class SelectCmdRoleUnitTypesByRoleIdQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectCmdRoleUnitTypesByRoleIdQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectCmdRoleUnitTypesByRoleIdQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + _sqlConfiguration.CommandDefinitionRoleUnitTypesTable, + _sqlConfiguration.ParameterNotation, + new string[] { "%COMMANDDEFINITIONROLEID%" }, + new string[] { "CommandDefinitionRoleId" }); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + throw new System.NotImplementedException(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Commands/SelectCommandDefinitionRolesByCommandIdQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Commands/SelectCommandDefinitionRolesByCommandIdQuery.cs new file mode 100644 index 000000000..28ce39157 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Commands/SelectCommandDefinitionRolesByCommandIdQuery.cs @@ -0,0 +1,33 @@ +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; + +namespace Resgrid.Repositories.DataRepository.Queries.Commands +{ + public class SelectCommandDefinitionRolesByCommandIdQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectCommandDefinitionRolesByCommandIdQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectCommandDefinitionRolesByCommandIdQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + _sqlConfiguration.CommandDefinitionRolesTable, + _sqlConfiguration.ParameterNotation, + new string[] { "%COMMANDDEFINITIONID%" }, + new string[] { "CommandDefinitionId" }); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + throw new System.NotImplementedException(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs b/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs index 30181b76b..85c6eb09c 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs @@ -1798,6 +1798,17 @@ ORDER BY Timestamp DESC SelectCommTestResultByResponseTokenQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE ResponseToken = %TOKEN% LIMIT 1"; #endregion CommunicationTests + #region Commands + CommandDefinitionRolesTable = "CommandDefinitionRoles"; + CommandDefinitionRoleUnitTypesTable = "CommandDefinitionRoleUnitTypes"; + CommandDefinitionRolePersonnelRolesTable = "CommandDefinitionRolePersonnelRoles"; + SelectCommandDefinitionRolesByCommandIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE CommandDefinitionId = %COMMANDDEFINITIONID% ORDER BY SortOrder"; + SelectCmdRoleUnitTypesByCommandIdQuery = "SELECT rut.* FROM %SCHEMA%.%TABLENAME% rut INNER JOIN %SCHEMA%.%ROLESTABLE% r ON r.CommandDefinitionRoleId = rut.CommandDefinitionRoleId WHERE r.CommandDefinitionId = %COMMANDDEFINITIONID%"; + SelectCmdRoleUnitTypesByRoleIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE CommandDefinitionRoleId = %COMMANDDEFINITIONROLEID%"; + SelectCmdRolePersonnelRolesByCommandIdQuery = "SELECT rpr.* FROM %SCHEMA%.%TABLENAME% rpr INNER JOIN %SCHEMA%.%ROLESTABLE% r ON r.CommandDefinitionRoleId = rpr.CommandDefinitionRoleId WHERE r.CommandDefinitionId = %COMMANDDEFINITIONID%"; + SelectCmdRolePersonnelRolesByRoleIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE CommandDefinitionRoleId = %COMMANDDEFINITIONROLEID%"; + #endregion Commands + #region WeatherAlerts WeatherAlertSourcesTable = "WeatherAlertSources"; WeatherAlertsTable = "WeatherAlerts"; diff --git a/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs b/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs index e1a2e871f..4942e478a 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs @@ -1748,6 +1748,17 @@ SELECT TOP 1 * SelectCommTestResultByResponseTokenQuery = "SELECT TOP 1 * FROM %SCHEMA%.%TABLENAME% WHERE [ResponseToken] = %TOKEN%"; #endregion CommunicationTests + #region Commands + CommandDefinitionRolesTable = "CommandDefinitionRoles"; + CommandDefinitionRoleUnitTypesTable = "CommandDefinitionRoleUnitTypes"; + CommandDefinitionRolePersonnelRolesTable = "CommandDefinitionRolePersonnelRoles"; + SelectCommandDefinitionRolesByCommandIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE [CommandDefinitionId] = %COMMANDDEFINITIONID% ORDER BY [SortOrder]"; + SelectCmdRoleUnitTypesByCommandIdQuery = "SELECT rut.* FROM %SCHEMA%.%TABLENAME% rut INNER JOIN %SCHEMA%.%ROLESTABLE% r ON r.[CommandDefinitionRoleId] = rut.[CommandDefinitionRoleId] WHERE r.[CommandDefinitionId] = %COMMANDDEFINITIONID%"; + SelectCmdRoleUnitTypesByRoleIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE [CommandDefinitionRoleId] = %COMMANDDEFINITIONROLEID%"; + SelectCmdRolePersonnelRolesByCommandIdQuery = "SELECT rpr.* FROM %SCHEMA%.%TABLENAME% rpr INNER JOIN %SCHEMA%.%ROLESTABLE% r ON r.[CommandDefinitionRoleId] = rpr.[CommandDefinitionRoleId] WHERE r.[CommandDefinitionId] = %COMMANDDEFINITIONID%"; + SelectCmdRolePersonnelRolesByRoleIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE [CommandDefinitionRoleId] = %COMMANDDEFINITIONROLEID%"; + #endregion Commands + #region WeatherAlerts WeatherAlertSourcesTable = "WeatherAlertSources"; WeatherAlertsTable = "WeatherAlerts"; diff --git a/Tests/Resgrid.Tests/Services/CommandsServiceTests.cs b/Tests/Resgrid.Tests/Services/CommandsServiceTests.cs new file mode 100644 index 000000000..7916f918d --- /dev/null +++ b/Tests/Resgrid.Tests/Services/CommandsServiceTests.cs @@ -0,0 +1,256 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Services; +using Resgrid.Tests.Mocks; + +namespace Resgrid.Tests.Services +{ + /// + /// Covers : command definition (pre-configured command board) templates. + /// Focuses on lane (CommandDefinitionRole) hydration, call-type template resolution with the + /// "Any Call Type" fallback, and the Save reconcile (insert/update/delete of lanes). + /// + [TestFixture] + public class CommandsServiceTests + { + private const int Dept = 44; + + private Mock _definitions; + private Mock _roles; + private Mock _roleUnitTypes; + private Mock _rolePersonnelRoles; + private CommandsService _service; + + [SetUp] + public void SetUp() + { + _definitions = new Mock(); + _roles = new Mock(); + _roleUnitTypes = new Mock(); + _rolePersonnelRoles = new Mock(); + + _roleUnitTypes.Setup(x => x.GetUnitTypesByCommandDefinitionIdAsync(It.IsAny())) + .ReturnsAsync(new List()); + _roleUnitTypes.Setup(x => x.GetUnitTypesByRoleIdAsync(It.IsAny())) + .ReturnsAsync(new List()); + _rolePersonnelRoles.Setup(x => x.GetPersonnelRolesByCommandDefinitionIdAsync(It.IsAny())) + .ReturnsAsync(new List()); + _rolePersonnelRoles.Setup(x => x.GetPersonnelRolesByRoleIdAsync(It.IsAny())) + .ReturnsAsync(new List()); + + _service = new CommandsService(_definitions.Object, _roles.Object, _roleUnitTypes.Object, _rolePersonnelRoles.Object, new MockUnitOfWork()); + } + + [Test] + public async Task GetCommandForCallTypeAsync_MatchesCallType_AndHydratesLanes() + { + _definitions.Setup(x => x.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List + { + new CommandDefinition { CommandDefinitionId = 1, DepartmentId = Dept, CallTypeId = null }, + new CommandDefinition { CommandDefinitionId = 2, DepartmentId = Dept, CallTypeId = 7 } + }); + + _roles.Setup(x => x.GetRolesByCommandDefinitionIdAsync(2)).ReturnsAsync(new List + { + new CommandDefinitionRole { CommandDefinitionRoleId = 20, CommandDefinitionId = 2, Name = "RIT", SortOrder = 1 }, + new CommandDefinitionRole { CommandDefinitionRoleId = 21, CommandDefinitionId = 2, Name = "Interior", SortOrder = 0 } + }); + + var result = await _service.GetCommandForCallTypeAsync(Dept, 7); + + result.Should().NotBeNull(); + result.CommandDefinitionId.Should().Be(2); + result.Assignments.Should().HaveCount(2); + result.Assignments.First().Name.Should().Be("Interior"); // ordered by SortOrder + } + + [Test] + public async Task GetCommandForCallTypeAsync_FallsBackToAnyCallTypeDefinition() + { + _definitions.Setup(x => x.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List + { + new CommandDefinition { CommandDefinitionId = 1, DepartmentId = Dept, CallTypeId = null }, + new CommandDefinition { CommandDefinitionId = 2, DepartmentId = Dept, CallTypeId = 7 } + }); + + _roles.Setup(x => x.GetRolesByCommandDefinitionIdAsync(It.IsAny())) + .ReturnsAsync(new List()); + + var result = await _service.GetCommandForCallTypeAsync(Dept, 99); + + result.Should().NotBeNull(); + result.CommandDefinitionId.Should().Be(1); + } + + [Test] + public async Task Save_ReconcilesLanes_DeletesRemoved_UpsertsIncoming_AndStampsDefinitionId() + { + var command = new CommandDefinition + { + CommandDefinitionId = 5, + DepartmentId = Dept, + Name = "House Fire", + Assignments = new List + { + new CommandDefinitionRole { CommandDefinitionRoleId = 50, Name = "Interior (renamed)" }, + new CommandDefinitionRole { CommandDefinitionRoleId = 0, Name = "Staging" } + } + }; + + _definitions.Setup(x => x.SaveOrUpdateAsync(command, It.IsAny(), false)) + .ReturnsAsync(command); + + // Row 50 stays (updated), row 51 was removed by the caller and must be deleted. + _roles.Setup(x => x.GetRolesByCommandDefinitionIdAsync(5)).ReturnsAsync(new List + { + new CommandDefinitionRole { CommandDefinitionRoleId = 50, CommandDefinitionId = 5, Name = "Interior" }, + new CommandDefinitionRole { CommandDefinitionRoleId = 51, CommandDefinitionId = 5, Name = "Old Lane" } + }); + + _roles.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), false)) + .ReturnsAsync((CommandDefinitionRole r, CancellationToken _, bool __) => r); + + var saved = await _service.Save(command, CancellationToken.None); + + _roles.Verify(x => x.DeleteAsync(It.Is(r => r.CommandDefinitionRoleId == 51), It.IsAny()), Times.Once); + _roles.Verify(x => x.SaveOrUpdateAsync(It.Is(r => r.CommandDefinitionRoleId == 50 && r.CommandDefinitionId == 5), It.IsAny(), false), Times.Once); + _roles.Verify(x => x.SaveOrUpdateAsync(It.Is(r => r.Name == "Staging" && r.CommandDefinitionId == 5), It.IsAny(), false), Times.Once); + saved.Assignments.Should().HaveCount(2); + } + + [Test] + public async Task Save_ForeignRoleId_IsTreatedAsInsert_NotHijackedUpdate() + { + var command = new CommandDefinition + { + CommandDefinitionId = 5, + DepartmentId = Dept, + Assignments = new List + { + // Claims an id that does NOT belong to definition 5. + new CommandDefinitionRole { CommandDefinitionRoleId = 999, Name = "Sneaky" } + } + }; + + _definitions.Setup(x => x.SaveOrUpdateAsync(command, It.IsAny(), false)) + .ReturnsAsync(command); + _roles.Setup(x => x.GetRolesByCommandDefinitionIdAsync(5)).ReturnsAsync(new List()); + _roles.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), false)) + .ReturnsAsync((CommandDefinitionRole r, CancellationToken _, bool __) => r); + + await _service.Save(command, CancellationToken.None); + + _roles.Verify(x => x.SaveOrUpdateAsync(It.Is(r => r.CommandDefinitionRoleId == 0 && r.Name == "Sneaky"), It.IsAny(), false), Times.Once); + } + + [Test] + public async Task Save_NullAssignments_LeavesExistingLanesUntouched() + { + var command = new CommandDefinition { CommandDefinitionId = 5, DepartmentId = Dept, Assignments = null }; + + _definitions.Setup(x => x.SaveOrUpdateAsync(command, It.IsAny(), false)) + .ReturnsAsync(command); + + await _service.Save(command, CancellationToken.None); + + _roles.Verify(x => x.DeleteAsync(It.IsAny(), It.IsAny()), Times.Never); + _roles.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task Save_PersistsLaneRequirements_ReplacingExisting() + { + var command = new CommandDefinition + { + CommandDefinitionId = 5, + DepartmentId = Dept, + Assignments = new List + { + new CommandDefinitionRole + { + CommandDefinitionRoleId = 50, + Name = "RIT", + // duplicate unit type id collapses to one row + RequiredUnitTypes = new List + { + new CommandDefinitionRoleUnitType { UnitTypeId = 7 }, + new CommandDefinitionRoleUnitType { UnitTypeId = 7 } + }, + RequiredRoles = new List + { + new CommandDefinitionRolePersonnelRole { PersonnelRoleId = 3 } + } + } + } + }; + + _definitions.Setup(x => x.SaveOrUpdateAsync(command, It.IsAny(), false)) + .ReturnsAsync(command); + _roles.Setup(x => x.GetRolesByCommandDefinitionIdAsync(5)).ReturnsAsync(new List + { + new CommandDefinitionRole { CommandDefinitionRoleId = 50, CommandDefinitionId = 5, Name = "RIT" } + }); + _roles.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), false)) + .ReturnsAsync((CommandDefinitionRole r, CancellationToken _, bool __) => r); + + // A stale requirement row exists and must be replaced. + var staleUnitType = new CommandDefinitionRoleUnitType { CommandDefinitionRoleUnitTypeId = 1, CommandDefinitionRoleId = 50, UnitTypeId = 9 }; + _roleUnitTypes.Setup(x => x.GetUnitTypesByRoleIdAsync(50)).ReturnsAsync(new List { staleUnitType }); + _roleUnitTypes.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), false)) + .ReturnsAsync((CommandDefinitionRoleUnitType r, CancellationToken _, bool __) => r); + _rolePersonnelRoles.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), false)) + .ReturnsAsync((CommandDefinitionRolePersonnelRole r, CancellationToken _, bool __) => r); + + await _service.Save(command, CancellationToken.None); + + _roleUnitTypes.Verify(x => x.DeleteAsync(staleUnitType, It.IsAny()), Times.Once); + _roleUnitTypes.Verify(x => x.SaveOrUpdateAsync(It.Is(r => r.UnitTypeId == 7 && r.CommandDefinitionRoleId == 50), It.IsAny(), false), Times.Once); + _rolePersonnelRoles.Verify(x => x.SaveOrUpdateAsync(It.Is(r => r.PersonnelRoleId == 3 && r.CommandDefinitionRoleId == 50), It.IsAny(), false), Times.Once); + } + + [Test] + public async Task GetRoleWithRequirementsAsync_HydratesRequirementSets() + { + _roles.Setup(x => x.GetByIdAsync(50)).ReturnsAsync(new CommandDefinitionRole { CommandDefinitionRoleId = 50, ForceRequirements = true }); + _roleUnitTypes.Setup(x => x.GetUnitTypesByRoleIdAsync(50)).ReturnsAsync(new List + { + new CommandDefinitionRoleUnitType { CommandDefinitionRoleId = 50, UnitTypeId = 7 } + }); + _rolePersonnelRoles.Setup(x => x.GetPersonnelRolesByRoleIdAsync(50)).ReturnsAsync(new List + { + new CommandDefinitionRolePersonnelRole { CommandDefinitionRoleId = 50, PersonnelRoleId = 3 } + }); + + var role = await _service.GetRoleWithRequirementsAsync(50); + + role.Should().NotBeNull(); + role.RequiredUnitTypes.Select(x => x.UnitTypeId).Should().BeEquivalentTo(new[] { 7 }); + role.RequiredRoles.Select(x => x.PersonnelRoleId).Should().BeEquivalentTo(new[] { 3 }); + } + + [Test] + public async Task DeleteAsync_DeletesChildLanes_BeforeTheDefinition() + { + var command = new CommandDefinition { CommandDefinitionId = 5, DepartmentId = Dept }; + + _roles.Setup(x => x.GetRolesByCommandDefinitionIdAsync(5)).ReturnsAsync(new List + { + new CommandDefinitionRole { CommandDefinitionRoleId = 50, CommandDefinitionId = 5 } + }); + _definitions.Setup(x => x.DeleteAsync(command, It.IsAny())).ReturnsAsync(true); + + var result = await _service.DeleteAsync(command, CancellationToken.None); + + result.Should().BeTrue(); + _roles.Verify(x => x.DeleteAsync(It.Is(r => r.CommandDefinitionRoleId == 50), It.IsAny()), Times.Once); + _definitions.Verify(x => x.DeleteAsync(command, It.IsAny()), Times.Once); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/IncidentCommandServiceParTests.cs b/Tests/Resgrid.Tests/Services/IncidentCommandServiceParTests.cs index a8639977b..1bfcdb857 100644 --- a/Tests/Resgrid.Tests/Services/IncidentCommandServiceParTests.cs +++ b/Tests/Resgrid.Tests/Services/IncidentCommandServiceParTests.cs @@ -41,6 +41,8 @@ public class IncidentCommandServiceParTests private Mock _roleRepo; private Mock _eventAggregator; private Mock _coreEventService; + private Mock _unitsService; + private Mock _personnelRolesService; private IncidentCommandService _service; [SetUp] @@ -61,6 +63,8 @@ public void SetUp() _roleRepo = new Mock(); _eventAggregator = new Mock(); _coreEventService = new Mock(); + _unitsService = new Mock(); + _personnelRolesService = new Mock(); // Timeline entries are append-only inserts; echo back the entry so WriteLogAsync resolves a non-null result. _logRepo.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) @@ -69,7 +73,8 @@ public void SetUp() _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); + _roleRepo.Object, _eventAggregator.Object, _coreEventService.Object, + _unitsService.Object, _personnelRolesService.Object); } private void ArrangeCall(bool checkInTimersEnabled = true, int departmentId = Dept) @@ -422,6 +427,205 @@ public async Task MoveResourceAsync_ReturnsNull_WhenTargetNodeMissing() result.Should().BeNull(); } + // Forced lane requirements (§ command board templates): when a lane's source role has + // ForceRequirements, own-department units must match a required unit type and personnel must + // hold a required personnel role; violations throw CommandRequirementsNotMetException. + + private void ArrangeForcedLane(string nodeId = "node-1", int sourceRoleId = 50, + List requiredUnitTypes = null, List requiredRoles = null, bool force = true) + { + _commandRepo.Setup(x => x.GetByIdAsync("ic1")).ReturnsAsync(new IncidentCommand + { + IncidentCommandId = "ic1", DepartmentId = Dept, CallId = CallId, Status = (int)IncidentCommandStatus.Active + }); + + _nodeRepo.Setup(x => x.GetByIdAsync(nodeId)).ReturnsAsync(new CommandStructureNode + { + CommandStructureNodeId = nodeId, DepartmentId = Dept, CallId = CallId, Name = "RIT", SourceRoleId = sourceRoleId + }); + + _commandsService.Setup(x => x.GetRoleWithRequirementsAsync(sourceRoleId)).ReturnsAsync(new CommandDefinitionRole + { + CommandDefinitionRoleId = sourceRoleId, + Name = "RIT", + ForceRequirements = force, + RequiredUnitTypes = (requiredUnitTypes ?? new List()) + .Select(id => new CommandDefinitionRoleUnitType { CommandDefinitionRoleId = sourceRoleId, UnitTypeId = id }).ToList(), + RequiredRoles = (requiredRoles ?? new List()) + .Select(id => new CommandDefinitionRolePersonnelRole { CommandDefinitionRoleId = sourceRoleId, PersonnelRoleId = id }).ToList() + }); + + _assignmentRepo.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((ResourceAssignment a, CancellationToken ct, bool b) => a); + _assignmentRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((ResourceAssignment a, CancellationToken ct, bool b) => a); + } + + private static ResourceAssignment NewAssignment(int kind, string resourceId) => new ResourceAssignment + { + IncidentCommandId = "ic1", + DepartmentId = Dept, + CommandStructureNodeId = "node-1", + ResourceKind = kind, + ResourceId = resourceId + }; + + [Test] + public async Task AssignResourceAsync_Rejects_UnitNotMatchingForcedLaneUnitTypes() + { + ArrangeForcedLane(requiredUnitTypes: new List { 7 }); + _unitsService.Setup(x => x.GetUnitByIdAsync(5)).ReturnsAsync(new Unit { UnitId = 5, DepartmentId = Dept, Name = "Engine 1", Type = "Engine" }); + _unitsService.Setup(x => x.GetUnitTypesForDepartmentAsync(Dept)).ReturnsAsync(new List + { + new UnitType { UnitTypeId = 8, Type = "Engine" } // maps to type 8, lane requires 7 + }); + + Func act = () => _service.AssignResourceAsync(NewAssignment((int)ResourceAssignmentKind.RealUnit, "5"), "user1"); + + await act.Should().ThrowAsync(); + _assignmentRepo.Verify(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task AssignResourceAsync_Allows_UnitMatchingForcedLaneUnitTypes() + { + ArrangeForcedLane(requiredUnitTypes: new List { 7 }); + _unitsService.Setup(x => x.GetUnitByIdAsync(5)).ReturnsAsync(new Unit { UnitId = 5, DepartmentId = Dept, Name = "Engine 1", Type = "Engine" }); + _unitsService.Setup(x => x.GetUnitTypesForDepartmentAsync(Dept)).ReturnsAsync(new List + { + new UnitType { UnitTypeId = 7, Type = "Engine" } + }); + + var saved = await _service.AssignResourceAsync(NewAssignment((int)ResourceAssignmentKind.RealUnit, "5"), "user1"); + + saved.Should().NotBeNull(); + } + + [Test] + public async Task AssignResourceAsync_Rejects_PersonnelWithoutRequiredRole() + { + ArrangeForcedLane(requiredRoles: new List { 3 }); + _personnelRolesService.Setup(x => x.GetRolesForUserAsync("u1", Dept)).ReturnsAsync(new List + { + new PersonnelRole { PersonnelRoleId = 4, Name = "EMT" } // lane requires role 3 + }); + + Func act = () => _service.AssignResourceAsync(NewAssignment((int)ResourceAssignmentKind.RealPersonnel, "u1"), "user1"); + + await act.Should().ThrowAsync(); + } + + [Test] + public async Task AssignResourceAsync_StampsWarning_WhenAdvisoryRequirementsNotMet() + { + // Requirements exist but ForceRequirements is off — the assignment succeeds and the violation + // is stamped as an advisory warning for the IC app to render on the resource chip. + ArrangeForcedLane(requiredUnitTypes: new List { 7 }, force: false); + _unitsService.Setup(x => x.GetUnitByIdAsync(5)).ReturnsAsync(new Unit { UnitId = 5, DepartmentId = Dept, Name = "Engine 1", Type = "Engine" }); + _unitsService.Setup(x => x.GetUnitTypesForDepartmentAsync(Dept)).ReturnsAsync(new List + { + new UnitType { UnitTypeId = 8, Type = "Engine" } // maps to type 8, lane wants 7 + }); + + var saved = await _service.AssignResourceAsync(NewAssignment((int)ResourceAssignmentKind.RealUnit, "5"), "user1"); + + saved.Should().NotBeNull(); + saved.RequirementsWarning.Should().BeTrue(); + saved.RequirementsWarningMessage.Should().Contain("Engine 1"); + } + + [Test] + public async Task AssignResourceAsync_ClearsWarning_WhenAdvisoryRequirementsMet() + { + ArrangeForcedLane(requiredUnitTypes: new List { 7 }, force: false); + _unitsService.Setup(x => x.GetUnitByIdAsync(5)).ReturnsAsync(new Unit { UnitId = 5, DepartmentId = Dept, Name = "Engine 1", Type = "Engine" }); + _unitsService.Setup(x => x.GetUnitTypesForDepartmentAsync(Dept)).ReturnsAsync(new List + { + new UnitType { UnitTypeId = 7, Type = "Engine" } + }); + + // A stale warning from a previous lane must be recomputed away. + var assignment = NewAssignment((int)ResourceAssignmentKind.RealUnit, "5"); + assignment.RequirementsWarning = true; + assignment.RequirementsWarningMessage = "stale"; + + var saved = await _service.AssignResourceAsync(assignment, "user1"); + + saved.Should().NotBeNull(); + saved.RequirementsWarning.Should().BeFalse(); + saved.RequirementsWarningMessage.Should().BeNull(); + } + + [Test] + public async Task MoveResourceAsync_StampsWarning_ForAdvisoryViolation() + { + ArrangeForcedLane(requiredRoles: new List { 3 }, force: false); + _assignmentRepo.Setup(x => x.GetByIdAsync("ra1")).ReturnsAsync(new ResourceAssignment + { + ResourceAssignmentId = "ra1", DepartmentId = Dept, CallId = CallId, IncidentCommandId = "ic1", + ResourceKind = (int)ResourceAssignmentKind.RealPersonnel, ResourceId = "u1" + }); + _personnelRolesService.Setup(x => x.GetRolesForUserAsync("u1", Dept)).ReturnsAsync(new List()); + + var moved = await _service.MoveResourceAsync(Dept, "ra1", "node-1", "user1"); + + moved.Should().NotBeNull(); + moved.CommandStructureNodeId.Should().Be("node-1"); + moved.RequirementsWarning.Should().BeTrue(); + moved.RequirementsWarningMessage.Should().Contain("RIT"); + } + + [Test] + public async Task AssignResourceAsync_DoesNotGate_MutualAidResources() + { + // Linked-department resources carry no own-department type metadata to validate. + ArrangeForcedLane(requiredUnitTypes: new List { 7 }); + + var saved = await _service.AssignResourceAsync(NewAssignment((int)ResourceAssignmentKind.LinkedDeptUnit, "77"), "user1"); + + saved.Should().NotBeNull(); + } + + [Test] + public async Task AssignResourceAsync_IgnoresLaneRequirements_WhenLaneOnDifferentCall() + { + // A lane from another call (same department) is a foreign-incident lane: its requirements must + // not gate an assignment on this incident, so a forced violation there cannot block the assign. + ArrangeForcedLane(requiredUnitTypes: new List { 7 }); + _nodeRepo.Setup(x => x.GetByIdAsync("node-1")).ReturnsAsync(new CommandStructureNode + { + CommandStructureNodeId = "node-1", DepartmentId = Dept, CallId = 999, Name = "Other call lane", SourceRoleId = 50 + }); + _unitsService.Setup(x => x.GetUnitByIdAsync(5)).ReturnsAsync(new Unit { UnitId = 5, DepartmentId = Dept, Name = "Engine 1", Type = "Engine" }); + _unitsService.Setup(x => x.GetUnitTypesForDepartmentAsync(Dept)).ReturnsAsync(new List + { + new UnitType { UnitTypeId = 8, Type = "Engine" } // would violate the foreign lane's required type 7 + }); + + var saved = await _service.AssignResourceAsync(NewAssignment((int)ResourceAssignmentKind.RealUnit, "5"), "user1"); + + saved.Should().NotBeNull(); + saved.RequirementsWarning.Should().BeFalse(); + saved.RequirementsWarningMessage.Should().BeNull(); + } + + [Test] + public async Task MoveResourceAsync_Rejects_WhenTargetLaneForcesRequirements() + { + ArrangeForcedLane(requiredRoles: new List { 3 }); + _assignmentRepo.Setup(x => x.GetByIdAsync("ra1")).ReturnsAsync(new ResourceAssignment + { + ResourceAssignmentId = "ra1", DepartmentId = Dept, CallId = CallId, IncidentCommandId = "ic1", + ResourceKind = (int)ResourceAssignmentKind.RealPersonnel, ResourceId = "u1" + }); + _personnelRolesService.Setup(x => x.GetRolesForUserAsync("u1", Dept)).ReturnsAsync(new List()); + + Func act = () => _service.MoveResourceAsync(Dept, "ra1", "node-1", "user1"); + + await act.Should().ThrowAsync(); + _assignmentRepo.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + // Offline-first change tracking: every mutation stamps ModifiedOn (the delta "changed since" cursor) and a // lane removal is a soft-delete tombstone (DeletedOn), so removals propagate to offline clients on delta sync. diff --git a/Web/Resgrid.Web.Services/Controllers/v4/CommandsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/CommandsController.cs index 0e1d47a34..ec8553d90 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/CommandsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/CommandsController.cs @@ -159,7 +159,7 @@ public async Task> SaveCommand([FromBody] SaveComman { foreach (var lane in input.Lanes) { - command.Assignments.Add(new CommandDefinitionRole + var role = new CommandDefinitionRole { CommandDefinitionRoleId = lane.CommandDefinitionRoleId ?? 0, Name = lane.Name, @@ -172,7 +172,15 @@ public async Task> SaveCommand([FromBody] SaveComman MinTimeInRole = lane.MinTimeInRole, MaxTimeInRole = lane.MaxTimeInRole, ForceRequirements = lane.ForceRequirements - }); + }; + + // The API save is a full document: absent lists clear the lane's requirements. + role.RequiredUnitTypes = (lane.RequiredUnitTypes ?? new List()) + .Select(id => new CommandDefinitionRoleUnitType { UnitTypeId = id }).ToList(); + role.RequiredRoles = (lane.RequiredPersonnelRoles ?? new List()) + .Select(id => new CommandDefinitionRolePersonnelRole { PersonnelRoleId = id }).ToList(); + + command.Assignments.Add(role); } } @@ -241,7 +249,9 @@ private static CommandResultData ConvertCommandData(CommandDefinition command) MaxUnits = lane.MaxUnits, MinTimeInRole = lane.MinTimeInRole, MaxTimeInRole = lane.MaxTimeInRole, - ForceRequirements = lane.ForceRequirements + ForceRequirements = lane.ForceRequirements, + RequiredUnitTypes = lane.RequiredUnitTypes?.Select(x => x.UnitTypeId).ToList() ?? new List(), + RequiredPersonnelRoles = lane.RequiredRoles?.Select(x => x.PersonnelRoleId).ToList() ?? new List() }); } } diff --git a/Web/Resgrid.Web.Services/Controllers/v4/IncidentCommandController.cs b/Web/Resgrid.Web.Services/Controllers/v4/IncidentCommandController.cs index f80300cdc..8682dcd40 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/IncidentCommandController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/IncidentCommandController.cs @@ -261,7 +261,21 @@ public IncidentCommandController(IIncidentCommandService incidentCommandService) assignment.DepartmentId = DepartmentId; var result = new ICModels.ResourceAssignmentResult(); - var saved = await _incidentCommandService.AssignResourceAsync(assignment, UserId, CancellationToken.None); + + Resgrid.Model.ResourceAssignment saved; + try + { + saved = await _incidentCommandService.AssignResourceAsync(assignment, UserId, CancellationToken.None); + } + catch (CommandRequirementsNotMetException ex) + { + Resgrid.Framework.Logging.LogException(ex); + // The target lane forces unit-type/personnel-role requirements the resource doesn't meet. + result.Status = ResponseHelper.Failure; + result.Message = ex.Message; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } if (saved == null) { @@ -271,6 +285,7 @@ public IncidentCommandController(IIncidentCommandService incidentCommandService) } result.Data = saved; + result.Message = saved.RequirementsWarningMessage; // advisory (non-forced) requirements notice, if any result.PageSize = 1; result.Status = ResponseHelper.Success; ResponseHelper.PopulateV4ResponseData(result); @@ -287,7 +302,21 @@ public IncidentCommandController(IIncidentCommandService incidentCommandService) return BadRequest(); var result = new ICModels.ResourceAssignmentResult(); - var assignment = await _incidentCommandService.MoveResourceAsync(DepartmentId, input.ResourceAssignmentId, input.TargetNodeId, UserId, CancellationToken.None); + + Resgrid.Model.ResourceAssignment assignment; + try + { + assignment = await _incidentCommandService.MoveResourceAsync(DepartmentId, input.ResourceAssignmentId, input.TargetNodeId, UserId, CancellationToken.None); + } + catch (CommandRequirementsNotMetException ex) + { + Resgrid.Framework.Logging.LogException(ex); + // The target lane forces unit-type/personnel-role requirements the resource doesn't meet. + result.Status = ResponseHelper.Failure; + result.Message = ex.Message; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } if (assignment == null) { @@ -297,6 +326,7 @@ public IncidentCommandController(IIncidentCommandService incidentCommandService) } result.Data = assignment; + result.Message = assignment.RequirementsWarningMessage; // advisory (non-forced) requirements notice, if any result.PageSize = 1; result.Status = ResponseHelper.Success; ResponseHelper.PopulateV4ResponseData(result); diff --git a/Web/Resgrid.Web.Services/Models/v4/Commands/CommandModels.cs b/Web/Resgrid.Web.Services/Models/v4/Commands/CommandModels.cs index ee1c66af0..c1ff5af0c 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Commands/CommandModels.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Commands/CommandModels.cs @@ -44,7 +44,18 @@ public class CommandRoleResultData public int MaxUnits { get; set; } public int MinTimeInRole { get; set; } public int MaxTimeInRole { get; set; } + + /// + /// When true the API rejects assigning/moving own-department resources into this lane unless + /// they match the requirement lists below; when false the requirements are advisory (UI hints). + /// public bool ForceRequirements { get; set; } + + /// UnitTypeIds a unit must match to satisfy this lane (empty = unrestricted). + public List RequiredUnitTypes { get; set; } = new List(); + + /// PersonnelRoleIds a member must hold (any one) to satisfy this lane (empty = unrestricted). + public List RequiredPersonnelRoles { get; set; } = new List(); } /// @@ -93,5 +104,11 @@ public class SaveCommandLaneInput public int MinTimeInRole { get; set; } public int MaxTimeInRole { get; set; } public bool ForceRequirements { get; set; } + + /// UnitTypeIds required to assign a unit to this lane (empty = unrestricted). + public List RequiredUnitTypes { get; set; } = new List(); + + /// PersonnelRoleIds required (any one) to assign a member to this lane (empty = unrestricted). + public List RequiredPersonnelRoles { get; set; } = new List(); } } diff --git a/Web/Resgrid.Web.Services/Models/v4/IncidentCommand/IncidentCommandModels.cs b/Web/Resgrid.Web.Services/Models/v4/IncidentCommand/IncidentCommandModels.cs index 568242922..f26b9a9bb 100644 --- a/Web/Resgrid.Web.Services/Models/v4/IncidentCommand/IncidentCommandModels.cs +++ b/Web/Resgrid.Web.Services/Models/v4/IncidentCommand/IncidentCommandModels.cs @@ -54,6 +54,13 @@ public class CommandNodeResult : StandardApiResponseV4Base public class ResourceAssignmentResult : StandardApiResponseV4Base { public Resgrid.Model.ResourceAssignment Data { get; set; } + + /// + /// Human-readable requirements notice. On Status=failure: why the assignment was rejected (forced + /// lane requirements not met). On Status=success: an advisory warning when the resource doesn't + /// meet the lane's non-forced requirements (also stamped on Data.RequirementsWarning/-Message). + /// + public string Message { get; set; } } public class TacticalObjectiveResult : StandardApiResponseV4Base diff --git a/Web/Resgrid.Web/Areas/User/Controllers/CommandController.cs b/Web/Resgrid.Web/Areas/User/Controllers/CommandController.cs index de8092007..fece184ae 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/CommandController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/CommandController.cs @@ -1,6 +1,7 @@ -using System.Collections.Generic; -using System.Collections.ObjectModel; +using System; +using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -13,6 +14,11 @@ namespace Resgrid.Web.Areas.User.Controllers { + /// + /// Command definition templates: the department's pre-configured incident command boards. Each + /// definition is keyed to a call type (or "Any Call Type") and its assignments/lanes seed the live + /// command board when command is established on a call of that type. + /// [Area("User")] public class CommandController : SecureBaseController { @@ -20,11 +26,16 @@ public class CommandController : SecureBaseController private readonly ICallsService _callsService; private readonly ICommandsService _commandsService; + private readonly IUnitsService _unitsService; + private readonly IPersonnelRolesService _personnelRolesService; - public CommandController(ICallsService callsService, ICommandsService commandsService) + public CommandController(ICallsService callsService, ICommandsService commandsService, + IUnitsService unitsService, IPersonnelRolesService personnelRolesService) { _callsService = callsService; _commandsService = commandsService; + _unitsService = unitsService; + _personnelRolesService = personnelRolesService; } #endregion Private Members and Constructors @@ -35,6 +46,7 @@ public async Task Index() { var model = new CommandIndexView(); model.Commands = await _commandsService.GetAllCommandsForDepartmentAsync(DepartmentId); + model.CallTypes = await _callsService.GetCallTypesForDepartmentAsync(DepartmentId); return View(model); } @@ -45,102 +57,211 @@ public async Task New() { var model = new NewCommandView(); model.Command = new CommandDefinition(); - model.CallTypes = await _callsService.GetCallTypesForDepartmentAsync(DepartmentId); - - var types = new List(); - types.Add(new CallType() { Type = "Any Call Type" }); - types.AddRange(model.CallTypes); - model.Types = new SelectList(types, "CallTypeId", "Type"); + await PopulateSupportingDataAsync(model); return View(model); } [HttpPost] + [ValidateAntiForgeryToken] [Authorize(Policy = ResgridResources.Command_Create)] - public IActionResult New(NewCommandView model, IFormCollection form) + public async Task New(NewCommandView model, IFormCollection form, CancellationToken cancellationToken) { + if (string.IsNullOrWhiteSpace(model.Command?.Name)) + ModelState.AddModelError("Command.Name", "Command name is required."); + if (ModelState.IsValid) { - List assignments = (from object key in form.Keys - where key.ToString().StartsWith("assignmentName_") - select int.Parse(key.ToString().Replace("assignmentName_", ""))).ToList(); - - if (assignments.Count > 0) - model.Command.Assignments = new Collection(); - model.Command.DepartmentId = DepartmentId; + model.Command.CallTypeId = await ResolveCallTypeIdAsync(model.SelectedType); + model.Command.Assignments = ParseAssignmentsFromForm(form); - if (model.SelectedType != 0) - model.Command.CallTypeId = model.SelectedType; + await _commandsService.Save(model.Command, cancellationToken); - //model.Training.CreatedOn = DateTime.UtcNow; - //model.Training.CreatedByUserId = UserId; - //model.Training.GroupsToAdd = form["groupsToAdd"]; - //model.Training.RolesToAdd = form["rolesToAdd"]; - //model.Training.UsersToAdd = form["usersToAdd"]; - //model.Training.Description = System.Net.WebUtility.HtmlDecode(model.Training.Description); - //model.Training.TrainingText = System.Net.WebUtility.HtmlDecode(model.Training.TrainingText); + return RedirectToAction("Index"); + } - foreach (var i in assignments) - { - if (form.ContainsKey("assignmentName_" + i)) - { - var assignmentName = form["assignmentName_" + i]; - var assignmentDescription = form["assignmentDescription_" + i]; - var assignmentLock = bool.Parse(form["assignmentLock_" + i]); + await PopulateSupportingDataAsync(model); + return View(model); + } - var assignment = new CommandDefinitionRole(); - assignment.Name = assignmentName; - assignment.Description = assignmentDescription; - assignment.ForceRequirements = assignmentLock; + [HttpGet] + [Authorize(Policy = ResgridResources.Command_Update)] + public async Task Edit(int commandId) + { + var command = await _commandsService.GetCommandByIdAsync(commandId); + if (command == null || command.DepartmentId != DepartmentId) + return RedirectToAction("Index"); - var units = new List(); - var roles = new List(); - var certs = new List(); + var model = new NewCommandView(); + model.Command = command; + model.SelectedType = command.CallTypeId ?? 0; - if (form.ContainsKey("assignmentUnits_" + i)) - units.AddRange(form["assignmentUnits_" + i].ToString().Split(char.Parse(","))); + await PopulateSupportingDataAsync(model); - if (form.ContainsKey("assignmentRoles_" + i)) - roles.AddRange(form["assignmentRoles_" + i].ToString().Split(char.Parse(","))); + return View(model); + } - if (form.ContainsKey("assignmentCerts_" + i)) - certs.AddRange(form["assignmentCerts_" + i].ToString().Split(char.Parse(","))); + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Command_Update)] + public async Task Edit(NewCommandView model, IFormCollection form, CancellationToken cancellationToken) + { + var command = await _commandsService.GetCommandByIdAsync(model.Command.CommandDefinitionId); - foreach (var unitType in units) - { - var assignmentUnit = new CommandDefinitionRoleUnitType(); - assignmentUnit.UnitTypeId = int.Parse(unitType); + if (command == null || command.DepartmentId != DepartmentId) + return RedirectToAction("Index"); - assignment.RequiredUnitTypes.Add(assignmentUnit); - } + if (string.IsNullOrWhiteSpace(model.Command?.Name)) + ModelState.AddModelError("Command.Name", "Command name is required."); - foreach (var personnelRole in roles) - { - var assignmentRole = new CommandDefinitionRolePersonnelRole(); - assignmentRole.PersonnelRoleId = int.Parse(personnelRole); + if (ModelState.IsValid) + { + command.Name = model.Command.Name; + command.Description = model.Command.Description; + command.CallTypeId = await ResolveCallTypeIdAsync(model.SelectedType); + command.Timer = model.Command.Timer; + command.TimerMinutes = model.Command.TimerMinutes; + command.Assignments = ParseAssignmentsFromForm(form); - assignment.RequiredRoles.Add(assignmentRole); - } + await _commandsService.Save(command, cancellationToken); - foreach (var personnelCert in certs) - { - var assignmentCert = new CommandDefinitionRoleCert(); - assignmentCert.DepartmentCertificationTypeId = int.Parse(personnelCert); + return RedirectToAction("Index"); + } - //assignment.RequiredCerts.Add(assignmentCert); - } - } - } + model.Command = command; + await PopulateSupportingDataAsync(model); + return View(model); + } - _commandsService.Save(model.Command); + [HttpGet] + [Authorize(Policy = ResgridResources.Command_View)] + public async Task View(int commandId) + { + var command = await _commandsService.GetCommandByIdAsync(commandId); + if (command == null || command.DepartmentId != DepartmentId) return RedirectToAction("Index"); - } + + var model = new NewCommandView(); + model.Command = command; + model.SelectedType = command.CallTypeId ?? 0; + await PopulateSupportingDataAsync(model); return View(model); } + + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Command_Delete)] + public async Task Delete(int commandId, CancellationToken cancellationToken) + { + var command = await _commandsService.GetCommandByIdAsync(commandId); + + if (command != null && command.DepartmentId == DepartmentId) + await _commandsService.DeleteAsync(command, cancellationToken); + + return RedirectToAction("Index"); + } + + #region Private Helpers + + private async Task PopulateSupportingDataAsync(NewCommandView model) + { + model.CallTypes = await _callsService.GetCallTypesForDepartmentAsync(DepartmentId); + + var types = new List(); + types.Add(new CallType() { Type = "Any Call Type" }); + if (model.CallTypes != null) + types.AddRange(model.CallTypes); + + model.Types = new SelectList(types, "CallTypeId", "Type", model.SelectedType); + + model.UnitTypes = await _unitsService.GetUnitTypesForDepartmentAsync(DepartmentId) ?? new List(); + model.PersonnelRoles = await _personnelRolesService.GetRolesForDepartmentAsync(DepartmentId) ?? new List(); + } + + /// + /// Resolves the posted call-type selection to a value that belongs to the current department. + /// "Any Call Type" (0) or any id not owned by the department resolves to null, preventing a + /// caller from binding a command definition to another department's call type. + /// + private async Task ResolveCallTypeIdAsync(int selectedType) + { + if (selectedType == 0) + return null; + + var callTypes = await _callsService.GetCallTypesForDepartmentAsync(DepartmentId); + if (callTypes != null && callTypes.Any(ct => ct.CallTypeId == selectedType)) + return selectedType; + + return null; + } + + /// + /// Parses the dynamic assignment/lane rows out of the posted form. Rows are indexed form fields + /// (assignmentName_N, assignmentDescription_N, ...); SortOrder is assigned from the row order so + /// the board seeds lanes in the order they appear in the editor. + /// + private static List ParseAssignmentsFromForm(IFormCollection form) + { + var assignments = new List(); + + var indexes = form.Keys + .Where(k => k.StartsWith("assignmentName_", StringComparison.OrdinalIgnoreCase)) + .Select(k => int.TryParse(k.Substring("assignmentName_".Length), out var i) ? i : (int?)null) + .Where(i => i.HasValue) + .Select(i => i.Value) + .OrderBy(i => i) + .ToList(); + + int sortOrder = 0; + foreach (var i in indexes) + { + string name = form[$"assignmentName_{i}"]; + if (string.IsNullOrWhiteSpace(name)) + continue; + + var assignment = new CommandDefinitionRole(); + assignment.Name = name.Trim(); + assignment.Description = form[$"assignmentDescription_{i}"]; + assignment.SortOrder = sortOrder++; + + if (int.TryParse(form[$"assignmentId_{i}"], out var roleId)) + assignment.CommandDefinitionRoleId = roleId; + + if (int.TryParse(form[$"assignmentLaneType_{i}"], out var laneType) && Enum.IsDefined(typeof(CommandNodeType), laneType)) + assignment.LaneType = laneType; + + if (bool.TryParse(form[$"assignmentLock_{i}"], out var forceRequirements)) + assignment.ForceRequirements = forceRequirements; + + // The form is a full document: absent/empty pickers clear the lane's requirements. + assignment.RequiredUnitTypes = ParseIdCsv(form[$"assignmentUnitTypes_{i}"]) + .Select(id => new CommandDefinitionRoleUnitType { UnitTypeId = id }).ToList(); + assignment.RequiredRoles = ParseIdCsv(form[$"assignmentRoles_{i}"]) + .Select(id => new CommandDefinitionRolePersonnelRole { PersonnelRoleId = id }).ToList(); + + assignments.Add(assignment); + } + + return assignments; + } + + private static List ParseIdCsv(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return new List(); + + return value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(x => int.TryParse(x, out var id) ? id : 0) + .Where(id => id > 0) + .Distinct() + .ToList(); + } + + #endregion Private Helpers } } diff --git a/Web/Resgrid.Web/Areas/User/Models/Command/CommandIndexView.cs b/Web/Resgrid.Web/Areas/User/Models/Command/CommandIndexView.cs index 5b03151d6..e3af8e0af 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Command/CommandIndexView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Command/CommandIndexView.cs @@ -6,5 +6,6 @@ namespace Resgrid.Web.Areas.User.Models.Command public class CommandIndexView { public List Commands { get; set; } + public List CallTypes { get; set; } } } \ No newline at end of file diff --git a/Web/Resgrid.Web/Areas/User/Models/Command/NewCommandView.cs b/Web/Resgrid.Web/Areas/User/Models/Command/NewCommandView.cs index b4cfe1137..c0c599848 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Command/NewCommandView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Command/NewCommandView.cs @@ -11,5 +11,7 @@ public class NewCommandView public List CallTypes { get; set; } public SelectList Types { get; set; } public int SelectedType { get; set; } + public List UnitTypes { get; set; } = new List(); + public List PersonnelRoles { get; set; } = new List(); } } \ No newline at end of file diff --git a/Web/Resgrid.Web/Areas/User/Views/Command/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/Command/Edit.cshtml index 8d982eb14..d71098fbc 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Command/Edit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Command/Edit.cshtml @@ -1,21 +1,21 @@ -@using Resgrid.Model +@using Resgrid.Model @model Resgrid.Web.Areas.User.Models.Command.NewCommandView @{ - ViewBag.Title = "Resgrid | New Command Definition"; + ViewBag.Title = "Resgrid | Edit Command Board"; }
-

New Command Definition

+

Edit Command Board

@@ -26,7 +26,7 @@
-
+
@@ -37,70 +37,17 @@
} @Html.AntiForgeryToken() + @Html.HiddenFor(m => m.Command.CommandDefinitionId)
- -
- -
-
- @Html.TextBoxFor(m => m.Command.Name, new { @class = "form-control", autofocus = "autofocus" }) -
-
-
-
- -
-
- @Html.TextAreaFor(m => m.Command.Description, new { id = "editor", rows = "10", cols = "30", style = "width:560px;height:300px" }) -
-
-
-
- -
-
- -
-
-
-
- -
-
-
During a command you can assign units/personnel to perform tasks. For example Interior Search, RIT, Water Supply, Safety, etc.
-
-
-
- - - - - - - - - -
NameDescription Add Assignment
-
-
-
-
+ @await Html.PartialAsync("_CommandForm", Model)
Cancel - +
@@ -110,49 +57,7 @@
- - +@await Html.PartialAsync("_AssignmentModal") @section Scripts { diff --git a/Web/Resgrid.Web/Areas/User/Views/Command/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Command/Index.cshtml index 229b62f33..d663578e4 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Command/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Command/Index.cshtml @@ -47,7 +47,7 @@ Type - + Lanes @@ -66,20 +66,23 @@ @if (t.CallTypeId != null) { - + @(Model.CallTypes?.FirstOrDefault(x => x.CallTypeId == t.CallTypeId.Value)?.Type ?? "Unknown Call Type") } else { @Html.Raw("Any Call Type") } - + @(t.Assignments?.Count ?? 0) lane(s) View @if (ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) { Edit - Delete +
+ + +
} diff --git a/Web/Resgrid.Web/Areas/User/Views/Command/New.cshtml b/Web/Resgrid.Web/Areas/User/Views/Command/New.cshtml index 9d98ef973..aa3b319e1 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Command/New.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Command/New.cshtml @@ -1,21 +1,21 @@ -@using Resgrid.Model +@using Resgrid.Model @model Resgrid.Web.Areas.User.Models.Command.NewCommandView @{ - ViewBag.Title = "Resgrid | New Command Definition"; + ViewBag.Title = "Resgrid | New Command Board"; }
-

New Command Definition

+

New Command Board

@@ -40,67 +40,13 @@
- -
- -
-
- @Html.TextBoxFor(m => m.Command.Name, new { @class = "form-control", autofocus = "autofocus" }) -
-
-
-
- -
-
- @Html.TextAreaFor(m => m.Command.Description, new { id = "editor", rows = "10", cols = "30", style = "width:560px;height:300px" }) -
-
-
-
- -
-
- -
-
-
-
- -
-
-
During a command you can assign units/personnel to perform tasks. For example Interior Search, RIT, Water Supply, Safety, etc.
-
-
-
- - - - - - - - - -
NameDescription Add Assignment
-
-
-
-
+ @await Html.PartialAsync("_CommandForm", Model)
Cancel - +
@@ -110,49 +56,7 @@ - - +@await Html.PartialAsync("_AssignmentModal") @section Scripts { diff --git a/Web/Resgrid.Web/Areas/User/Views/Command/View.cshtml b/Web/Resgrid.Web/Areas/User/Views/Command/View.cshtml index 3830e1bd1..49d930985 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Command/View.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Command/View.cshtml @@ -1,21 +1,33 @@ -@using Resgrid.Model +@using Resgrid.Model @model Resgrid.Web.Areas.User.Models.Command.NewCommandView @{ - ViewBag.Title = "Resgrid | New Command Definition"; + ViewBag.Title = "Resgrid | View Command Board"; + var callTypeName = "Any Call Type"; + if (Model.Command.CallTypeId.HasValue && Model.CallTypes != null) + { + var callType = Model.CallTypes.FirstOrDefault(x => x.CallTypeId == Model.Command.CallTypeId.Value); + if (callType != null) + { + callTypeName = callType.Type; + } + } + var assignments = Model.Command.Assignments != null + ? Model.Command.Assignments.OrderBy(a => a.SortOrder).ToList() + : new System.Collections.Generic.List(); }
-

New Command Definition

+

View Command Board

@@ -26,135 +38,91 @@
-
+
+
Name
+
@Model.Command.Name
+
Description
+
@Model.Command.Description
+
Call Type
+
@callTypeName
+
Repeating Timer
+
+ @if (Model.Command.Timer && Model.Command.TimerMinutes > 0) + { + @:Every @Model.Command.TimerMinutes minutes + } + else + { + @:None + } +
+
-
-
- @if (!String.IsNullOrEmpty(Model.Message)) +

Lanes / Assignments

+
+ + + + + + + + + + + + + @if (assignments.Count == 0) { -
- @Model.Message -
+ + + } - @Html.AntiForgeryToken() -
- - - - -
- -
-
- @Html.TextBoxFor(m => m.Command.Name, new { @class = "form-control", autofocus = "autofocus" }) -
-
-
-
- -
-
- @Html.TextAreaFor(m => m.Command.Description, new { id = "editor", rows = "10", cols = "30", style = "width:560px;height:300px" }) -
-
-
-
- -
-
- -
-
-
-
- -
-
-
During a command you can assign units/personnel to perform tasks. For example Interior Search, RIT, Water Supply, Safety, etc.
-
-
-
-
OrderNameLane TypeDescriptionRequirementsEnforced
No lanes configured; the board starts empty and lanes are added during the incident.
- - - - - - - - -
NameDescription Add Assignment
-
-
-
-
- -
-
- Cancel - -
-
- -
-
-
- - - - - - -@section Scripts -{ - -} \ No newline at end of file diff --git a/Web/Resgrid.Web/Areas/User/Views/Command/_AssignmentModal.cshtml b/Web/Resgrid.Web/Areas/User/Views/Command/_AssignmentModal.cshtml new file mode 100644 index 000000000..0e56474de --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Command/_AssignmentModal.cshtml @@ -0,0 +1,63 @@ +@using Resgrid.Model +@model Resgrid.Web.Areas.User.Models.Command.NewCommandView + + diff --git a/Web/Resgrid.Web/Areas/User/Views/Command/_CommandForm.cshtml b/Web/Resgrid.Web/Areas/User/Views/Command/_CommandForm.cshtml new file mode 100644 index 000000000..ee9e3edd3 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Command/_CommandForm.cshtml @@ -0,0 +1,124 @@ +@using Resgrid.Model +@model Resgrid.Web.Areas.User.Models.Command.NewCommandView +@{ + var assignments = Model.Command?.Assignments != null + ? Model.Command.Assignments.OrderBy(a => a.SortOrder).ToList() + : new System.Collections.Generic.List(); +} + +
+ +
+
+ @Html.TextBoxFor(m => m.Command.Name, new { @class = "form-control", autofocus = "autofocus" }) +
+
+
+
+ +
+
+ @Html.TextAreaFor(m => m.Command.Description, new { id = "editor", rows = "5", cols = "30", style = "width:560px;height:120px" }) +
+
+
+
+ +
+
+ + This board is automatically applied when command is established on a call of this type. The "Any Call Type" board is used when no type-specific board exists. +
+
+
+
+ +
+
+ @Html.CheckBoxFor(m => m.Command.Timer) + Every + @Html.TextBoxFor(m => m.Command.TimerMinutes, new { @class = "form-control", type = "number", min = "0", style = "width:80px;display:inline-block;margin:0 6px;" }) + minutes + When enabled, a repeating incident timer (e.g. PAR/benchmark check) is started automatically when command is established with this board. +
+
+
+
+ +
+
+
The lanes (Divisions, Groups, Staging, etc.) that make up the default board layout for this call type. When command is established the board starts with these lanes; the Incident Commander can add, rename, or remove lanes on the fly based on the incident's needs.
+
+
+
+ + + + + + + + + + + + @for (int i = 0; i < assignments.Count; i++) + { + var assignment = assignments[i]; + var laneTypeName = Enum.IsDefined(typeof(CommandNodeType), assignment.LaneType) + ? ((CommandNodeType)assignment.LaneType).ToString() + : CommandNodeType.Division.ToString(); + var requiredUnitTypeIds = assignment.RequiredUnitTypes?.Select(x => x.UnitTypeId).ToList() ?? new System.Collections.Generic.List(); + var requiredRoleIds = assignment.RequiredRoles?.Select(x => x.PersonnelRoleId).ToList() ?? new System.Collections.Generic.List(); + var requiredUnitTypeNames = Model.UnitTypes.Where(x => requiredUnitTypeIds.Contains(x.UnitTypeId)).Select(x => x.Type).ToList(); + var requiredRoleNames = Model.PersonnelRoles.Where(x => requiredRoleIds.Contains(x.PersonnelRoleId)).Select(x => x.Name).ToList(); + + + + + + + + } + +
NameLane TypeDescriptionRequirements Add Lane
+ @assignment.Name + + + + @laneTypeName + + + @assignment.Description + + + @if (requiredUnitTypeNames.Count > 0) + { +
Units: @string.Join(", ", requiredUnitTypeNames)
+ } + @if (requiredRoleNames.Count > 0) + { +
Roles: @string.Join(", ", requiredRoleNames)
+ } + @if (assignment.ForceRequirements) + { +
Enforced
+ } + + + +
+
+
+
+
diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/command/resgrid.commands.newcommand.js b/Web/Resgrid.Web/wwwroot/js/app/internal/command/resgrid.commands.newcommand.js index fde5820bf..7baed7650 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/command/resgrid.commands.newcommand.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/command/resgrid.commands.newcommand.js @@ -4,52 +4,78 @@ var resgrid; (function (commands) { var newcommand; (function (newcommand) { + newcommand.assignmentCount = 0; + $(document).ready(function () { - resgrid.common.analytics.track('Command - New Definition'); + resgrid.common.analytics.track('Command - Board Definition'); $('#SelectedType').select2(); + $('#assignment-unittypes').select2({ placeholder: 'Any unit type', allowClear: true }); + $('#assignment-personnelroles').select2({ placeholder: 'Any personnel', allowClear: true }); - function initSelect2(selector, placeholder, url) { - $(selector).select2({ - placeholder: placeholder, - allowClear: true, - ajax: { - url: url, - dataType: 'json', - processResults: function (data) { - return { results: $.map(data, function (item) { return { id: item.Id, text: item.Name }; }) }; - } - } - }); - } - - initSelect2('#unitTypes', 'Select Unit Types...', resgrid.absoluteBaseUrl + '/User/Units/GetUnitTypes'); - initSelect2('#personnelRoles', 'Select Personnel Roles...', resgrid.absoluteBaseUrl + '/User/Department/GetRecipientsForGrid?filter=2'); - initSelect2('#certifications', 'Select Certifications...', resgrid.absoluteBaseUrl + '/User/Personnel/GetCertifications'); + // Continue numbering after any server-rendered lane rows (Edit page). + newcommand.assignmentCount = parseInt($('#assignments').data('next-index'), 10) || 0; $('#newAssignmentModal').on('show.bs.modal', function () { $('#assignment-name').val(''); $('#description-text').val(''); + $('#assignment-lanetype').val('0'); $('#forceRequirements').prop('checked', false); - $('#unitTypes, #personnelRoles, #certifications').val(null).trigger('change'); + $('#assignment-unittypes, #assignment-personnelroles').val(null).trigger('change'); }); }); function addAssignment() { + var name = $.trim($('#assignment-name').val()); + if (!name) { + return; + } + $('#newAssignmentModal').modal('hide'); - var unitTypeValues = $('#unitTypes').val() || []; - var roleValues = $('#personnelRoles').val() || []; - var certValues = $('#certifications').val() || []; - $('#assignments tbody').first().append( - "" + $('#assignment-name').val() + - "" + - "" + $('#description-text').val() + - "" + - "" + - "" + - "" + - "" + - "" - ); + + var index = newcommand.assignmentCount++; + var description = $('#description-text').val(); + var laneType = $('#assignment-lanetype').val() || '0'; + var laneTypeName = $('#assignment-lanetype option:selected').text(); + const forceRequirements = $('#forceRequirements').is(':checked') ? 'true' : 'false'; + + const unitTypeIds = $('#assignment-unittypes').val() || []; + const unitTypeNames = $('#assignment-unittypes option:selected').map(function () { return $(this).text(); }).get(); + const roleIds = $('#assignment-personnelroles').val() || []; + const roleNames = $('#assignment-personnelroles option:selected').map(function () { return $(this).text(); }).get(); + + var row = $(''); + + var nameCell = $('').text(name); + nameCell.append($('').attr('name', 'assignmentId_' + index).val('0')); + nameCell.append($('').attr('name', 'assignmentName_' + index).val(name)); + + var laneCell = $('').text(laneTypeName); + laneCell.append($('').attr('name', 'assignmentLaneType_' + index).val(laneType)); + + var descriptionCell = $('').text(description); + descriptionCell.append($('').attr('name', 'assignmentDescription_' + index).val(description)); + + const requirementsCell = $(''); + if (unitTypeNames.length > 0) { + requirementsCell.append($('
').append($('').text('Units: ')).append(document.createTextNode(unitTypeNames.join(', ')))); + } + if (roleNames.length > 0) { + requirementsCell.append($('
').append($('').text('Roles: ')).append(document.createTextNode(roleNames.join(', ')))); + } + if (forceRequirements === 'true') { + requirementsCell.append($('
').append($('Enforced'))); + } + requirementsCell.append($('').attr('name', 'assignmentUnitTypes_' + index).val(unitTypeIds.join(','))); + requirementsCell.append($('').attr('name', 'assignmentRoles_' + index).val(roleIds.join(','))); + requirementsCell.append($('').attr('name', 'assignmentLock_' + index).val(forceRequirements)); + + var actionCell = $(''); + actionCell.append($('').on('click', function () { + row.remove(); + })); + + row.append(nameCell).append(laneCell).append(descriptionCell).append(requirementsCell).append(actionCell); + $('#assignments tbody').first().append(row); } newcommand.addAssignment = addAssignment; })(newcommand = commands.newcommand || (commands.newcommand = {}));