diff --git a/Core/Resgrid.Model/Repositories/IPersonnelRolesRepository.cs b/Core/Resgrid.Model/Repositories/IPersonnelRolesRepository.cs index 067cea3e..5ba38a24 100644 --- a/Core/Resgrid.Model/Repositories/IPersonnelRolesRepository.cs +++ b/Core/Resgrid.Model/Repositories/IPersonnelRolesRepository.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; namespace Resgrid.Model.Repositories @@ -39,5 +40,16 @@ public interface IPersonnelRolesRepository: IRepository /// The personnel role identifier. /// Task<PersonnelRole>. Task GetRoleByRoleIdAsync(int personnelRoleId); + + /// + /// Removes every row in other tables that points at a personnel role, so the role row itself can + /// be deleted without tripping a foreign key (CallDispatchRoles has a non-cascading FK on RoleId). + /// Rows that merely reference the role as an optional qualification (UnitRoles) are nulled out + /// instead of deleted. + /// + /// The personnel role identifier. + /// The cancellation token. + /// Task<bool>. + Task DeleteRoleDependenciesAsync(int personnelRoleId, CancellationToken cancellationToken = default(CancellationToken)); } } diff --git a/Core/Resgrid.Services/CallEmailTemplates/ResgridEmailTemplate.cs b/Core/Resgrid.Services/CallEmailTemplates/ResgridEmailTemplate.cs index 7ed34568..505eea16 100644 --- a/Core/Resgrid.Services/CallEmailTemplates/ResgridEmailTemplate.cs +++ b/Core/Resgrid.Services/CallEmailTemplates/ResgridEmailTemplate.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; -using System.Diagnostics.Eventing.Reader; using System.Linq; using System.Text; using System.Threading.Tasks; @@ -27,49 +26,47 @@ public async Task GenerateCall(CallEmail email, string managingUser, List< Call c = new Call(); c.Notes = email.Body; + // Seed the department default up front so every exit path (including the + // fallback below) leaves a priority the department actually owns on the call. + c.Priority = priority; + string[] data = email.Body.Split(char.Parse("|")); - if (data.Any() && data.Length >= 5) + // ADDRESS and NATURE are the two required values and they sit at index 3 and 5, + // so a body needs at least 6 segments to be a Resgrid format message. NOTES + // (index 6) is optional and anything past it is treated as part of the notes. + if (data.Length >= 6) { - if (!string.IsNullOrEmpty(data[0])) - c.IncidentNumber = data[0].Trim(); - - if (!string.IsNullOrEmpty(data[1])) - c.Type = data[1].Trim(); - - if (string.IsNullOrEmpty(data[2])) - { - int prio; - if (int.TryParse(data[2], out prio)) - { - c.Priority = prio; - } - else - { - c.Priority = priority; - } - } - else + c.IncidentNumber = GetValue(data, 0); + c.Type = ParseCallType(GetValue(data, 1), callTypes); + c.Priority = ParseCallPriority(GetValue(data, 2), priority, activePriorities); + c.MapPage = GetValue(data, 4); + c.NatureOfCall = GetValue(data, 5); + + // Re-join everything from index 6 on, a pipe inside the notes text shouldn't + // truncate them. When NOTES isn't supplied the raw body stays in Notes, which + // is the behavior imports have always had. + if (data.Length > 6) { - c.Priority = priority; - } + var notes = String.Join("|", data.Skip(6)).Trim(); - if (!string.IsNullOrEmpty(data[4])) - c.MapPage = data[4]; + if (!String.IsNullOrWhiteSpace(notes)) + c.Notes = notes; + } - c.NatureOfCall = data[5]; + var address = GetValue(data, 3); - if (!string.IsNullOrEmpty(data[3])) + if (!String.IsNullOrEmpty(address)) { - c.Address = data[3]; + c.Address = address; try { - var address = await geolocationProvider.GetLatLonFromAddress(c.Address); + var geolocation = await geolocationProvider.GetLatLonFromAddress(c.Address); - if (address != null) - c.GeoLocationData = address; + if (geolocation != null) + c.GeoLocationData = geolocation; } catch (Exception ex) { @@ -80,8 +77,14 @@ public async Task GenerateCall(CallEmail email, string managingUser, List< StringBuilder title = new StringBuilder(); title.Append("Email Call "); - title.Append(((CallPriority)c.Priority).ToString()); - title.Append(" "); + + var priorityName = GetCallPriorityName(c.Priority, activePriorities); + + if (!String.IsNullOrEmpty(priorityName)) + { + title.Append(priorityName); + title.Append(" "); + } if (!string.IsNullOrEmpty(c.Type)) { @@ -120,5 +123,101 @@ public async Task GenerateCall(CallEmail email, string managingUser, List< return c; } + + private static string GetValue(string[] data, int index) + { + if (data == null || index < 0 || index >= data.Length) + return null; + + var value = data[index]; + + if (String.IsNullOrWhiteSpace(value)) + return null; + + return value.Trim(); + } + + /// + /// TYPE is documented as free text so whatever the CAD sends is kept, but when the + /// department has Custom Call Types the value is normalized to the casing of the + /// configured type so protocol triggers, filters and reports match on it. + /// + private static string ParseCallType(string data, List callTypes) + { + if (String.IsNullOrWhiteSpace(data)) + return null; + + if (callTypes != null && callTypes.Any()) + { + var customType = callTypes.FirstOrDefault(x => !String.IsNullOrWhiteSpace(x.Type) && + String.Equals(x.Type.Trim(), data, StringComparison.OrdinalIgnoreCase)); + + if (customType != null) + return customType.Type; + } + + return data; + } + + /// + /// PRIORITY accepts the priority name or its identifier. Departments on the system + /// priorities keep the documented Low = 0, Medium = 1, High = 2, Emergency = 3 + /// integers (those are their identifiers), departments with Custom Call Priorities + /// can send the priority name instead of an internal identifier they can't see. + /// Anything that doesn't resolve falls back to the department default, an identifier + /// the department doesn't own would leave dispatch without a priority to resolve. + /// + private static int ParseCallPriority(string data, int priority, List activePriorities) + { + if (String.IsNullOrWhiteSpace(data)) + return priority; + + if (activePriorities != null && activePriorities.Any()) + { + var namedPriority = activePriorities.FirstOrDefault(x => !x.IsDeleted && !String.IsNullOrWhiteSpace(x.Name) && + String.Equals(x.Name.Trim(), data, StringComparison.OrdinalIgnoreCase)); + + if (namedPriority != null) + return namedPriority.DepartmentCallPriorityId; + + int parsedPriorityId; + if (int.TryParse(data, out parsedPriorityId)) + { + var idPriority = activePriorities.FirstOrDefault(x => !x.IsDeleted && x.DepartmentCallPriorityId == parsedPriorityId); + + if (idPriority != null) + return idPriority.DepartmentCallPriorityId; + } + + return priority; + } + + // No priority list was supplied by the caller, fall back to the built in priorities. + int parsedPriority; + if (int.TryParse(data, out parsedPriority) && Enum.IsDefined(typeof(CallPriority), parsedPriority)) + return parsedPriority; + + CallPriority namedSystemPriority; + if (Enum.TryParse(data, true, out namedSystemPriority) && Enum.IsDefined(typeof(CallPriority), namedSystemPriority)) + return (int)namedSystemPriority; + + return priority; + } + + private static string GetCallPriorityName(int priority, List activePriorities) + { + if (activePriorities != null && activePriorities.Any()) + { + var match = activePriorities.FirstOrDefault(x => x.DepartmentCallPriorityId == priority); + + if (match != null && !String.IsNullOrWhiteSpace(match.Name)) + return match.Name.Trim(); + } + + if (Enum.IsDefined(typeof(CallPriority), priority)) + return ((CallPriority)priority).ToString(); + + return String.Empty; + } } } diff --git a/Core/Resgrid.Services/PersonnelRolesService.cs b/Core/Resgrid.Services/PersonnelRolesService.cs index 21dd2b3c..f1a213b5 100644 --- a/Core/Resgrid.Services/PersonnelRolesService.cs +++ b/Core/Resgrid.Services/PersonnelRolesService.cs @@ -6,6 +6,7 @@ using Resgrid.Model.Events; using Resgrid.Model.Providers; using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Queries; using Resgrid.Model.Services; namespace Resgrid.Services @@ -17,16 +18,18 @@ public class PersonnelRolesService : IPersonnelRolesService private readonly IDepartmentMembersRepository _departmentMemberRepository; private readonly ISubscriptionsService _subscriptionsService; private readonly IEventAggregator _eventAggregator; + private readonly IUnitOfWork _unitOfWork; public PersonnelRolesService(IPersonnelRolesRepository personnelRolesRepository, IPersonnelRoleUsersRepository personnelRoleUsersRepository, ISubscriptionsService subscriptionsService, IDepartmentMembersRepository departmentMemberRepository, - IEventAggregator eventAggregator) + IEventAggregator eventAggregator, IUnitOfWork unitOfWork) { _personnelRolesRepository = personnelRolesRepository; _personnelRoleUsersRepository = personnelRoleUsersRepository; _subscriptionsService = subscriptionsService; _departmentMemberRepository = departmentMemberRepository; _eventAggregator = eventAggregator; + _unitOfWork = unitOfWork; } /// @@ -89,8 +92,31 @@ public async Task GetRoleByDepartmentAndNameAsync(int departmentI { var role = await GetRoleByIdAsync(roleId); - var result = await _personnelRolesRepository.DeleteAsync(role, cancellationToken); - SendRoleVisibilityRefresh(role?.DepartmentId ?? 0); + if (role == null) + return false; + + // Call dispatches, shift group requirements, run cards and the rest all point back at the + // role row; CallDispatchRoles has a non-cascading FK, so the delete below fails outright for + // any role that has ever been dispatched unless those rows go first. Both steps share one + // connection and transaction, otherwise a failure on the role delete leaves the dependent + // rows already committed and the role stripped of its dispatches, requirements and members. + bool result; + _unitOfWork.CreateOrGetConnection(); + try + { + await _personnelRolesRepository.DeleteRoleDependenciesAsync(roleId, cancellationToken); + + result = await _personnelRolesRepository.DeleteAsync(role, cancellationToken); + + _unitOfWork.CommitChanges(); + } + catch + { + _unitOfWork.DiscardChanges(); + throw; + } + + SendRoleVisibilityRefresh(role.DepartmentId); return result; } diff --git a/Repositories/Resgrid.Repositories.DataRepository/PersonnelRolesRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/PersonnelRolesRepository.cs index f976654d..0090278d 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/PersonnelRolesRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/PersonnelRolesRepository.cs @@ -2,8 +2,10 @@ using System.Collections.Generic; using System.Data.Common; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Dapper; +using Resgrid.Config; using Resgrid.Framework; using Resgrid.Model; using Resgrid.Model.Repositories; @@ -211,6 +213,65 @@ public async Task> GetPersonnelRolesByDepartmentIdAsy } } + public async Task DeleteRoleDependenciesAsync(int personnelRoleId, CancellationToken cancellationToken = default(CancellationToken)) + { + try + { + var dynamicParameters = new DynamicParametersExtension(); + dynamicParameters.Add("RoleId", personnelRoleId); + + var notation = _sqlConfiguration.ParameterNotation; + var schema = _sqlConfiguration.SchemaName; + + // Every table below points at PersonnelRoles. CallDispatchRoles is the one with a + // non-cascading FK, so leaving any of these behind blocks the role delete outright. + // UnitRoles only names the role as an optional qualification for a seat, so the + // requirement is cleared rather than the seat being deleted. + var sql = DataConfig.DatabaseType == DatabaseTypes.Postgres + ? $@"DELETE FROM {schema}.personnelroleusers WHERE personnelroleid = {notation}RoleId; + DELETE FROM {schema}.calldispatchroles WHERE roleid = {notation}RoleId; + DELETE FROM {schema}.shiftgrouproles WHERE personnelroleid = {notation}RoleId; + DELETE FROM {schema}.commanddefinitionrolepersonnelroles WHERE personnelroleid = {notation}RoleId; + DELETE FROM {schema}.runcardrolerequirements WHERE personnelroleid = {notation}RoleId; + DELETE FROM {schema}.stationcoveragerequirements WHERE personnelroleid = {notation}RoleId; + DELETE FROM {schema}.chatchannelaccessrules WHERE personnelroleid = {notation}RoleId; + UPDATE {schema}.unitroles SET personnelroleid = NULL, personnelrolerequired = false WHERE personnelroleid = {notation}RoleId;" + : $@"DELETE FROM {schema}.[PersonnelRoleUsers] WHERE [PersonnelRoleId] = {notation}RoleId; + DELETE FROM {schema}.[CallDispatchRoles] WHERE [RoleId] = {notation}RoleId; + DELETE FROM {schema}.[ShiftGroupRoles] WHERE [PersonnelRoleId] = {notation}RoleId; + DELETE FROM {schema}.[CommandDefinitionRolePersonnelRoles] WHERE [PersonnelRoleId] = {notation}RoleId; + DELETE FROM {schema}.[RunCardRoleRequirements] WHERE [PersonnelRoleId] = {notation}RoleId; + DELETE FROM {schema}.[StationCoverageRequirements] WHERE [PersonnelRoleId] = {notation}RoleId; + DELETE FROM {schema}.[ChatChannelAccessRules] WHERE [PersonnelRoleId] = {notation}RoleId; + UPDATE {schema}.[UnitRoles] SET [PersonnelRoleId] = NULL, [PersonnelRoleRequired] = 0 WHERE [PersonnelRoleId] = {notation}RoleId;"; + + var executeFunction = new Func>(async x => + { + await x.ExecuteAsync(sql, dynamicParameters, _unitOfWork.Transaction); + + return true; + }); + + if (_unitOfWork?.Connection == null) + { + using (var conn = _connectionProvider.Create()) + { + await conn.OpenAsync(cancellationToken); + + return await executeFunction(conn); + } + } + + return await executeFunction(_unitOfWork.CreateOrGetConnection()); + } + catch (Exception ex) + { + Logging.LogException(ex); + + throw; + } + } + private static Func PersonnelRoleUserMapping(Dictionary dictionary) { return new Func((role, roleUser) => diff --git a/Tests/Resgrid.Tests/Services/CallEmailFactoryTests.cs b/Tests/Resgrid.Tests/Services/CallEmailFactoryTests.cs index cd5aea68..98f444a8 100644 --- a/Tests/Resgrid.Tests/Services/CallEmailFactoryTests.cs +++ b/Tests/Resgrid.Tests/Services/CallEmailFactoryTests.cs @@ -550,5 +550,218 @@ public async Task should_work_for_base_template() //return true; } } + + [TestFixture] + public class when_importing_a_resgrid_template_call : with_the_calls_email_factory + { + //ID | TYPE | PRIORITY | ADDRESS | MAPPAGE | NATURE | NOTES + + private List SystemPriorities() + { + return new List + { + new DepartmentCallPriority { DepartmentCallPriorityId = 0, Name = "Low" }, + new DepartmentCallPriority { DepartmentCallPriorityId = 1, Name = "Medium" }, + new DepartmentCallPriority { DepartmentCallPriorityId = 2, Name = "High", IsDefault = true }, + new DepartmentCallPriority { DepartmentCallPriorityId = 3, Name = "Emergency" } + }; + } + + private List CustomPriorities() + { + return new List + { + new DepartmentCallPriority { DepartmentCallPriorityId = 500, Name = "MVA" }, + new DepartmentCallPriority { DepartmentCallPriorityId = 501, Name = "Medical" }, + new DepartmentCallPriority { DepartmentCallPriorityId = 502, Name = "Structure Fire" }, + new DepartmentCallPriority { DepartmentCallPriorityId = 503, Name = "Retired", IsDeleted = true } + }; + } + + private CallEmail BuildEmail(string body) + { + return new CallEmail + { + MessageId = "100", + Subject = "Dispatch", + Body = body, + TextBody = body + }; + } + + [Test] + public async Task should_import_every_documented_value() + { + var email = BuildEmail("2020-1234 | MEDICAL | 3 | 155 Main St. Carson City, NV 89701 | 12B | 55 Y/O Male, Chest Pain | Caller is on scene"); + var mmId = Guid.NewGuid().ToString(); + + var call = await _callEmailFactory.GenerateCallFromEmailText(CallEmailTypes.Resgrid, email, mmId, _dispatchUsers, + null, null, null, (int)CallPriority.High, SystemPriorities(), null, null); + + call.Should().NotBeNull(); + call.ReportingUserId.Should().Be(mmId); + call.SourceIdentifier.Should().Be("100"); + call.CallSource.Should().Be((int)CallSources.EmailImport); + call.IncidentNumber.Should().Be("2020-1234"); + call.Type.Should().Be("MEDICAL"); + call.Priority.Should().Be((int)CallPriority.Emergency); + call.Address.Should().Be("155 Main St. Carson City, NV 89701"); + call.MapPage.Should().Be("12B"); + call.NatureOfCall.Should().Be("55 Y/O Male, Chest Pain"); + call.Notes.Should().Be("Caller is on scene"); + call.Name.Should().Be("Email Call Emergency MEDICAL 2020-1234 "); + call.Dispatches.Count.Should().Be(_dispatchUsers.Count); + } + + [Test] + public async Task should_import_the_documented_empty_value_example() + { + var body = " | MEDICAL | 3 | 155 Main St. Carson City, NV 89701 | | 55 Y/O Male, Chest Pain, Diaphoretic, Conscious and abnormal breathing | "; + var email = BuildEmail(body); + + var call = await _callEmailFactory.GenerateCallFromEmailText(CallEmailTypes.Resgrid, email, Guid.NewGuid().ToString(), _dispatchUsers, + null, null, null, (int)CallPriority.High, SystemPriorities(), null, null); + + call.Should().NotBeNull(); + call.IncidentNumber.Should().BeNull(); + call.MapPage.Should().BeNull(); + call.Type.Should().Be("MEDICAL"); + call.Priority.Should().Be((int)CallPriority.Emergency); + call.NatureOfCall.Should().Be("55 Y/O Male, Chest Pain, Diaphoretic, Conscious and abnormal breathing"); + + // NOTES was not supplied, so the raw body stays in the notes like it always has. + call.Notes.Should().Be(body); + } + + [Test] + public async Task should_use_the_department_default_when_the_priority_is_empty() + { + var email = BuildEmail("2020-1234 | MEDICAL | | 155 Main St. Carson City, NV 89701 | | Chest Pain | "); + + var call = await _callEmailFactory.GenerateCallFromEmailText(CallEmailTypes.Resgrid, email, Guid.NewGuid().ToString(), _dispatchUsers, + null, null, null, (int)CallPriority.Medium, SystemPriorities(), null, null); + + call.Should().NotBeNull(); + call.Priority.Should().Be((int)CallPriority.Medium); + call.Name.Should().Be("Email Call Medium MEDICAL 2020-1234 "); + } + + [Test] + public async Task should_match_a_custom_call_priority_by_name() + { + var email = BuildEmail("2020-1234 | MEDICAL | structure fire | 155 Main St. Carson City, NV 89701 | | Smoke showing | "); + + var call = await _callEmailFactory.GenerateCallFromEmailText(CallEmailTypes.Resgrid, email, Guid.NewGuid().ToString(), _dispatchUsers, + null, null, null, 501, CustomPriorities(), null, null); + + call.Should().NotBeNull(); + call.Priority.Should().Be(502); + call.Name.Should().Be("Email Call Structure Fire MEDICAL 2020-1234 "); + } + + [Test] + public async Task should_match_a_custom_call_priority_by_identifier() + { + var email = BuildEmail("2020-1234 | MEDICAL | 500 | 155 Main St. Carson City, NV 89701 | | Two vehicles | "); + + var call = await _callEmailFactory.GenerateCallFromEmailText(CallEmailTypes.Resgrid, email, Guid.NewGuid().ToString(), _dispatchUsers, + null, null, null, 501, CustomPriorities(), null, null); + + call.Should().NotBeNull(); + call.Priority.Should().Be(500); + } + + [Test] + public async Task should_fall_back_to_the_default_for_a_priority_the_department_does_not_own() + { + // 3 is the system Emergency identifier, but this department runs custom + // priorities and has no priority 3 for dispatch to resolve against. + var email = BuildEmail("2020-1234 | MEDICAL | 3 | 155 Main St. Carson City, NV 89701 | | Chest Pain | "); + + var call = await _callEmailFactory.GenerateCallFromEmailText(CallEmailTypes.Resgrid, email, Guid.NewGuid().ToString(), _dispatchUsers, + null, null, null, 501, CustomPriorities(), null, null); + + call.Should().NotBeNull(); + call.Priority.Should().Be(501); + } + + [Test] + public async Task should_not_match_a_deleted_custom_call_priority() + { + var email = BuildEmail("2020-1234 | MEDICAL | Retired | 155 Main St. Carson City, NV 89701 | | Chest Pain | "); + + var call = await _callEmailFactory.GenerateCallFromEmailText(CallEmailTypes.Resgrid, email, Guid.NewGuid().ToString(), _dispatchUsers, + null, null, null, 501, CustomPriorities(), null, null); + + call.Should().NotBeNull(); + call.Priority.Should().Be(501); + } + + [Test] + public async Task should_normalize_the_type_to_a_custom_call_type() + { + var email = BuildEmail("2020-1234 | medical | 3 | 155 Main St. Carson City, NV 89701 | | Chest Pain | "); + + var callTypes = new List + { + new CallType { CallTypeId = 10, Type = "Medical" }, + new CallType { CallTypeId = 11, Type = "Structure Fire" } + }; + + var call = await _callEmailFactory.GenerateCallFromEmailText(CallEmailTypes.Resgrid, email, Guid.NewGuid().ToString(), _dispatchUsers, + null, null, null, (int)CallPriority.High, SystemPriorities(), callTypes, null); + + call.Should().NotBeNull(); + call.Type.Should().Be("Medical"); + } + + [Test] + public async Task should_keep_a_type_that_is_not_a_configured_call_type() + { + var email = BuildEmail("2020-1234 | WILDLAND | 3 | 155 Main St. Carson City, NV 89701 | | Chest Pain | "); + + var callTypes = new List + { + new CallType { CallTypeId = 10, Type = "Medical" } + }; + + var call = await _callEmailFactory.GenerateCallFromEmailText(CallEmailTypes.Resgrid, email, Guid.NewGuid().ToString(), _dispatchUsers, + null, null, null, (int)CallPriority.High, SystemPriorities(), callTypes, null); + + call.Should().NotBeNull(); + call.Type.Should().Be("WILDLAND"); + } + + [Test] + public async Task should_keep_pipes_that_are_part_of_the_notes() + { + var email = BuildEmail("2020-1234 | MEDICAL | 3 | 155 Main St. Carson City, NV 89701 | | Chest Pain | Unit 1 | Unit 2"); + + var call = await _callEmailFactory.GenerateCallFromEmailText(CallEmailTypes.Resgrid, email, Guid.NewGuid().ToString(), _dispatchUsers, + null, null, null, (int)CallPriority.High, SystemPriorities(), null, null); + + call.Should().NotBeNull(); + call.NatureOfCall.Should().Be("Chest Pain"); + call.Notes.Should().Be("Unit 1 | Unit 2"); + } + + [Test] + public async Task should_fall_back_when_the_body_is_not_the_resgrid_format() + { + var email = BuildEmail("Structure fire at 155 Main St."); + + var call = await _callEmailFactory.GenerateCallFromEmailText(CallEmailTypes.Resgrid, email, Guid.NewGuid().ToString(), _dispatchUsers, + null, null, null, 501, CustomPriorities(), null, null); + + call.Should().NotBeNull(); + call.Name.Should().Be("Dispatch"); + call.NatureOfCall.Should().Be("Structure fire at 155 Main St."); + call.Notes.Should().Contain("WARNING: FALLBACK RESGRID EMAIL IMPORT!"); + + // The fallback still has to land on a priority the department owns. + call.Priority.Should().Be(501); + } + } + } } diff --git a/Tests/Resgrid.Tests/Services/PersonnelRolesServiceTests.cs b/Tests/Resgrid.Tests/Services/PersonnelRolesServiceTests.cs new file mode 100644 index 00000000..1c2dea72 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/PersonnelRolesServiceTests.cs @@ -0,0 +1,151 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Framework.Testing; +using Resgrid.Model; +using Resgrid.Model.Events; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + namespace PersonnelRolesServiceTests + { + public class with_the_personnel_roles_service : TestBase + { + protected IPersonnelRolesService _personnelRolesService; + + protected Mock _personnelRolesRepositoryMock; + protected Mock _personnelRoleUsersRepositoryMock; + protected Mock _subscriptionsServiceMock; + protected Mock _departmentMembersRepositoryMock; + protected Mock _eventAggregatorMock; + protected Mock _unitOfWorkMock; + + protected readonly List _repositoryCallOrder = new List(); + + protected with_the_personnel_roles_service() + { + BuildService(); + } + + // Rebuild the mocks before every test so setups from one test never leak into the next + // (NUnit reuses the fixture instance for every test in the fixture). + protected override void Before_all_tests() + { + BuildService(); + } + + private void BuildService() + { + _repositoryCallOrder.Clear(); + + _personnelRolesRepositoryMock = new Mock(); + _personnelRoleUsersRepositoryMock = new Mock(); + _subscriptionsServiceMock = new Mock(); + _departmentMembersRepositoryMock = new Mock(); + _eventAggregatorMock = new Mock(); + _unitOfWorkMock = new Mock(); + + _unitOfWorkMock.Setup(x => x.CreateOrGetConnection()) + .Callback(() => _repositoryCallOrder.Add("connection")) + .Returns((DbConnection)null); + + _unitOfWorkMock.Setup(x => x.CommitChanges()) + .Callback(() => _repositoryCallOrder.Add("commit")); + + _unitOfWorkMock.Setup(x => x.DiscardChanges()) + .Callback(() => _repositoryCallOrder.Add("rollback")); + + _personnelRolesRepositoryMock + .Setup(x => x.DeleteRoleDependenciesAsync(It.IsAny(), It.IsAny())) + .Callback(() => _repositoryCallOrder.Add("dependencies")) + .ReturnsAsync(true); + + _personnelRolesRepositoryMock + .Setup(x => x.DeleteAsync(It.IsAny(), It.IsAny())) + .Callback(() => _repositoryCallOrder.Add("role")) + .ReturnsAsync(true); + + _personnelRolesService = new PersonnelRolesService( + _personnelRolesRepositoryMock.Object, + _personnelRoleUsersRepositoryMock.Object, + _subscriptionsServiceMock.Object, + _departmentMembersRepositoryMock.Object, + _eventAggregatorMock.Object, + _unitOfWorkMock.Object); + } + } + + [TestFixture] + public class when_deleting_a_personnel_role : with_the_personnel_roles_service + { + [Test] + public async Task dependent_rows_should_be_removed_before_the_role_row() + { + _personnelRolesRepositoryMock.Setup(x => x.GetRoleByRoleIdAsync(6787)) + .ReturnsAsync(new PersonnelRole { PersonnelRoleId = 6787, DepartmentId = 1, Name = "Paramedic" }); + + var result = await _personnelRolesService.DeleteRoleByIdAsync(6787); + + result.Should().BeTrue(); + // CallDispatchRoles has a non-cascading FK on the role, so the cleanup has to land first + // or the role delete throws a constraint violation. + _repositoryCallOrder.Should().Equal("connection", "dependencies", "role", "commit"); + } + + [Test] + public async Task both_deletes_should_share_one_transaction_that_commits_once() + { + _personnelRolesRepositoryMock.Setup(x => x.GetRoleByRoleIdAsync(6787)) + .ReturnsAsync(new PersonnelRole { PersonnelRoleId = 6787, DepartmentId = 1, Name = "Paramedic" }); + + await _personnelRolesService.DeleteRoleByIdAsync(6787); + + // The dependency cleanup and the role delete have to land on the same connection, or a + // failure on the second one leaves the first one already committed. + _unitOfWorkMock.Verify(x => x.CreateOrGetConnection(), Times.Once); + _unitOfWorkMock.Verify(x => x.CommitChanges(), Times.Once); + _unitOfWorkMock.Verify(x => x.DiscardChanges(), Times.Never); + } + + [Test] + public void a_failed_role_delete_should_roll_back_the_dependency_cleanup() + { + _personnelRolesRepositoryMock.Setup(x => x.GetRoleByRoleIdAsync(6787)) + .ReturnsAsync(new PersonnelRole { PersonnelRoleId = 6787, DepartmentId = 1, Name = "Paramedic" }); + + _personnelRolesRepositoryMock + .Setup(x => x.DeleteAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("constraint violation")); + + Assert.ThrowsAsync(async () => await _personnelRolesService.DeleteRoleByIdAsync(6787)); + + _unitOfWorkMock.Verify(x => x.DiscardChanges(), Times.Once); + _unitOfWorkMock.Verify(x => x.CommitChanges(), Times.Never); + // A rolled back delete never happened, so the visibility matrices must not be rebuilt. + _eventAggregatorMock.Verify(x => x.SendMessage(It.IsAny()), Times.Never); + } + + [Test] + public async Task a_role_that_no_longer_exists_should_not_be_deleted() + { + _personnelRolesRepositoryMock.Setup(x => x.GetRoleByRoleIdAsync(6787)) + .ReturnsAsync((PersonnelRole)null); + + var result = await _personnelRolesService.DeleteRoleByIdAsync(6787); + + result.Should().BeFalse(); + _repositoryCallOrder.Should().BeEmpty(); + } + } + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/SignalWireController.cs b/Web/Resgrid.Web.Services/Controllers/SignalWireController.cs index dd9fb48b..2fc551d0 100644 --- a/Web/Resgrid.Web.Services/Controllers/SignalWireController.cs +++ b/Web/Resgrid.Web.Services/Controllers/SignalWireController.cs @@ -1,4 +1,4 @@ -using Newtonsoft.Json; +using Newtonsoft.Json; using Resgrid.Chatbot.Interfaces; using Resgrid.Chatbot.Models; using Resgrid.Model; @@ -236,26 +236,8 @@ public async Task Receive(CancellationToken cancellationToken) if (isDispatchSource && textToCallEnabled) { - var c = new Call(); - c.Notes = textMessage.Text; - c.NatureOfCall = textMessage.Text; - c.LoggedOn = DateTime.UtcNow; - c.Name = string.Format("TTC {0}", c.LoggedOn.TimeConverter(department).ToString("g")); - c.Priority = (int)CallPriority.High; - c.ReportingUserId = department.ManagingUserId; - c.Dispatches = new Collection(); - c.CallSource = (int)CallSources.EmailImport; - c.SourceIdentifier = textMessage.MessageId; - c.DepartmentId = departmentId.Value; - var users = await _departmentsService.GetAllUsersForDepartmentAsync(departmentId.Value, true); - foreach (var u in users) - { - var cd = new CallDispatch(); - cd.UserId = u.UserId; - - c.Dispatches.Add(cd); - } + var c = await BuildTextToCallAsync(department, textMessage, users); var savedCall = await _callsService.SaveCallAsync(c, cancellationToken); @@ -413,5 +395,83 @@ public async Task Receive(CancellationToken cancellationToken) return Ok(); } + + /// + /// Builds the call for an inbound Text-To-Call message. When the department has a + /// Text-To-Call import format configured the message is run through that call + /// template, which is what lets the text supply values like the Call Type and Call + /// Priority. Without a configured format, or when the template can't make a call out + /// of the message, the text is imported as the nature of the call the way + /// Text-To-Call has always done it. + /// + private async Task BuildTextToCallAsync(Department department, TextMessage textMessage, + List users) + { + var priorities = await _callsService.GetActiveCallPrioritiesForDepartmentAsync(department.DepartmentId); + + // The department default, not a hardcoded High. High is priority identifier 2, + // which for a department running Custom Call Priorities is either the wrong + // priority or one the department doesn't own at all, and dispatch then has no + // priority to resolve for tones, colors and the notify-all flags. + int defaultPriority = (int)CallPriority.High; + + if (priorities != null && priorities.Any()) + { + var defaultPrio = priorities.FirstOrDefault(x => x.IsDefault && x.IsDeleted == false); + + if (defaultPrio != null) + defaultPriority = defaultPrio.DepartmentCallPriorityId; + } + + Call call = null; + var formatType = await _departmentSettingsService.GetTextToCallImportFormatForDepartmentAsync(department.DepartmentId); + + if (formatType.HasValue && Enum.IsDefined(typeof(CallEmailTypes), formatType.Value)) + { + var callEmail = new CallEmail(); + callEmail.MessageId = textMessage.MessageId; + callEmail.Subject = String.Format("TTC {0}", DateTime.UtcNow.TimeConverter(department).ToString("g")); + callEmail.Body = textMessage.Text; + callEmail.TextBody = textMessage.Text; + + var activeCalls = await _callsService.GetLatest10ActiveCallsByDepartmentAsync(department.DepartmentId); + var units = await _unitsService.GetUnitsForDepartmentAsync(department.DepartmentId); + var callTypes = await _callsService.GetCallTypesForDepartmentAsync(department.DepartmentId); + + call = await _callsService.GenerateCallFromEmail(formatType.Value, callEmail, department.ManagingUserId, users, + department, activeCalls, units, defaultPriority, priorities, callTypes); + } + + if (call == null) + { + call = new Call(); + call.Notes = textMessage.Text; + call.NatureOfCall = textMessage.Text; + call.ReportingUserId = department.ManagingUserId; + call.SourceIdentifier = textMessage.MessageId; + call.Priority = defaultPriority; + call.Dispatches = new Collection(); + + foreach (var u in users) + { + var cd = new CallDispatch(); + cd.UserId = u.UserId; + + call.Dispatches.Add(cd); + } + } + + if (call.LoggedOn == DateTime.MinValue) + call.LoggedOn = DateTime.UtcNow; + + if (String.IsNullOrWhiteSpace(call.Name)) + call.Name = String.Format("TTC {0}", call.LoggedOn.TimeConverter(department).ToString("g")); + + call.CallSource = (int)CallSources.EmailImport; + call.DepartmentId = department.DepartmentId; + + return call; + } + } } diff --git a/Web/Resgrid.Web.Services/Controllers/TwilioController.cs b/Web/Resgrid.Web.Services/Controllers/TwilioController.cs index 68a29f41..1f8a8024 100644 --- a/Web/Resgrid.Web.Services/Controllers/TwilioController.cs +++ b/Web/Resgrid.Web.Services/Controllers/TwilioController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Globalization; @@ -346,26 +346,8 @@ private async System.Threading.Tasks.Task ProcessTextCommandsAsync(TextMessage t if (isDispatchSource) { - var c = new Call(); - c.Notes = textMessage.Text; - c.NatureOfCall = textMessage.Text; - c.LoggedOn = DateTime.UtcNow; - c.Name = string.Format("TTC {0}", c.LoggedOn.TimeConverter(department).ToString("g")); - c.Priority = (int)CallPriority.High; - c.ReportingUserId = department.ManagingUserId; - c.Dispatches = new Collection(); - c.CallSource = (int)CallSources.EmailImport; - c.SourceIdentifier = textMessage.MessageId; - c.DepartmentId = departmentId.Value; - var users = await _departmentsService.GetAllUsersForDepartmentAsync(departmentId.Value, true); - foreach (var u in users) - { - var cd = new CallDispatch(); - cd.UserId = u.UserId; - - c.Dispatches.Add(cd); - } + var c = await BuildTextToCallAsync(department, textMessage, users); var savedCall = await _callsService.SaveCallAsync(c); @@ -1661,6 +1643,84 @@ private static IReadOnlyCollection BuildMainMenuPrompts(string firstName TwilioVoicePromptCatalog.MainMenuSetStaffing }; } + + /// + /// Builds the call for an inbound Text-To-Call message. When the department has a + /// Text-To-Call import format configured the message is run through that call + /// template, which is what lets the text supply values like the Call Type and Call + /// Priority. Without a configured format, or when the template can't make a call out + /// of the message, the text is imported as the nature of the call the way + /// Text-To-Call has always done it. + /// + private async Task BuildTextToCallAsync(Department department, TextMessage textMessage, + List users) + { + var priorities = await _callsService.GetActiveCallPrioritiesForDepartmentAsync(department.DepartmentId); + + // The department default, not a hardcoded High. High is priority identifier 2, + // which for a department running Custom Call Priorities is either the wrong + // priority or one the department doesn't own at all, and dispatch then has no + // priority to resolve for tones, colors and the notify-all flags. + int defaultPriority = (int)CallPriority.High; + + if (priorities != null && priorities.Any()) + { + var defaultPrio = priorities.FirstOrDefault(x => x.IsDefault && x.IsDeleted == false); + + if (defaultPrio != null) + defaultPriority = defaultPrio.DepartmentCallPriorityId; + } + + Call call = null; + var formatType = await _departmentSettingsService.GetTextToCallImportFormatForDepartmentAsync(department.DepartmentId); + + if (formatType.HasValue && Enum.IsDefined(typeof(CallEmailTypes), formatType.Value)) + { + var callEmail = new CallEmail(); + callEmail.MessageId = textMessage.MessageId; + callEmail.Subject = String.Format("TTC {0}", DateTime.UtcNow.TimeConverter(department).ToString("g")); + callEmail.Body = textMessage.Text; + callEmail.TextBody = textMessage.Text; + + var activeCalls = await _callsService.GetLatest10ActiveCallsByDepartmentAsync(department.DepartmentId); + var units = await _unitsService.GetUnitsForDepartmentAsync(department.DepartmentId); + var callTypes = await _callsService.GetCallTypesForDepartmentAsync(department.DepartmentId); + + call = await _callsService.GenerateCallFromEmail(formatType.Value, callEmail, department.ManagingUserId, users, + department, activeCalls, units, defaultPriority, priorities, callTypes); + } + + if (call == null) + { + call = new Call(); + call.Notes = textMessage.Text; + call.NatureOfCall = textMessage.Text; + call.ReportingUserId = department.ManagingUserId; + call.SourceIdentifier = textMessage.MessageId; + call.Priority = defaultPriority; + call.Dispatches = new Collection(); + + foreach (var u in users) + { + var cd = new CallDispatch(); + cd.UserId = u.UserId; + + call.Dispatches.Add(cd); + } + } + + if (call.LoggedOn == DateTime.MinValue) + call.LoggedOn = DateTime.UtcNow; + + if (String.IsNullOrWhiteSpace(call.Name)) + call.Name = String.Format("TTC {0}", call.LoggedOn.TimeConverter(department).ToString("g")); + + call.CallSource = (int)CallSources.EmailImport; + call.DepartmentId = department.DepartmentId; + + return call; + } + } [Serializable] diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index 2165ca16..9a2e1dd0 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -45,6 +45,16 @@ the Twilio middleware's BaseUrlOverride behind the reverse proxy). + + + Builds the call for an inbound Text-To-Call message. When the department has a + Text-To-Call import format configured the message is run through that call + template, which is what lets the text supply values like the Call Type and Call + Priority. Without a configured format, or when the template can't make a call out + of the message, the text is imported as the nature of the call the way + Text-To-Call has always done it. + + Waits up to for the TTS audio of the @@ -54,6 +64,16 @@ normal append path already degrades those to <Say> or a skip. + + + Builds the call for an inbound Text-To-Call message. When the department has a + Text-To-Call import format configured the message is run through that call + template, which is what lets the text supply values like the Call Type and Call + Priority. Without a configured format, or when the template can't make a call out + of the message, the text is imported as the nature of the call the way + Text-To-Call has always done it. + + Call Priorities, for example Low, Medium, High. Call Priorities can be system provided ones or custom for a department diff --git a/Web/Resgrid.Web/Areas/User/Controllers/AccountSecurityController.cs b/Web/Resgrid.Web/Areas/User/Controllers/AccountSecurityController.cs index bfa8d117..334954a3 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/AccountSecurityController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/AccountSecurityController.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Identity; using Resgrid.Model; @@ -15,7 +16,11 @@ namespace Resgrid.Web.Areas.User.Controllers { + // Every action here acts on the signed-in user's own credentials and sessions, and reads that + // identity from the auth cookie's claims. Without this an anonymous request reaches the action with + // an empty UserId and blows up in the Identity store instead of being sent to the sign-in page. [Area("User")] + [Authorize] public class AccountSecurityController : SecureBaseController { private readonly IUserSessionService _userSessionService; diff --git a/Web/Resgrid.Web/Areas/User/Controllers/CustomMapsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/CustomMapsController.cs index 12afbb3c..8ab69bc8 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/CustomMapsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/CustomMapsController.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; using Resgrid.Model; using Resgrid.Model.Services; using Resgrid.Web.Areas.User.Models.CustomMaps; @@ -15,7 +16,10 @@ namespace Resgrid.Web.Areas.User.Controllers { + // Every action reads or writes maps scoped to the caller's department claim, so an anonymous + // request would run with a DepartmentId of 0 instead of being sent to sign-in. [Area("User")] + [Authorize] public class CustomMapsController : SecureBaseController { private readonly ICustomMapService _customMapService; diff --git a/Web/Resgrid.Web/Areas/User/Controllers/FilesController.cs b/Web/Resgrid.Web/Areas/User/Controllers/FilesController.cs index a34b99ca..db934d5b 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/FilesController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/FilesController.cs @@ -3,14 +3,19 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; using Resgrid.Framework; using Resgrid.Model; using Resgrid.Model.Services; using Resgrid.WebCore.Areas.User.Models.Files; +using IAuthorizationService = Resgrid.Model.Services.IAuthorizationService; namespace Resgrid.Web.Areas.User.Controllers { + // Uploads attach to the caller's own calls and the per-call authorization check reads the UserId + // claim, so every action here needs an authenticated caller. [Area("User")] + [Authorize] public class FilesController : SecureBaseController { private readonly IAuthorizationService _authorizationService; diff --git a/Web/Resgrid.Web/Areas/User/Controllers/HelpController.cs b/Web/Resgrid.Web/Areas/User/Controllers/HelpController.cs index 8178712a..4c6558c0 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/HelpController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/HelpController.cs @@ -1,11 +1,14 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; using Resgrid.Model.Services; using Resgrid.Web.Areas.User.Models.Help; namespace Resgrid.Web.Areas.User.Controllers { + // Both actions render help content for the caller's own department. [Area("User")] + [Authorize] public class HelpController : SecureBaseController { private readonly IDepartmentsService _departmentsService; diff --git a/Web/Resgrid.Web/Areas/User/Controllers/IndoorMapsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/IndoorMapsController.cs index 48ac1bbf..1070c365 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/IndoorMapsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/IndoorMapsController.cs @@ -2,12 +2,16 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.Logging; using Resgrid.Model.Services; namespace Resgrid.Web.Areas.User.Controllers { + // Every action is scoped to the caller's department claim, and the redirects land on CustomMaps + // actions that are department scoped themselves. [Area("User")] + [Authorize] public class IndoorMapsController : SecureBaseController { private readonly IIndoorMapService _indoorMapService; diff --git a/Web/Resgrid.Web/Areas/User/Controllers/LinksController.cs b/Web/Resgrid.Web/Areas/User/Controllers/LinksController.cs index 306d697a..91083c8d 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/LinksController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/LinksController.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.Localization; using Resgrid.Model; using Resgrid.Model.Services; @@ -15,7 +16,9 @@ namespace Resgrid.Web.Areas.User.Controllers { + // Department links are scoped to the caller's department claim on every action. [Area("User")] + [Authorize] public class LinksController : SecureBaseController { #region Private Members and Constructors diff --git a/Web/Resgrid.Web/Areas/User/Controllers/MappingController.cs b/Web/Resgrid.Web/Areas/User/Controllers/MappingController.cs index 3226c0c8..cccc0141 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/MappingController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/MappingController.cs @@ -10,6 +10,7 @@ using GeoJSON.Net.Geometry; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; using MongoDB.Bson; using Newtonsoft.Json; using Resgrid.Framework; @@ -25,7 +26,9 @@ namespace Resgrid.Web.Areas.User.Controllers { + // Map data, POIs and routing are all built from the caller's department and user claims. [Area("User")] + [Authorize] public class MappingController : SecureBaseController { private readonly IDepartmentSettingsService _departmentSettingsService; diff --git a/Web/Resgrid.Web/Areas/User/Controllers/NotesController.cs b/Web/Resgrid.Web/Areas/User/Controllers/NotesController.cs index 61e106f0..c8880973 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/NotesController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/NotesController.cs @@ -3,6 +3,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc.Rendering; using Resgrid.Framework; using Resgrid.Model; @@ -13,10 +14,13 @@ using AuditEvent = Resgrid.Model.Events.AuditEvent; using IndexView = Resgrid.Web.Areas.User.Models.Notes.IndexView; using Note = Resgrid.Model.Note; +using IAuthorizationService = Resgrid.Model.Services.IAuthorizationService; namespace Resgrid.Web.Areas.User.Controllers { + // Notes are scoped to the caller's department, and the per-note checks read the UserId claim. [Area("User")] + [Authorize] public class NotesController : SecureBaseController { private readonly INotesService _notesService; diff --git a/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs b/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs index 0f84e02b..7c0244d7 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs @@ -2004,6 +2004,14 @@ public async Task DeleteRole(int roleId, CancellationToken cancel await _personnelRolesService.DeleteRoleByIdAsync(roleId, cancellationToken); + // Deleting the role also drops its membership rows, so anything caching who is in what role + // has to be dumped or the role keeps showing up on personnel until the cache ages out. + _userProfileService.ClearAllUserProfilesFromCache(DepartmentId); + _departmentsService.InvalidateDepartmentUsersInCache(DepartmentId); + _departmentsService.InvalidatePersonnelNamesInCache(DepartmentId); + _departmentsService.InvalidateDepartmentMembers(); + _usersService.ClearCacheForDepartment(DepartmentId); + return RedirectToAction("Roles"); } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/SecurityController.cs b/Web/Resgrid.Web/Areas/User/Controllers/SecurityController.cs index 412f4368..b9f0c8a1 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/SecurityController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/SecurityController.cs @@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc.Rendering; using Resgrid.Framework; using Resgrid.Model; @@ -24,7 +25,11 @@ namespace Resgrid.Web.Areas.User.Controllers { + // Department security settings, SSO/SCIM configuration and audit logs. Individual actions still + // check IsUserDepartmentAdmin; this sends an anonymous caller to sign-in rather than leaving the + // controller reachable with no identity at all. [Area("User")] + [Authorize] public class SecurityController : SecureBaseController { private readonly IDepartmentsService _departmentsService; diff --git a/Web/Resgrid.Web/Areas/User/Views/Department/CallSettings.cshtml b/Web/Resgrid.Web/Areas/User/Views/Department/CallSettings.cshtml index 0652b653..0d9fc79c 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Department/CallSettings.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Department/CallSettings.cshtml @@ -110,9 +110,9 @@
ID
Incident Identifier from the CAD system. This field can be empty or set to 0000.
TYPE
-
Type of incident, can be anything, i.e. MEDICAL, FIRE, WILDLAND, HAZMAT, etc. This value can also be empty.
+
Type of incident, can be anything, i.e. MEDICAL, FIRE, WILDLAND, HAZMAT, etc. When your department has Call Types set up and this value matches one of them, casing aside, the call is tagged with that Call Type. This value can also be empty.
PRIORITY
-
Priority integer value of the Incident. Low = 0, Medium = 1, High = 2, Emergency = 3. This value can be empty.
+
Priority of the Incident, either the priority name or its integer value. On the built in priorities that is Low = 0, Medium = 1, High = 2, Emergency = 3. When your department has Custom Call Priorities set up send the priority name instead, i.e. Structure Fire, casing aside. This value can be empty, and a value that does not match one of your priorities uses your department default priority.
ADDRESS
Full Address of incident (Address, City, State, Zip). This value is required.
MAPPAGE