diff --git a/Core/Resgrid.Model/PhoneNumberResult.cs b/Core/Resgrid.Model/PhoneNumberResult.cs index c58b4c71b..9b7ecbed2 100644 --- a/Core/Resgrid.Model/PhoneNumberResult.cs +++ b/Core/Resgrid.Model/PhoneNumberResult.cs @@ -7,5 +7,12 @@ public class PhoneNumberResult public bool IsValid { get; set; } public string CountryCode { get; set; } public string ErrorMessage { get; set; } + + /// + /// ISO region the number actually parsed as ("GB", "AU"), which is not necessarily the region + /// that was passed in - an E.164 number carries its own. Lets a caller learn the region a set of + /// numbers belongs to and reuse it for the ones that arrived in national format. + /// + public string Region { get; set; } } } \ No newline at end of file diff --git a/Core/Resgrid.Model/Repositories/IActionLogsRepository.cs b/Core/Resgrid.Model/Repositories/IActionLogsRepository.cs index 75350e7af..3111288f3 100644 --- a/Core/Resgrid.Model/Repositories/IActionLogsRepository.cs +++ b/Core/Resgrid.Model/Repositories/IActionLogsRepository.cs @@ -14,11 +14,20 @@ public interface IActionLogsRepository: IRepository /// /// Gets the last action logs for department asynchronous. /// + /// + /// BREAKING CHANGE: the parameter was added to this + /// signature. The default value keeps ordinary call sites source compatible, but implementers of + /// this interface must add the parameter, precompiled assemblies bound to the three parameter + /// overload must be rebuilt, and expression tree call sites (Moq Setup/Verify, LINQ expressions) + /// must pass the argument explicitly because C# rejects omitted optional arguments there (CS0854). + /// See Documentation/breaking-changes.md. + /// /// The department identifier. /// if set to true [disable automatic available]. /// The time stamp. + /// if set to true include logs for hidden and disabled department members. /// Task<IEnumerable<ActionLog>>. - Task> GetLastActionLogsForDepartmentAsync(int departmentId, bool disableAutoAvailable, DateTime timeStamp); + Task> GetLastActionLogsForDepartmentAsync(int departmentId, bool disableAutoAvailable, DateTime timeStamp, bool includeHiddenAndDisabled = false); /// /// Gets all action logs for user. diff --git a/Core/Resgrid.Model/Services/IActionLogsService.cs b/Core/Resgrid.Model/Services/IActionLogsService.cs index bafd36681..23ee51699 100644 --- a/Core/Resgrid.Model/Services/IActionLogsService.cs +++ b/Core/Resgrid.Model/Services/IActionLogsService.cs @@ -26,11 +26,20 @@ public interface IActionLogsService /// /// Gets the last action logs for department asynchronous. /// + /// + /// BREAKING CHANGE: the parameter was added to this + /// signature. The default value keeps ordinary call sites source compatible, but implementers of + /// this interface must add the parameter, precompiled assemblies bound to the three parameter + /// overload must be rebuilt, and expression tree call sites (Moq Setup/Verify, LINQ expressions) + /// must pass the argument explicitly because C# rejects omitted optional arguments there (CS0854). + /// See Documentation/breaking-changes.md. + /// /// The department identifier. /// if set to true [force disable automatic available]. /// if set to true [bypass cache]. + /// if set to true include logs for hidden and disabled members. /// Task<List<ActionLog>>. - Task> GetLastActionLogsForDepartmentAsync(int departmentId, bool forceDisableAutoAvailable = false, bool bypassCache = false); + Task> GetLastActionLogsForDepartmentAsync(int departmentId, bool forceDisableAutoAvailable = false, bool bypassCache = false, bool includeHiddenAndDisabled = false); /// /// Gets all action logs for user. diff --git a/Core/Resgrid.Services/ActionLogsService.cs b/Core/Resgrid.Services/ActionLogsService.cs index 68c5c2890..39be2fa06 100644 --- a/Core/Resgrid.Services/ActionLogsService.cs +++ b/Core/Resgrid.Services/ActionLogsService.cs @@ -71,7 +71,7 @@ public void InvalidateActionLogs(int departmentId) _cacheProvider.Remove(string.Format(CacheKey, departmentId)); } - public async Task> GetLastActionLogsForDepartmentAsync(int departmentId, bool forceDisableAutoAvailable = false, bool bypassCache = false) + public async Task> GetLastActionLogsForDepartmentAsync(int departmentId, bool forceDisableAutoAvailable = false, bool bypassCache = false, bool includeHiddenAndDisabled = false) { async Task> getActionLogs() { @@ -83,7 +83,7 @@ async Task> getActionLogs() else disableAutoAvailable = await _departmentSettingsService.GetDisableAutoAvailableForDepartmentAsync(departmentId, false); - var statuses = await _actionLogsRepository.GetLastActionLogsForDepartmentAsync(departmentId, disableAutoAvailable, time); + var statuses = await _actionLogsRepository.GetLastActionLogsForDepartmentAsync(departmentId, disableAutoAvailable, time, includeHiddenAndDisabled); var values = statuses.GroupBy(l => l.UserId) .Select(g => g.OrderByDescending(l => l.ActionLogId).First()) @@ -110,7 +110,7 @@ async Task> getActionLogs() if (!bypassCache) { - return await _cacheProvider.RetrieveAsync(string.Format(CacheKey, departmentId), (Func>>) getActionLogs, CacheLength); + return await _cacheProvider.RetrieveAsync(string.Format(CacheKey, departmentId) + (includeHiddenAndDisabled ? "_IncHidden" : ""), (Func>>) getActionLogs, CacheLength); } return await getActionLogs(); diff --git a/Core/Resgrid.Services/CallEmailTemplates/CallEmailFactory.cs b/Core/Resgrid.Services/CallEmailTemplates/CallEmailFactory.cs index d49aa8823..42135122b 100644 --- a/Core/Resgrid.Services/CallEmailTemplates/CallEmailFactory.cs +++ b/Core/Resgrid.Services/CallEmailTemplates/CallEmailFactory.cs @@ -5,6 +5,7 @@ using Resgrid.Model.Identity; using System.Threading.Tasks; using Resgrid.Model.Providers; +using Resgrid.Framework; namespace Resgrid.Services.CallEmailTemplates { @@ -48,6 +49,8 @@ public async Task GenerateCallFromEmailText(CallEmailTypes type, CallEmail try { call = await _templates[(int)type].GenerateCall(email, managingUser, users, department, activeCalls, units, priority, activePriorities, callTypes, geolocationProvider); + + EnsureRequiredValues(call, email); } catch (Exception ex) { @@ -56,5 +59,33 @@ public async Task GenerateCallFromEmailText(CallEmailTypes type, CallEmail return call; } + + /// + /// Name and NatureOfCall are non-nullable on the Calls table. A template that can't find a value + /// for either, a CAD sending a blank segment or a body that didn't match the format, would hand + /// back a null and lose the dispatch on the insert. Fall back to the email itself instead. + /// + private static void EnsureRequiredValues(Call call, CallEmail email) + { + if (call == null) + return; + + if (String.IsNullOrWhiteSpace(call.NatureOfCall)) + call.NatureOfCall = FirstWithValue(email?.Subject, email?.Body, email?.TextBody); + + if (String.IsNullOrWhiteSpace(call.Name)) + call.Name = FirstWithValue(email?.Subject, call.NatureOfCall); + } + + private static string FirstWithValue(params string[] values) + { + foreach (var value in values) + { + if (!String.IsNullOrWhiteSpace(value)) + return value.Trim().Truncate(4000); + } + + return String.Empty; + } } } diff --git a/Core/Resgrid.Services/CallEmailTemplates/ResgridEmailTemplate.cs b/Core/Resgrid.Services/CallEmailTemplates/ResgridEmailTemplate.cs index 505eea169..5cdcbbac5 100644 --- a/Core/Resgrid.Services/CallEmailTemplates/ResgridEmailTemplate.cs +++ b/Core/Resgrid.Services/CallEmailTemplates/ResgridEmailTemplate.cs @@ -41,7 +41,11 @@ public async Task GenerateCall(CallEmail email, string managingUser, List< 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); + + // NATURE is a non-nullable column but CADs do send the segment empty. Fall back to + // the call type and then the subject so the dispatch still lands, GetValue hands + // back a null for a blank segment and that used to fail the insert. + c.NatureOfCall = GetValue(data, 5) ?? GetValue(data, 1) ?? email.Subject; // 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 diff --git a/Providers/Resgrid.Providers.Number/PhoneNumberProcesserProvider.cs b/Providers/Resgrid.Providers.Number/PhoneNumberProcesserProvider.cs index e5ceb28d8..17ea338f2 100644 --- a/Providers/Resgrid.Providers.Number/PhoneNumberProcesserProvider.cs +++ b/Providers/Resgrid.Providers.Number/PhoneNumberProcesserProvider.cs @@ -1,4 +1,7 @@ -using System; +using System; +using System.Globalization; +using System.Linq; +using System.Text; using Resgrid.Model; using Resgrid.Model.Providers; @@ -14,33 +17,36 @@ public PhoneNumberResult Process(string phoneNumber, string countryCode = null) { var territory = string.IsNullOrWhiteSpace(countryCode) ? "US" : countryCode.ToUpperInvariant(); - // Normalize: strip non-digit characters except leading + - var cleaned = phoneNumber?.Trim() ?? string.Empty; + // Strip characters the parser cannot see past. Real stored numbers carry invisible + // bidi/format marks pasted in from other apps, tabs, and non-standard brackets - all of + // which make an otherwise perfectly good number fail to parse. + var cleaned = Sanitize(phoneNumber); - GlobalPhone.Number number; - // Try with the given territory first - if (GlobalPhone.GlobalPhone.TryParse(cleaned, out number, territory) && number.IsValid) - { - result.IsValid = true; - result.InternationalNumber = number.InternationalString; - result.LocalNumber = number.NationalString; + if (string.IsNullOrWhiteSpace(cleaned)) return result; - } - // Try with no territory hint (for numbers starting with +) - if (GlobalPhone.GlobalPhone.TryParse(cleaned, out number, "ZZ") && number.IsValid) + // In order of confidence. The first two are the original behaviour; the rest only ever + // run once those have failed, so a number that parsed before still parses the same way. + foreach (var attempt in Attempts(cleaned, territory)) { + if (!GlobalPhone.GlobalPhone.TryParse(attempt.Value, out var candidate, attempt.Territory) || + candidate == null || !candidate.IsValid) + continue; + result.IsValid = true; - result.InternationalNumber = number.InternationalString; - result.LocalNumber = number.NationalString; + result.InternationalNumber = candidate.InternationalString; + result.LocalNumber = candidate.NationalString; + result.Region = candidate.RegionCode; + return result; } - result.IsValid = number != null && number.IsValid; - if (number != null) + // Nothing parsed. Report against the original input so the caller sees what it passed in. + if (GlobalPhone.GlobalPhone.TryParse(cleaned, out var parsed, territory) && parsed != null) { - result.InternationalNumber = number.InternationalString; - result.LocalNumber = number.NationalString; + result.InternationalNumber = parsed.InternationalString; + result.LocalNumber = parsed.NationalString; + result.Region = parsed.RegionCode; } } catch (Exception e) @@ -51,5 +57,72 @@ public PhoneNumberResult Process(string phoneNumber, string countryCode = null) return result; } + + private static (string Value, string Territory)[] Attempts(string cleaned, string territory) + { + var digits = new string(cleaned.Where(char.IsDigit).ToArray()); + + return new[] + { + // Original behaviour: the caller's region, then no region hint (for "+" numbers). + (cleaned, territory), + (cleaned, "ZZ"), + + // "00" is the international access prefix in most of the world - the typed equivalent of + // "+". Stored values routinely use it ("0040...", "00306..."), and it parses as nothing. + (cleaned.StartsWith("00", StringComparison.Ordinal) && digits.Length > 4 + ? "+" + digits.Substring(2) + : null, "ZZ"), + + // A country code with no "+" at all ("447700900123"). Only worth trying when the length + // rules out a national number, and only after the region attempts have failed - so a + // valid national number is never reinterpreted as an international one. + (digits.Length >= 11 && digits.Length <= 15 && !cleaned.Contains('+') + ? "+" + digits + : null, "ZZ") + } + .Where(a => !string.IsNullOrWhiteSpace(a.Item1)) + .Select(a => (a.Item1, a.Item2)) + .ToArray(); + } + + /// + /// Removes characters that carry no dialling meaning but do stop the number parsing: Unicode + /// format and control marks (bidi overrides pasted in from other applications), and bracket + /// styles the parser does not recognise. Digits, "+", and the ordinary separators the parser + /// already understands are left exactly as they are. + /// + private static string Sanitize(string phoneNumber) + { + if (string.IsNullOrWhiteSpace(phoneNumber)) + return string.Empty; + + var builder = new StringBuilder(phoneNumber.Length); + + foreach (var character in phoneNumber) + { + var category = CharUnicodeInfo.GetUnicodeCategory(character); + + if (category == UnicodeCategory.Format || category == UnicodeCategory.Control) + continue; + + // "{201} 555-0123" is a real stored shape; the parser handles "()" but not "{}" or "[]". + if (character == '{' || character == '[') + { + builder.Append('('); + continue; + } + + if (character == '}' || character == ']') + { + builder.Append(')'); + continue; + } + + builder.Append(character); + } + + return builder.ToString().Trim(); + } } } diff --git a/Repositories/Resgrid.Repositories.DataRepository/ActionLogsRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/ActionLogsRepository.cs index b8617e054..00db08ee8 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/ActionLogsRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/ActionLogsRepository.cs @@ -31,7 +31,7 @@ public ActionLogsRepository(IConnectionProvider connectionProvider, SqlConfigura _unitOfWork = unitOfWork; } - public async Task> GetLastActionLogsForDepartmentAsync(int departmentId, bool disableAutoAvailable, DateTime timeStamp) + public async Task> GetLastActionLogsForDepartmentAsync(int departmentId, bool disableAutoAvailable, DateTime timeStamp, bool includeHiddenAndDisabled = false) { try { @@ -45,7 +45,9 @@ public async Task> GetLastActionLogsForDepartmentAsync(in dynamicParameters.Add("Timestamp", timeStamp); dynamicParameters.Add("LatestTimestamp", latestTimestamp); - var query = _queryFactory.GetQuery(); + var query = includeHiddenAndDisabled + ? _queryFactory.GetQuery() + : _queryFactory.GetQuery(); return await x.QueryAsync(sql: query, param: dynamicParameters, diff --git a/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs b/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs index aa25e040e..a25453547 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs @@ -28,6 +28,7 @@ protected SqlConfiguration() { } public string ActionLogsTable { get; set; } public string SelectLastActionLogsForDepartmentQuery { get; set; } + public string SelectLastActionLogsForDepartmentIncHiddenQuery { get; set; } public string SelectActionLogsByUserIdQuery { get; set; } public string SelectALogsByUserInDateRangQuery { get; set; } public string SelectALogsByDateRangeQuery { get; set; } diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/ActionLogs/SelectLastActionLogsForDepartmentIncHiddenQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/ActionLogs/SelectLastActionLogsForDepartmentIncHiddenQuery.cs new file mode 100644 index 000000000..c28d423d0 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/ActionLogs/SelectLastActionLogsForDepartmentIncHiddenQuery.cs @@ -0,0 +1,64 @@ +using System; +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; + +namespace Resgrid.Repositories.DataRepository.Queries.ActionLogs +{ + public class SelectLastActionLogsForDepartmentIncHiddenQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectLastActionLogsForDepartmentIncHiddenQuery(SqlConfiguration sqlConfiguration) + { + // Guarded here so every SqlConfiguration access in GetQuery is provably safe. A missing + // configuration is a container misregistration, fail at construction rather than handing + // back a query string that would reach the database malformed. + _sqlConfiguration = sqlConfiguration ?? throw new ArgumentNullException(nameof(sqlConfiguration)); + } + + public string GetQuery() + { + var queryTemplate = _sqlConfiguration.SelectLastActionLogsForDepartmentIncHiddenQuery; + + if (string.IsNullOrWhiteSpace(queryTemplate)) + throw new InvalidOperationException( + $"{nameof(SqlConfiguration.SelectLastActionLogsForDepartmentIncHiddenQuery)} is not set on {_sqlConfiguration.GetType().Name}."); + + var query = queryTemplate + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + string.Empty, + _sqlConfiguration.ParameterNotation, + new string[] { + "%DID%", + "%DAA%", + "%LTS%", + "%TS%" + }, + new string[] { + "DepartmentId", + "DisableAutoAvailable", + "LatestTimestamp", + "Timestamp" + }, + new string[] { + "%ACTIONLOGSTABLE%", + "%ASPNETUSERSTABLE%", + "%DEPARTMENTMEMBERSTABLE%" + }, + new string[] { + _sqlConfiguration.ActionLogsTable, + _sqlConfiguration.UserTable, + _sqlConfiguration.DepartmentMembersTable + } + ); + + 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 39c4a2baf..d3f1edbd6 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs @@ -49,10 +49,18 @@ public PostgreSqlConfiguration() SELECT al.*, u.* FROM %SCHEMA%.%ACTIONLOGSTABLE% al INNER JOIN %SCHEMA%.%ASPNETUSERSTABLE% u ON u.Id = al.UserId - INNER JOIN %SCHEMA%.%DEPARTMENTMEMBERSTABLE% dm ON dm.UserId = al.UserId + INNER JOIN %SCHEMA%.%DEPARTMENTMEMBERSTABLE% dm ON dm.UserId = al.UserId AND dm.DepartmentId = al.DepartmentId WHERE al.DepartmentId = %DID% AND dm.IsDeleted = false AND (%DAA% = true OR al.Timestamp >= %TS%) AND dm.IsDisabled = false AND dm.IsHidden = false AND al.Timestamp >= %LTS%"; + SelectLastActionLogsForDepartmentIncHiddenQuery = @" + SELECT al.*, u.* + FROM %SCHEMA%.%ACTIONLOGSTABLE% al + INNER JOIN %SCHEMA%.%ASPNETUSERSTABLE% u ON u.Id = al.UserId + INNER JOIN %SCHEMA%.%DEPARTMENTMEMBERSTABLE% dm ON dm.UserId = al.UserId AND dm.DepartmentId = al.DepartmentId + WHERE al.DepartmentId = %DID% AND dm.IsDeleted = false AND + (%DAA% = true OR al.Timestamp >= %TS%) AND + al.Timestamp >= %LTS%"; SelectActionLogsByUserIdQuery = @" SELECT %SCHEMA%.%ACTIONLOGSTABLE%.*, %SCHEMA%.%ASPNETUSERSTABLE%.* FROM %SCHEMA%.%ACTIONLOGSTABLE% diff --git a/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs b/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs index a12535d92..aab4694d7 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs @@ -47,10 +47,18 @@ public SqlServerConfiguration() SELECT al.*, u.* FROM %SCHEMA%.%ACTIONLOGSTABLE% al INNER JOIN %SCHEMA%.%ASPNETUSERSTABLE% u ON u.[Id] = al.[UserId] - INNER JOIN %SCHEMA%.%DEPARTMENTMEMBERSTABLE% dm ON dm.[UserId] = al.[UserId] + INNER JOIN %SCHEMA%.%DEPARTMENTMEMBERSTABLE% dm ON dm.[UserId] = al.[UserId] AND dm.[DepartmentId] = al.[DepartmentId] WHERE al.DepartmentId = %DID% AND dm.IsDeleted = 0 AND (%DAA% = 1 OR al.Timestamp >= %TS%) AND dm.IsDisabled = 0 AND dm.IsHidden = 0 AND al.Timestamp >= %LTS%"; + SelectLastActionLogsForDepartmentIncHiddenQuery = @" + SELECT al.*, u.* + FROM %SCHEMA%.%ACTIONLOGSTABLE% al + INNER JOIN %SCHEMA%.%ASPNETUSERSTABLE% u ON u.[Id] = al.[UserId] + INNER JOIN %SCHEMA%.%DEPARTMENTMEMBERSTABLE% dm ON dm.[UserId] = al.[UserId] AND dm.[DepartmentId] = al.[DepartmentId] + WHERE al.DepartmentId = %DID% AND dm.IsDeleted = 0 AND + (%DAA% = 1 OR al.Timestamp >= %TS%) AND + al.Timestamp >= %LTS%"; SelectActionLogsByUserIdQuery = @" SELECT %SCHEMA%.%ACTIONLOGSTABLE%.*, %SCHEMA%.%ASPNETUSERSTABLE%.* FROM %SCHEMA%.%ACTIONLOGSTABLE% diff --git a/Resgrid.Model/PhoneNumberResult.cs b/Resgrid.Model/PhoneNumberResult.cs new file mode 100644 index 000000000..502dbff4e --- /dev/null +++ b/Resgrid.Model/PhoneNumberResult.cs @@ -0,0 +1,18 @@ +namespace Resgrid.Model +{ + public class PhoneNumberResult + { + public string LocalNumber { get; set; } + public string InternationalNumber { get; set; } + public bool IsValid { get; set; } + public string CountryCode { get; set; } + public string ErrorMessage { get; set; } + + /// + /// ISO region the number actually parsed as ("GB", "AU"), which is not necessarily the region + /// that was passed in - an E.164 number carries its own. Lets a caller learn the region a set of + /// numbers belongs to and reuse it for the ones that arrived in national format. + /// + public string Region { get; set; } + } +} diff --git a/Tests/Resgrid.Tests/Chatbot/CallRespondersActionHandlerTests.cs b/Tests/Resgrid.Tests/Chatbot/CallRespondersActionHandlerTests.cs index b248f2763..914282b86 100644 --- a/Tests/Resgrid.Tests/Chatbot/CallRespondersActionHandlerTests.cs +++ b/Tests/Resgrid.Tests/Chatbot/CallRespondersActionHandlerTests.cs @@ -41,7 +41,7 @@ public async Task HandleAsync_WhenCallScopedStatesAreNotCurrent_ExcludesPersonne DestinationId = 42 } }); - actionLogs.Setup(x => x.GetLastActionLogsForDepartmentAsync(1, false, false)).ReturnsAsync(new List + actionLogs.Setup(x => x.GetLastActionLogsForDepartmentAsync(1, false, false, false)).ReturnsAsync(new List { new ActionLog { @@ -113,7 +113,7 @@ public async Task HandleAsync_WhenCallScopedStatesAreNotCurrent_ExcludesPersonne response.Text.Should().Contain("No personnel or units"); response.Text.Should().NotContain("Alex Responder"); response.Text.Should().NotContain("Engine 1"); - actionLogs.Verify(x => x.GetLastActionLogsForDepartmentAsync(1, false, false), Times.Once); + actionLogs.Verify(x => x.GetLastActionLogsForDepartmentAsync(1, false, false, false), Times.Once); units.Verify(x => x.GetAllLatestStatusForUnitsByDepartmentIdAsync(1), Times.Once); } @@ -156,7 +156,7 @@ public async Task HandleAsync_WithResponderMode_FiltersUsingClassifierMode(strin }; var actionLogs = new Mock(); actionLogs.Setup(x => x.GetActionLogsForCallAsync(1, 42)).ReturnsAsync(currentLogs); - actionLogs.Setup(x => x.GetLastActionLogsForDepartmentAsync(1, false, false)).ReturnsAsync(currentLogs); + actionLogs.Setup(x => x.GetLastActionLogsForDepartmentAsync(1, false, false, false)).ReturnsAsync(currentLogs); var units = new Mock(); units.Setup(x => x.GetUnitStatesForCallAsync(1, 42)).ReturnsAsync(new List()); diff --git a/Tests/Resgrid.Tests/Chatbot/ChatbotHandlerTests.cs b/Tests/Resgrid.Tests/Chatbot/ChatbotHandlerTests.cs index bd7c9807e..d0fcf01e0 100644 --- a/Tests/Resgrid.Tests/Chatbot/ChatbotHandlerTests.cs +++ b/Tests/Resgrid.Tests/Chatbot/ChatbotHandlerTests.cs @@ -998,7 +998,7 @@ public async Task Personnel_WithQuery_FiltersByName() }); var actionLogs = new Mock(); - actionLogs.Setup(a => a.GetLastActionLogsForDepartmentAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(new List()); + actionLogs.Setup(a => a.GetLastActionLogsForDepartmentAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(new List()); var userStates = new Mock(); userStates.Setup(u => u.GetLatestStatesForDepartmentAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new List()); diff --git a/Tests/Resgrid.Tests/Providers/PhoneNumberProcesserProviderFormatTests.cs b/Tests/Resgrid.Tests/Providers/PhoneNumberProcesserProviderFormatTests.cs index 234984fb2..31474008e 100644 --- a/Tests/Resgrid.Tests/Providers/PhoneNumberProcesserProviderFormatTests.cs +++ b/Tests/Resgrid.Tests/Providers/PhoneNumberProcesserProviderFormatTests.cs @@ -95,5 +95,97 @@ public void Process_rejects_leading_zero_area_codes_under_the_us_region(string s result.IsValid.Should().BeFalse(); } + // ── Shapes recovered from the normalization sweep's skip list ───────────────── + // Every case below was reported as "does not parse to a valid number" against real stored + // data. Values here are synthetic stand-ins with the same structure. + + [TestCase("0040722555017", "+40722555017")] // Romania + [TestCase("00306945550123", "+306945550123")] // Greece + [TestCase("00359877555012", "+359877555012")] // Bulgaria + [TestCase("00212607555012", "+212607555012")] // Morocco + [TestCase("0044 7400 555012", "+447400555012")] // spaced, United Kingdom + public void Process_reads_00_as_the_international_prefix(string stored, string expected) + { + // "00" is the international access code across most of the world - the typed equivalent of + // "+" - and stored numbers routinely use it. Under a US region hint it parses as nothing. + var result = _provider.Process(stored, "US"); + + result.IsValid.Should().BeTrue(); + result.InternationalNumber.Should().Be(expected); + } + + [TestCase("447400555012", "+447400555012")] + [TestCase("61255501234", "+61255501234")] + public void Process_accepts_a_country_code_with_no_plus(string stored, string expected) + { + var result = _provider.Process(stored, "US"); + + result.IsValid.Should().BeTrue(); + result.InternationalNumber.Should().Be(expected); + } + + [Test] + public void Process_ignores_bracket_styles_the_parser_does_not_know() + { + // "{201} 555-0123" is a real stored shape; "()" parses and "{}" did not. + var result = _provider.Process("{201} 555-0123", "US"); + + result.IsValid.Should().BeTrue(); + result.InternationalNumber.Should().Be("+12015550123"); + } + + [Test] + public void Process_ignores_invisible_formatting_characters() + { + // Numbers pasted in from other applications carry bidi/format marks that are invisible in + // every UI but stop the number parsing. + var result = _provider.Process("+1 201 555 0123‬", "US"); + + result.IsValid.Should().BeTrue(); + result.InternationalNumber.Should().Be("+12015550123"); + } + + [Test] + public void Process_reports_the_region_the_number_actually_belongs_to() + { + // Lets the sweep learn a department's country from the numbers that already parse, and + // reuse it for the national-format ones that cannot be read without it. + _provider.Process("+447400555012", null).Region.Should().Be("GB"); + _provider.Process("+61255501234", null).Region.Should().Be("AU"); + _provider.Process("+12015550123", null).Region.Should().Be("US"); + } + + [TestCase("07400555012", "GB", "+447400555012")] + [TestCase("0491570156", "AU", "+61491570156")] + [TestCase("0272555012", "NZ", "+64272555012")] + [TestCase("0824555012", "ZA", "+27824555012")] + [TestCase("0722555017", "RO", "+40722555017")] + public void Process_reads_a_national_number_once_the_region_is_known(string stored, string region, string expected) + { + // The single biggest cause of skipped rows: a perfectly good national number read against + // the wrong country. Same values under "US" fail outright. + _provider.Process(stored, "US").IsValid.Should().BeFalse(); + + var result = _provider.Process(stored, region); + + result.IsValid.Should().BeTrue(); + result.InternationalNumber.Should().Be(expected); + } + + [TestCase("jbusby")] + [TestCase("N/A")] + [TestCase("Tom ellis")] + [TestCase("someone@example.com")] + [TestCase("00000")] + [TestCase("9999999999")] + [TestCase("1010101010")] + [TestCase("705")] + [TestCase("")] + [TestCase(null)] + public void Process_still_rejects_what_is_not_a_number(string stored) + { + // The recovery attempts must not turn junk into a number that dials somewhere. + _provider.Process(stored, "US").IsValid.Should().BeFalse(); + } } } diff --git a/Tests/Resgrid.Tests/Services/ActionLogsServiceTests.cs b/Tests/Resgrid.Tests/Services/ActionLogsServiceTests.cs index 372216afb..d77a3d5ac 100644 --- a/Tests/Resgrid.Tests/Services/ActionLogsServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/ActionLogsServiceTests.cs @@ -97,8 +97,8 @@ protected with_the_actionLogs_service() .Callback((ActionLog al, CancellationToken ct) => _savedLogs.Remove(al)); // Mock GetLastActionLogsForDepartmentAsync (used by GetAllActionLogsForDepartmentAsync indirectly) - _actionLogsRepositoryMock.Setup(m => m.GetLastActionLogsForDepartmentAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync((int deptId, bool disableAuto, DateTime time) => _savedLogs.Where(l => l.DepartmentId == deptId).ToList()); + _actionLogsRepositoryMock.Setup(m => m.GetLastActionLogsForDepartmentAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int deptId, bool disableAuto, DateTime time, bool includeHidden) => _savedLogs.Where(l => l.DepartmentId == deptId).ToList()); // Mock department members repository _departmentMembersRepositoryMock.Setup(m => m.GetAllAsync()) diff --git a/Tests/Resgrid.Tests/Services/CallEmailFactoryTests.cs b/Tests/Resgrid.Tests/Services/CallEmailFactoryTests.cs index 98f444a8a..5817b7169 100644 --- a/Tests/Resgrid.Tests/Services/CallEmailFactoryTests.cs +++ b/Tests/Resgrid.Tests/Services/CallEmailFactoryTests.cs @@ -761,6 +761,48 @@ public async Task should_fall_back_when_the_body_is_not_the_resgrid_format() // The fallback still has to land on a priority the department owns. call.Priority.Should().Be(501); } + + [Test] + public async Task should_fall_back_to_the_type_when_the_nature_segment_is_empty() + { + // NatureOfCall is non-nullable on the Calls table, a CAD sending the segment blank + // used to produce a null and fail the insert. + var email = BuildEmail("2020-1234 | MEDICAL | 3 | 155 Main St. Carson City, NV 89701 | 12B | | Caller is on scene"); + + 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("MEDICAL"); + } + + [Test] + public async Task should_fall_back_to_the_subject_when_the_nature_and_type_segments_are_empty() + { + var email = BuildEmail("2020-1234 | | 3 | 155 Main St. Carson City, NV 89701 | 12B | | Caller is on scene"); + + 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("Dispatch"); + } + + [Test] + public async Task should_never_return_a_null_nature_or_name() + { + // Every segment blank and no subject to fall back on, the factory still has to hand + // back values the Calls table will accept. + var body = " | | | | | | "; + var email = new CallEmail { MessageId = "100", Subject = null, Body = body, TextBody = 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.NatureOfCall.Should().NotBeNull(); + call.Name.Should().NotBeNull(); + } } } diff --git a/Tests/Resgrid.Tests/Services/CheckInTimerServiceTests.cs b/Tests/Resgrid.Tests/Services/CheckInTimerServiceTests.cs index 8187474be..9fe86a0cf 100644 --- a/Tests/Resgrid.Tests/Services/CheckInTimerServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/CheckInTimerServiceTests.cs @@ -551,7 +551,7 @@ public async Task GetActiveTimerStatusesForCallAsync_FiltersOut_WhenPersonnelSta _overrideRepo.Setup(x => x.GetMatchingOverridesAsync(10, null, 0)).ReturnsAsync(new List()); _recordRepo.Setup(x => x.GetByCallIdAsync(1)).ReturnsAsync(new List()); // User is Responding (2), not On Scene (3) - _actionLogsService.Setup(x => x.GetLastActionLogsForDepartmentAsync(10, It.IsAny(), It.IsAny())) + _actionLogsService.Setup(x => x.GetLastActionLogsForDepartmentAsync(10, It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new List { new ActionLog { UserId = "user1", ActionTypeId = (int)ActionTypes.Responding } }); var result = await _service.GetActiveTimerStatusesForCallAsync(call); @@ -577,7 +577,7 @@ public async Task GetActiveTimerStatusesForCallAsync_IncludesTimer_WhenPersonnel _overrideRepo.Setup(x => x.GetMatchingOverridesAsync(10, null, 0)).ReturnsAsync(new List()); _recordRepo.Setup(x => x.GetByCallIdAsync(1)).ReturnsAsync(new List()); // User is On Scene (3) - matches - _actionLogsService.Setup(x => x.GetLastActionLogsForDepartmentAsync(10, It.IsAny(), It.IsAny())) + _actionLogsService.Setup(x => x.GetLastActionLogsForDepartmentAsync(10, It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new List { new ActionLog { UserId = "user1", ActionTypeId = (int)ActionTypes.OnScene } }); var result = await _service.GetActiveTimerStatusesForCallAsync(call); diff --git a/Tests/Resgrid.Tests/Services/DispatchRecommendationServiceTests.cs b/Tests/Resgrid.Tests/Services/DispatchRecommendationServiceTests.cs index 8706461e5..b791a1f22 100644 --- a/Tests/Resgrid.Tests/Services/DispatchRecommendationServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/DispatchRecommendationServiceTests.cs @@ -104,7 +104,7 @@ public void SetUp() { "user-1", new List { new PersonnelRole { PersonnelRoleId = FirefighterRoleId, Name = "Firefighter" } } }, { "user-2", new List { new PersonnelRole { PersonnelRoleId = FirefighterRoleId, Name = "Firefighter" } } } }); - _actionLogsService.Setup(x => x.GetLastActionLogsForDepartmentAsync(DepartmentId, It.IsAny(), It.IsAny())) + _actionLogsService.Setup(x => x.GetLastActionLogsForDepartmentAsync(DepartmentId, It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new List()); _userStateService.Setup(x => x.GetLatestStatesForDepartmentAsync(DepartmentId, It.IsAny())) .ReturnsAsync(new List()); diff --git a/Tools/Resgrid.Console/Commands/NormalizePhoneNumbersCommand.cs b/Tools/Resgrid.Console/Commands/NormalizePhoneNumbersCommand.cs index 907566310..2cf7df681 100644 --- a/Tools/Resgrid.Console/Commands/NormalizePhoneNumbersCommand.cs +++ b/Tools/Resgrid.Console/Commands/NormalizePhoneNumbersCommand.cs @@ -19,7 +19,7 @@ namespace Resgrid.Console.Commands /// save flow already produces (+12015550123). /// /// Most existing rows were written before the phone validation in EditUserProfile/AddPerson - /// existed, so they hold whatever the user typed - "(270) 555-0101", "270-555-0102". Inbound SMS + /// existed, so they hold whatever the user typed - "(270) 555-0101", "0740 055 5012". Inbound SMS /// and voice resolve the sender by comparing against the stored number, and those formats match /// nothing, so those users cannot be identified by a text or a call. Every current write path /// validates and stores E.164, so this only has to run once. @@ -37,10 +37,25 @@ public sealed class NormalizePhoneNumbersCommand( IAddressService addressService, IPhoneNumberProcesserProvider phoneNumberProcesser) : ICommandService { + private const string MobileField = "MobileNumber"; + private const string HomeField = "HomeNumber"; + private sealed record Change(int DepartmentId, string UserId, string Field, string From, string To); private sealed record Skip(int DepartmentId, string UserId, string Field, string Value, string Reason); + /// One stored number in flight: what it was, and what it parsed to (if anything). + private sealed class Candidate + { + public UserProfile Profile { get; init; } + public string Field { get; init; } + public string Original { get; init; } + public PhoneNumberResult Result { get; set; } + + public bool Parsed => Result != null && Result.IsValid && + !string.IsNullOrWhiteSpace(Result.InternationalNumber); + } + public async Task ExecuteMainAsync(string[] args, CancellationToken cancellationToken) { var apply = args.Any(a => a.Equals("--Apply", StringComparison.OrdinalIgnoreCase)); @@ -74,6 +89,12 @@ public async Task ExecuteMainAsync(string[] args, CancellationToken ca var changes = new List(); var skips = new List(); + + // A profile belongs to a user, not a department, so the same row comes back under every + // department the user belongs to. Track what has been handled so it is not re-parsed and + // re-written once per membership, and so a profile is not reported as a failure under one + // department when it already resolved under another. + var handled = new HashSet(); var scanned = 0; foreach (var department in departments) @@ -88,36 +109,53 @@ public async Task ExecuteMainAsync(string[] args, CancellationToken ca if (profiles == null) continue; - // Resolved once per department rather than per profile: it is the same lookup for - // everyone in it. - var departmentRegion = await CountryIsoAsync(department.AddressId); + var fresh = profiles.Where(p => p != null && !handled.Contains(p.UserProfileId)).ToList(); + + if (fresh.Count == 0) + continue; + + scanned += fresh.Count; + + var candidates = await BuildCandidatesAsync(fresh, department); + var inferred = InferRegion(candidates); + + if (inferred != null) + RetryFailuresWithRegion(candidates, inferred); var pending = new List(); - foreach (var profile in profiles) + foreach (var profile in fresh) { - scanned++; + var changed = false; - var region = await ResolveRegionAsync(profile, departmentRegion); + foreach (var candidate in candidates.Where(c => c.Profile.UserProfileId == profile.UserProfileId)) + { + if (!candidate.Parsed) + { + skips.Add(new Skip(department.DepartmentId, profile.UserId, candidate.Field, + candidate.Original, ClassifySkip(candidate.Original))); + continue; + } - var mobile = Normalize(profile.MobileNumber, "MobileNumber", region, department.DepartmentId, profile.UserId, skips); - var home = Normalize(profile.HomeNumber, "HomeNumber", region, department.DepartmentId, profile.UserId, skips); + if (string.Equals(candidate.Result.InternationalNumber, candidate.Original, StringComparison.Ordinal)) + continue; - if (mobile == null && home == null) - continue; + changes.Add(new Change(department.DepartmentId, profile.UserId, candidate.Field, + candidate.Original, candidate.Result.InternationalNumber)); - if (mobile != null) - { - changes.Add(new Change(department.DepartmentId, profile.UserId, "MobileNumber", profile.MobileNumber, mobile)); - profile.MobileNumber = mobile; - } + if (candidate.Field == MobileField) + profile.MobileNumber = candidate.Result.InternationalNumber; + else + profile.HomeNumber = candidate.Result.InternationalNumber; - if (home != null) - { - changes.Add(new Change(department.DepartmentId, profile.UserId, "HomeNumber", profile.HomeNumber, home)); - profile.HomeNumber = home; + changed = true; } + handled.Add(profile.UserProfileId); + + if (!changed) + continue; + profile.LastUpdated = DateTime.UtcNow; pending.Add(profile); } @@ -137,8 +175,10 @@ public async Task ExecuteMainAsync(string[] args, CancellationToken ca userProfileService.ClearAllUserProfilesFromCache(department.DepartmentId); } - logger.LogInformation("Department {DepartmentId} ({Name}): {Count} profile(s) {Action}.", - department.DepartmentId, department.Name, pending.Count, apply ? "updated" : "would be updated"); + logger.LogInformation("Department {DepartmentId} ({Name}){Region}: {Count} profile(s) {Action}.", + department.DepartmentId, department.Name, + inferred == null ? string.Empty : $" [region {inferred}]", + pending.Count, apply ? "updated" : "would be updated"); } Report(scanned, changes, skips, apply); @@ -158,36 +198,134 @@ public async Task ExecuteMainAsync(string[] args, CancellationToken ca return ExitCode.Success; } + private async Task> BuildCandidatesAsync(List profiles, Department department) + { + // Resolved once per department rather than per profile: it is the same lookup for everyone. + // Both region lookups rethrow: the region decides how a number parses, so swallowing a + // failed lookup here would let an --Apply run rewrite numbers against the wrong region or + // report them as unparseable. The outer handler turns this into a non-zero exit. + string departmentRegion; + + try + { + departmentRegion = await CountryIsoAsync(department.AddressId); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to resolve the region for department {DepartmentId} (AddressId {AddressId}).", + department.DepartmentId, department.AddressId); + throw; + } + + var candidates = new List(); + + foreach (var profile in profiles) + { + string region; + + try + { + region = await ResolveRegionAsync(profile, departmentRegion); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to resolve the region for user {UserId} in department {DepartmentId}.", + profile.UserId, department.DepartmentId); + throw; + } + + foreach (var field in new[] { MobileField, HomeField }) + { + var number = field == MobileField ? profile.MobileNumber : profile.HomeNumber; + + if (string.IsNullOrWhiteSpace(number)) + continue; + + candidates.Add(new Candidate + { + Profile = profile, + Field = field, + Original = number, + Result = phoneNumberProcesser.Process(number, region) + }); + } + } + + return candidates; + } + /// - /// Returns the canonical form when the stored value should be rewritten, or null to leave it - /// alone (blank, already canonical, or not parseable as a real number). + /// The country a department's numbers actually belong to, learned from the ones that already + /// parsed. + /// + /// A national-format number ("07400555012", "0491570156") cannot be read without knowing its + /// country, and most departments have no address on file to supply one - which is why the + /// bulk of the skipped rows are perfectly good non-US numbers. Members of a department + /// overwhelmingly share a country, and rows already stored in E.164 state theirs explicitly, + /// so the numbers that did parse tell us how to read the ones that did not. + /// + /// + /// Requires a clear majority, so a handful of foreign numbers cannot relabel a department and + /// turn a bad parse into a confidently wrong number. + /// /// - private string Normalize(string number, string field, string region, int departmentId, string userId, - List skips) + private static string InferRegion(List candidates) { - if (string.IsNullOrWhiteSpace(number)) + var regions = candidates + .Where(c => c.Parsed && !string.IsNullOrWhiteSpace(c.Result.Region)) + .Select(c => c.Result.Region) + .ToList(); + + if (regions.Count == 0) return null; - var result = phoneNumberProcesser.Process(number, region); + var top = regions.GroupBy(r => r).OrderByDescending(g => g.Count()).First(); + + return top.Count() * 2 > regions.Count ? top.Key : null; + } - if (result == null || !result.IsValid || string.IsNullOrWhiteSpace(result.InternationalNumber)) + private void RetryFailuresWithRegion(List candidates, string region) + { + foreach (var candidate in candidates.Where(c => !c.Parsed)) { - // Never guess. A number that does not parse to a real one - a truncated entry, or a - // national format whose country cannot be resolved from the profile's address - is - // reported for a human to look at rather than rewritten into something that would - // dial somewhere else. - skips.Add(new Skip(departmentId, userId, field, number, "does not parse to a valid number")); - return null; + var retry = phoneNumberProcesser.Process(candidate.Original, region); + + if (retry != null && retry.IsValid && !string.IsNullOrWhiteSpace(retry.InternationalNumber)) + candidate.Result = retry; } + } - return string.Equals(result.InternationalNumber, number, StringComparison.Ordinal) - ? null - : result.InternationalNumber; + /// + /// Why a value could not be used, so the report can be triaged in groups rather than read row + /// by row. Most of what lands here is not a mistyped number at all - it is a name, an email + /// address, "N/A", or a placeholder - and those want clearing, not fixing. + /// + private static string ClassifySkip(string value) + { + var trimmed = (value ?? string.Empty).Trim(); + var digits = trimmed.Count(char.IsDigit); + + if (digits == 0) + return "not a phone number (no digits)"; + + if (trimmed.Contains('@') || trimmed.Any(char.IsLetter)) + return "contains letters"; + + if (trimmed.Contains(';') || trimmed.Contains(',')) + return "more than one number in the field"; + + if (digits < 7) + return "too short"; + + if (trimmed.Where(char.IsDigit).Distinct().Count() <= 2) + return "placeholder"; + + return "does not parse to a valid number"; } /// /// The country to interpret a national-format number against. Without one, a stored - /// "270-555-0102" cannot be resolved to a country code at all. + /// "0740 055 5012" cannot be resolved to a country code at all. /// /// Follows EditUserProfile - the home (physical) address country, then the mailing address - /// and falls back to the department's own address when the profile has neither, or when the @@ -235,11 +373,14 @@ private void Report(int scanned, List changes, List skips, bool ap logger.LogInformation("Numbers {Action}: {Count}", apply ? "rewritten" : "to rewrite", changes.Count); logger.LogInformation("Numbers skipped: {Count}", skips.Count); - // After normalization two profiles can land on the same number - the production data - // already holds the same number in two formats on different rows. The inbound lookup - // prefers a verified profile, but these are worth a human look. + foreach (var reason in skips.GroupBy(s => s.Reason).OrderByDescending(g => g.Count())) + logger.LogInformation(" {Count,6} {Reason}", reason.Count(), reason.Key); + + // After normalization two profiles can land on the same number - production already holds + // the same number in two formats on different rows. The inbound lookup prefers a verified + // profile, but these are worth a human look. var collisions = changes - .Where(c => c.Field == "MobileNumber") + .Where(c => c.Field == MobileField) .GroupBy(c => c.To) .Where(g => g.Select(c => c.UserId).Distinct().Count() > 1) .ToList(); diff --git a/Tools/Resgrid.Console/appsettings.json b/Tools/Resgrid.Console/appsettings.json index 338f0132b..e3045a382 100644 --- a/Tools/Resgrid.Console/appsettings.json +++ b/Tools/Resgrid.Console/appsettings.json @@ -5,7 +5,7 @@ "Logging": { "IncludeScopes": false, "LogLevel": { - "Default": "Debug", + "Default": "Debug", "System": "Information", "Microsoft": "Information" } diff --git a/Web/Resgrid.Web.Services/Controllers/v4/PersonnelController.cs b/Web/Resgrid.Web.Services/Controllers/v4/PersonnelController.cs index 685924edb..d0be723f3 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/PersonnelController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/PersonnelController.cs @@ -155,7 +155,7 @@ public async Task> GetAllPersonnelInfos } var filters = await GetFilterOptions(); - var actionLogs = await _actionLogsService.GetLastActionLogsForDepartmentAsync(DepartmentId); + var actionLogs = await _actionLogsService.GetLastActionLogsForDepartmentAsync(DepartmentId, includeHiddenAndDisabled: true); var userStates = await _userStateService.GetLatestStatesForDepartmentAsync(DepartmentId); var users = await _departmentsService.GetAllUsersForDepartmentAsync(DepartmentId); Department department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs b/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs index 7c0244d7d..fd590e490 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs @@ -136,7 +136,18 @@ public async Task Index() var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); var users = await _departmentsService.GetAllUsersForDepartmentUnlimitedAsync(DepartmentId); var departmentMembers = await _departmentsService.GetAllMembersForDepartmentUnlimitedAsync(DepartmentId); - var actionLogs = await _actionLogsService.GetLastActionLogsForDepartmentAsync(DepartmentId); + List actionLogs; + + try + { + actionLogs = await _actionLogsService.GetLastActionLogsForDepartmentAsync(DepartmentId, includeHiddenAndDisabled: true); + } + catch (Exception ex) + { + Logging.LogException(ex, $"Failed to get the last action logs for the personnel list. DepartmentId: {DepartmentId}"); + throw; + } + var staffings = await _userStateService.GetLatestStatesForDepartmentAsync(DepartmentId); var canGroupAdminsDelete = await _authorizationService.CanGroupAdminsRemoveUsersAsync(DepartmentId); var profiles = await _userProfileService.GetAllProfilesForDepartmentIncDisabledDeletedAsync(DepartmentId); diff --git a/Web/Resgrid.Web/Controllers/AccountController.cs b/Web/Resgrid.Web/Controllers/AccountController.cs index 83270e215..e54540ef8 100644 --- a/Web/Resgrid.Web/Controllers/AccountController.cs +++ b/Web/Resgrid.Web/Controllers/AccountController.cs @@ -612,6 +612,22 @@ await _systemAuditsService.SaveSystemAuditAsync(new SystemAudit return RedirectToAction("LogOn", new { reason = "password-changed" }); } + // + // GET: /Account/LogOff + // Sign out itself stays POST + antiforgery (see below). Bookmarks, legacy links and the + // cookie handler's configured LogoutPath still issue a GET here, which would otherwise 404, + // so render a confirmation the user can submit. + [HttpGet] + [ActionName("LogOff")] + [AllowAnonymous] + public IActionResult LogOffConfirmation() + { + if (User?.Identity == null || !User.Identity.IsAuthenticated) + return RedirectToAction("LogOn", "Account", new { Area = "" }); + + return View(); + } + // // POST: /Account/LogOff [HttpPost] diff --git a/Web/Resgrid.Web/Views/Account/LogOff.cshtml b/Web/Resgrid.Web/Views/Account/LogOff.cshtml new file mode 100644 index 000000000..78d6b0911 --- /dev/null +++ b/Web/Resgrid.Web/Views/Account/LogOff.cshtml @@ -0,0 +1,62 @@ +@using Microsoft.Extensions.Localization +@inject IStringLocalizer localizer +@inject IStringLocalizer commonLocalizer +@{ + Layout = null; +} + + + + + + + Resgrid | @commonLocalizer["Logout"] + + + + + + + + + + + +
+

+

@commonLocalizer["Logout"]

+ +
+ +
+
+ + + + + + +