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"]
+
+
+
+
+
+
+
+
+
+
+
+