Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions Core/Resgrid.Model/Repositories/IUserProfilesRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,5 +61,20 @@ public interface IUserProfilesRepository: IRepository<UserProfile>
/// <param name="profiles">Profiles with SecurityPin and LastUpdated already set.</param>
/// <param name="cancellationToken">The cancellation token.</param>
Task UpdateSecurityPinsAsync(IEnumerable<UserProfile> profiles, CancellationToken cancellationToken = default);

/// <summary>
/// Updates only the MobileNumber/HomeNumber (and LastUpdated) columns for the supplied profiles
/// as a single batched command. Used by the one-off phone-number normalization sweep.
/// <para>
/// Deliberately bypasses UserProfileService.SaveProfileAsync: that treats any change to a
/// number as the user entering a new one and resets the verification state, clears the
/// verification codes and deletes the SMS chatbot identity links. Rewriting a number into its
/// canonical form is not a change of number, so none of that may fire.
/// </para>
/// Callers are responsible for profile cache eviction.
/// </summary>
/// <param name="profiles">Profiles with MobileNumber, HomeNumber and LastUpdated already set.</param>
/// <param name="cancellationToken">The cancellation token.</param>
Task UpdatePhoneNumbersAsync(IEnumerable<UserProfile> profiles, CancellationToken cancellationToken = default);
}
}
80 changes: 63 additions & 17 deletions Core/Resgrid.Services/SmsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@ public class SmsService : ISmsService
private readonly IEmailSender _emailSender;
private readonly ISubscriptionsService _subscriptionsService;
private readonly ICacheProvider _cacheProvider;
private readonly IPhoneNumberProcesserProvider _phoneNumberProcesser;

public SmsService(IUserProfileService userProfileService, IGeoLocationProvider geoLocationProvider,
ITextMessageProvider textMessageProvider, IDepartmentSettingsService departmentSettingsService,
IEmailSender emailSender, ISubscriptionsService subscriptionsService, ICacheProvider cacheProvider)
IEmailSender emailSender, ISubscriptionsService subscriptionsService, ICacheProvider cacheProvider,
IPhoneNumberProcesserProvider phoneNumberProcesser)
{
_userProfileService = userProfileService;
_geoLocationProvider = geoLocationProvider;
Expand All @@ -31,6 +33,50 @@ public SmsService(IUserProfileService userProfileService, IGeoLocationProvider g
_emailSender = emailSender;
_subscriptionsService = subscriptionsService;
_cacheProvider = cacheProvider;
_phoneNumberProcesser = phoneNumberProcesser;
}


/// <summary>
/// The number to hand a direct provider send (Twilio/SignalWire), in E.164.
/// <para>
/// Not <see cref="UserProfile.GetPhoneNumber"/>: that returns the display form, which strips the
/// leading "+" while keeping the country code. Twilio receives the value verbatim, so the result
/// is not a valid 'To' - fatal for any non-US number, which has no meaning at all without its
/// "+". CommunicationTestService already normalizes off the raw profile number for the same
/// reason; this is the same treatment for the dispatch path.
/// </para>
/// </summary>
private string ResolveDirectSendNumber(UserProfile profile)
{
var processed = _phoneNumberProcesser?.Process(profile?.MobileNumber);

if (processed != null && processed.IsValid && !string.IsNullOrWhiteSpace(processed.InternationalNumber))
return processed.InternationalNumber;

// Unparseable: fall back to the previous behaviour rather than dropping the message. The
// provider logs and swallows an invalid 'To', which is what happened before this too.
return profile?.GetPhoneNumber();
}

/// <summary>
/// The number to embed in a carrier SMS-gateway address, in national form.
/// <para>
/// The CarriersMap templates ("{0}@vtext.com", "{0}@txt.att.net") are US carrier gateways and
/// expect the bare 10-digit number. A stored E.164 value run through
/// <see cref="UserProfile.GetPhoneNumber"/> yields 11 digits with the leading country code, which
/// addresses a mailbox that does not exist. Every non-US carrier in the map is a direct-send
/// carrier, so the national form is the right one here.
/// </para>
/// </summary>
private string ResolveGatewayNumber(UserProfile profile)
{
var processed = _phoneNumberProcesser?.Process(profile?.MobileNumber);

if (processed != null && processed.IsValid && !string.IsNullOrWhiteSpace(processed.LocalNumber))
return processed.LocalNumber;

return profile?.GetPhoneNumber();
Comment on lines +72 to +79

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Bug high

Invalid gateway address formatting in Core/Resgrid.Services/SmsService.cs: ResolveGatewayNumber always returns PhoneNumberResult.LocalNumber for carrier-gateway sends, but several carriers in CarriersMap require country-code digits in the mailbox address. Select the gateway format per carrier and preserve processed.InternationalNumber without the leading + for carriers such as RogersWireless and the UK gateway carriers.

private string ResolveGatewayNumber(UserProfile profile)
{
	var processed = _phoneNumberProcesser?.Process(profile?.MobileNumber);

	if (processed == null || !processed.IsValid)
		return profile?.GetPhoneNumber();

	var carrier = (MobileCarriers)profile.MobileCarrier;
	if (Carriers.CarriersNumberLength.TryGetValue(carrier, out var format) && format.Item1 > 10)
		return processed.InternationalNumber?.TrimStart('+');

	return !string.IsNullOrWhiteSpace(processed.LocalNumber)
		? processed.LocalNumber
		: profile?.GetPhoneNumber();
}
Prompt for LLM

File Core/Resgrid.Services/SmsService.cs:

Line 72 to 79:

Invalid gateway address formatting in `Core/Resgrid.Services/SmsService.cs`: `ResolveGatewayNumber` always returns `PhoneNumberResult.LocalNumber` for carrier-gateway sends, but several carriers in `CarriersMap` require country-code digits in the mailbox address. Select the gateway format per carrier and preserve `processed.InternationalNumber` without the leading `+` for carriers such as `RogersWireless` and the UK gateway carriers.

Suggested Code:

private string ResolveGatewayNumber(UserProfile profile)
{
	var processed = _phoneNumberProcesser?.Process(profile?.MobileNumber);

	if (processed == null || !processed.IsValid)
		return profile?.GetPhoneNumber();

	var carrier = (MobileCarriers)profile.MobileCarrier;
	if (Carriers.CarriersNumberLength.TryGetValue(carrier, out var format) && format.Item1 > 10)
		return processed.InternationalNumber?.TrimStart('+');

	return !string.IsNullOrWhiteSpace(processed.LocalNumber)
		? processed.LocalNumber
		: profile?.GetPhoneNumber();
}

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

}

public async Task<bool> SendMessageAsync(Message message, string departmentNumber, int departmentId, UserProfile profile = null, Payment payment = null)
Expand All @@ -55,17 +101,17 @@ public async Task<bool> SendMessageAsync(Message message, string departmentNumbe
{
if (Config.SystemBehaviorConfig.DepartmentsToForceSmsGateway.Contains(departmentId))
{
await _textMessageProvider.SendTextMessage(profile.GetPhoneNumber(), FormatTextForMessage(message.Subject, message.Body, ShouldDiscloseOptOut(profile.UserId)),
await _textMessageProvider.SendTextMessage(ResolveDirectSendNumber(profile), FormatTextForMessage(message.Subject, message.Body, ShouldDiscloseOptOut(profile.UserId)),
departmentNumber, (MobileCarriers)profile.MobileCarrier, departmentId, true, false);
}
else if (Carriers.DirectSendCarriers.Contains((MobileCarriers)profile.MobileCarrier))
{
await _textMessageProvider.SendTextMessage(profile.GetPhoneNumber(), FormatTextForMessage(message.Subject, message.Body, ShouldDiscloseOptOut(profile.UserId)),
await _textMessageProvider.SendTextMessage(ResolveDirectSendNumber(profile), FormatTextForMessage(message.Subject, message.Body, ShouldDiscloseOptOut(profile.UserId)),
departmentNumber, (MobileCarriers)profile.MobileCarrier, departmentId, false, false);
}
else
{
email.To.Add(string.Format(Carriers.CarriersMap[(MobileCarriers)profile.MobileCarrier], profile.GetPhoneNumber()));
email.To.Add(string.Format(Carriers.CarriersMap[(MobileCarriers)profile.MobileCarrier], ResolveGatewayNumber(profile)));

email.From = new MailAddress(Config.OutboundEmailServerConfig.FromMail, "RGMsg");
email.Subject = message.Subject;
Expand Down Expand Up @@ -158,7 +204,7 @@ public async Task<bool> SendCallAsync(Call call, CallDispatch dispatch, string d
// // text = text + " " + call.ShortenedCallUrl;
// //}

// await _textMessageProvider.SendTextMessage(profile.GetPhoneNumber(), FormatTextForMessage(call.Name, text, ShouldDiscloseOptOut(profile.UserId)), departmentNumber, (MobileCarriers)profile.MobileCarrier, departmentId, true, true);
// await _textMessageProvider.SendTextMessage(ResolveDirectSendNumber(profile), FormatTextForMessage(call.Name, text, ShouldDiscloseOptOut(profile.UserId)), departmentNumber, (MobileCarriers)profile.MobileCarrier, departmentId, true, true);

// if (Config.SystemBehaviorConfig.SendCallsToSmsEmailGatewayAdditionally)
// SendCallViaEmailSmsGateway(call, address, profile);
Expand Down Expand Up @@ -197,7 +243,7 @@ public async Task<bool> SendCallAsync(Call call, CallDispatch dispatch, string d
// text = text + " " + call.ShortenedCallUrl;
//}

await _textMessageProvider.SendTextMessage(profile.GetPhoneNumber(), FormatTextForMessage(call.Name, text, ShouldDiscloseOptOut(profile.UserId)), departmentNumber, (MobileCarriers)profile.MobileCarrier, departmentId, false, true);
await _textMessageProvider.SendTextMessage(ResolveDirectSendNumber(profile), FormatTextForMessage(call.Name, text, ShouldDiscloseOptOut(profile.UserId)), departmentNumber, (MobileCarriers)profile.MobileCarrier, departmentId, false, true);
}
else
{
Expand Down Expand Up @@ -268,7 +314,7 @@ public async Task<bool> SendCancelCallAsync(Call call, CallDispatch dispatch, st
text = text + " (" + protocols + ")";
}

await _textMessageProvider.SendTextMessage(profile.GetPhoneNumber(), FormatTextForMessage(call.Name, text, ShouldDiscloseOptOut(profile.UserId)), departmentNumber, (MobileCarriers)profile.MobileCarrier, departmentId, false, true);
await _textMessageProvider.SendTextMessage(ResolveDirectSendNumber(profile), FormatTextForMessage(call.Name, text, ShouldDiscloseOptOut(profile.UserId)), departmentNumber, (MobileCarriers)profile.MobileCarrier, departmentId, false, true);
}
else
{
Expand All @@ -282,7 +328,7 @@ public async Task<bool> SendCancelCallAsync(Call call, CallDispatch dispatch, st
private async Task SendCancelCallViaEmailSmsGatewayAsync(Call call, string address, UserProfile profile)
{
MailMessage email = new MailMessage();
email.To.Add(string.Format(Carriers.CarriersMap[(MobileCarriers)profile.MobileCarrier], profile.GetPhoneNumber()));
email.To.Add(string.Format(Carriers.CarriersMap[(MobileCarriers)profile.MobileCarrier], ResolveGatewayNumber(profile)));

email.From = new MailAddress(Config.OutboundEmailServerConfig.FromMail, "RGCall");
email.Subject = "CANCELLED: " + call.Name;
Expand All @@ -307,7 +353,7 @@ private async Task SendCancelCallViaEmailSmsGatewayAsync(Call call, string addre
private void SendCallViaEmailSmsGateway(Call call, string address, UserProfile profile)
{
MailMessage email = new MailMessage();
email.To.Add(string.Format(Carriers.CarriersMap[(MobileCarriers)profile.MobileCarrier], profile.GetPhoneNumber()));
email.To.Add(string.Format(Carriers.CarriersMap[(MobileCarriers)profile.MobileCarrier], ResolveGatewayNumber(profile)));

email.From = new MailAddress(Config.OutboundEmailServerConfig.FromMail, "RGCall");
email.Subject = call.Name;
Expand Down Expand Up @@ -345,16 +391,16 @@ public void SendTroubleAlert(Unit unit, Call call, string unitAddress, string de

if (Config.SystemBehaviorConfig.DepartmentsToForceSmsGateway.Contains(departmentId))
{
_textMessageProvider.SendTextMessage(profile.GetPhoneNumber(), FormatTextForMessage("Trouble Alert", text, ShouldDiscloseOptOut(profile.UserId)), departmentNumber, (MobileCarriers)profile.MobileCarrier, departmentId, true, false);
_textMessageProvider.SendTextMessage(ResolveDirectSendNumber(profile), FormatTextForMessage("Trouble Alert", text, ShouldDiscloseOptOut(profile.UserId)), departmentNumber, (MobileCarriers)profile.MobileCarrier, departmentId, true, false);
}
else if (Carriers.DirectSendCarriers.Contains((MobileCarriers)profile.MobileCarrier))
{
_textMessageProvider.SendTextMessage(profile.GetPhoneNumber(), FormatTextForMessage("Trouble Alert", text, ShouldDiscloseOptOut(profile.UserId)), departmentNumber, (MobileCarriers)profile.MobileCarrier, departmentId, false, false);
_textMessageProvider.SendTextMessage(ResolveDirectSendNumber(profile), FormatTextForMessage("Trouble Alert", text, ShouldDiscloseOptOut(profile.UserId)), departmentNumber, (MobileCarriers)profile.MobileCarrier, departmentId, false, false);
}
else
{
MailMessage email = new MailMessage();
email.To.Add(string.Format(Carriers.CarriersMap[(MobileCarriers)profile.MobileCarrier], profile.GetPhoneNumber()));
email.To.Add(string.Format(Carriers.CarriersMap[(MobileCarriers)profile.MobileCarrier], ResolveGatewayNumber(profile)));

email.From = new MailAddress(Config.OutboundEmailServerConfig.FromMail, "RGCall");
email.Subject = text;
Expand All @@ -381,11 +427,11 @@ public async Task<bool> SendTextAsync(string userId, string title, string messag
if (Carriers.DirectSendCarriers.Contains((MobileCarriers)profile.MobileCarrier))
{
//string departmentNumber = _departmentSettingsService.GetTextToCallNumberForDepartment(departmentId);
await _textMessageProvider.SendTextMessage(profile.GetPhoneNumber(), FormatTextForMessage(title, message, ShouldDiscloseOptOut(profile.UserId)), departmentNumber, (MobileCarriers)profile.MobileCarrier, departmentId, false, false);
await _textMessageProvider.SendTextMessage(ResolveDirectSendNumber(profile), FormatTextForMessage(title, message, ShouldDiscloseOptOut(profile.UserId)), departmentNumber, (MobileCarriers)profile.MobileCarrier, departmentId, false, false);
}
else
{
email.To.Add(string.Format(Carriers.CarriersMap[(MobileCarriers)profile.MobileCarrier], profile.GetPhoneNumber()));
email.To.Add(string.Format(Carriers.CarriersMap[(MobileCarriers)profile.MobileCarrier], ResolveGatewayNumber(profile)));

email.From = new MailAddress(Config.OutboundEmailServerConfig.FromMail, "RGNot");

Expand Down Expand Up @@ -417,18 +463,18 @@ public async Task<bool> SendNotificationAsync(string userId, int departmentId, s
{
if (Config.SystemBehaviorConfig.DepartmentsToForceSmsGateway.Contains(departmentId))
{
await _textMessageProvider.SendTextMessage(profile.GetPhoneNumber(), FormatNotificationForMessage(message, ShouldDiscloseOptOut(profile.UserId)),
await _textMessageProvider.SendTextMessage(ResolveDirectSendNumber(profile), FormatNotificationForMessage(message, ShouldDiscloseOptOut(profile.UserId)),
departmentNumber, (MobileCarriers)profile.MobileCarrier, departmentId, true, false);
}
else if (Carriers.DirectSendCarriers.Contains((MobileCarriers)profile.MobileCarrier))
{
//string departmentNumber = _departmentSettingsService.GetTextToCallNumberForDepartment(departmentId);
await _textMessageProvider.SendTextMessage(profile.GetPhoneNumber(), FormatNotificationForMessage(message, ShouldDiscloseOptOut(profile.UserId)),
await _textMessageProvider.SendTextMessage(ResolveDirectSendNumber(profile), FormatNotificationForMessage(message, ShouldDiscloseOptOut(profile.UserId)),
departmentNumber, (MobileCarriers)profile.MobileCarrier, departmentId, false, false);
}
else
{
email.To.Add(string.Format(Carriers.CarriersMap[(MobileCarriers)profile.MobileCarrier], profile.GetPhoneNumber()));
email.To.Add(string.Format(Carriers.CarriersMap[(MobileCarriers)profile.MobileCarrier], ResolveGatewayNumber(profile)));

email.From = new MailAddress(Config.OutboundEmailServerConfig.FromMail, "Resgrid");
email.Subject = "Notification";
Expand Down
104 changes: 84 additions & 20 deletions Core/Resgrid.Services/UserProfileService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

Expand Down Expand Up @@ -174,42 +175,105 @@ public void ClearAllUserProfilesFromCache(int departmentId)

public async Task<UserProfile> GetProfileByMobileNumberAsync(string number)
{
string numberToTest =
number.Replace(" ", "").Replace("(", "").Replace(")", "").Replace("+", "").Replace("-", "").Replace(".", "").Trim();
return await FindProfileByPhoneAsync(number,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Unhandled exception path in Core/Resgrid.Services/UserProfileService.cs at lines 185, 211, and this await site: return await FindProfileByPhoneAsync(number, ...) propagates repository lookup failures without contextual diagnostics. Wrap the await in try/catch, log the failure with the phone number, and then rethrow or map the exception to an application error.

Kody rule violation: Handle async operations with proper error handling

try
{
	return await FindProfileByPhoneAsync(number,
		_userProfileRepository.GetProfileByMobileNumberAsync,
		profile => profile.MobileNumberVerified);
}
catch (Exception ex)
{
	_logger.LogError(ex, "Failed to get profile by mobile number for {PhoneNumber}", number);
	throw;
}
Prompt for LLM

File Core/Resgrid.Services/UserProfileService.cs:

Line 178:

Unhandled exception path in Core/Resgrid.Services/UserProfileService.cs at lines 185, 211, and this await site: return await FindProfileByPhoneAsync(number, ...) propagates repository lookup failures without contextual diagnostics. Wrap the await in try/catch, log the failure with the phone number, and then rethrow or map the exception to an application error.

Suggested Code:

			try
			{
				return await FindProfileByPhoneAsync(number,
					_userProfileRepository.GetProfileByMobileNumberAsync,
					profile => profile.MobileNumberVerified);
			}
			catch (Exception ex)
			{
				_logger.LogError(ex, "Failed to get profile by mobile number for {PhoneNumber}", number);
				throw;
			}

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Missing structured error logging in Core/Resgrid.Services/UserProfileService.cs at lines 185, 211, and this await site obscures failures in FindProfileByPhoneAsync(number, ...) and removes operation and identifier context from diagnostics. Add a try/catch that logs the operation name and input phone number as structured fields before rethrowing.

Kody rule violation: Include error context in structured logs

try
{
	return await FindProfileByPhoneAsync(number,
		_userProfileRepository.GetProfileByMobileNumberAsync,
		profile => profile.MobileNumberVerified);
}
catch (Exception ex)
{
	_logger.LogError(ex, "GetProfileByMobileNumberAsync failed", new { operation = nameof(GetProfileByMobileNumberAsync), phoneNumber = number });
	throw;
}
Prompt for LLM

File Core/Resgrid.Services/UserProfileService.cs:

Line 178:

Missing structured error logging in Core/Resgrid.Services/UserProfileService.cs at lines 185, 211, and this await site obscures failures in FindProfileByPhoneAsync(number, ...) and removes operation and identifier context from diagnostics. Add a try/catch that logs the operation name and input phone number as structured fields before rethrowing.

Suggested Code:

			try
			{
				return await FindProfileByPhoneAsync(number,
					_userProfileRepository.GetProfileByMobileNumberAsync,
					profile => profile.MobileNumberVerified);
			}
			catch (Exception ex)
			{
				_logger.LogError(ex, "GetProfileByMobileNumberAsync failed", new { operation = nameof(GetProfileByMobileNumberAsync), phoneNumber = number });
				throw;
			}

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

_userProfileRepository.GetProfileByMobileNumberAsync,
profile => profile.MobileNumberVerified);
}

var profile = await _userProfileRepository.GetProfileByMobileNumberAsync(numberToTest);
public async Task<UserProfile> GetProfileByHomeNumberAsync(string number)
{
return await FindProfileByPhoneAsync(number,
_userProfileRepository.GetProfileByHomeNumberAsync,
profile => profile.HomeNumberVerified);
}

if (profile != null)
return profile;
/// <summary>
/// Resolves the profile that owns a phone number, preferring one that has actually proven it.
/// <para>
/// The same number can sit on more than one profile - a stale or secondary account, or someone
/// who mistyped it and never completed verification. A profile that verified the number is the
/// only one that has demonstrated possession, so it wins outright, even over a closer match on
/// the number's shape. Everything else falls back to candidate order (the number exactly as
/// dialled before its country-code variant).
/// </para>
/// <para>
/// Within a single candidate the query does the same ranking, so this only has to arbitrate
/// between candidates.
/// </para>
/// </summary>
private static async Task<UserProfile> FindProfileByPhoneAsync(string number,
Func<string, Task<UserProfile>> lookup, Func<UserProfile, bool?> isVerified)
{
UserProfile unverifiedMatch = null;

if (numberToTest.Length == 11 && numberToTest[0] == char.Parse("1"))
foreach (var candidate in PhoneLookupCandidates(number))
{
numberToTest = numberToTest.Remove(0, 1);
var profile = await lookup(candidate);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Unwrapped external call boundary in Core/Resgrid.Services/UserProfileService.cs: var profile = await lookup(candidate); can propagate raw infrastructure exceptions without candidate context. Wrap the lookup delegate invocation in try/catch, log the candidate, and rethrow or map the exception.

Kody rule violation: Add try-catch blocks for external calls

try
{
	var profile = await lookup(candidate);
	// existing logic
}
catch (Exception ex)
{
	_logger.LogError(ex, "External lookup failed for {Candidate}", candidate);
	throw;
}
Prompt for LLM

File Core/Resgrid.Services/UserProfileService.cs:

Line 211:

Unwrapped external call boundary in Core/Resgrid.Services/UserProfileService.cs: var profile = await lookup(candidate); can propagate raw infrastructure exceptions without candidate context. Wrap the lookup delegate invocation in try/catch, log the candidate, and rethrow or map the exception.

Suggested Code:

				try
				{
					var profile = await lookup(candidate);
					// existing logic
				}
				catch (Exception ex)
				{
					_logger.LogError(ex, "External lookup failed for {Candidate}", candidate);
					throw;
				}

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.


if (profile == null)
continue;

if (isVerified(profile) == true)
return profile;

return await _userProfileRepository.GetProfileByMobileNumberAsync(numberToTest);
// Keep the first one found so a candidate that matches nothing verified still resolves,
// but keep looking in case a later candidate did verify the number.
unverifiedMatch ??= profile;
}

return null;
return unverifiedMatch;
}

public async Task<UserProfile> GetProfileByHomeNumberAsync(string number)
/// <summary>
/// The stored numbers a lookup should be tried against, most-specific first.
/// <para>
/// Profiles are saved in E.164 (+12015550123) while inbound SMS and voice hand us the number in
/// whatever shape the carrier used, so a lookup has to cover the country code being present on
/// one side but not the other. The leading "+" is covered by the query itself, which matches the
/// stored value both bare and plus-prefixed.
/// </para>
/// <para>
/// The order matters and the candidates are tried one at a time rather than matched together:
/// 2015550123 and 12015550123 can be two different profiles, and the repository takes
/// FirstOrDefault() with no ORDER BY. Asking for the number exactly as dialled first means the
/// country-code variant is only ever reached as a fallback.
/// </para>
/// </summary>
private static IEnumerable<string> PhoneLookupCandidates(string number)
{
string numberToTest =
number.Replace(" ", "").Replace("(", "").Replace(")", "").Replace("+", "").Replace("-", "").Replace(".", "").Trim();
var digits = NormalizePhoneNumber(number);

var profile = await _userProfileRepository.GetProfileByMobileNumberAsync(numberToTest);
// A blank inbound number must never match: the stored column can also be blank and an
// empty-to-empty compare would hand back an arbitrary profile.
if (string.IsNullOrWhiteSpace(digits))
yield break;

if (profile != null)
return profile;
yield return digits;

if (numberToTest.Length == 11 && numberToTest[0] == char.Parse("1"))
{
numberToTest = numberToTest.Remove(0, 1);
if (digits.Length == 11 && digits[0] == '1')
yield return digits.Substring(1);
else if (digits.Length == 10)
yield return "1" + digits;
}

return await _userProfileRepository.GetProfileByMobileNumberAsync(numberToTest);
/// <summary>
/// Reduces a number to bare digits. Inbound numbers arrive formatted in assorted ways
/// ("+1 (201) 555-0123"), and only the digits are comparable against a stored number.
/// </summary>
private static string NormalizePhoneNumber(string number)
{
if (string.IsNullOrWhiteSpace(number))
return null;

var digits = new StringBuilder(number.Length);

foreach (var character in number)
{
if (character >= '0' && character <= '9')
digits.Append(character);
}

return null;
return digits.ToString();
}

public async Task<List<UserProfile>> GetSelectedUserProfilesAsync(List<string> userIds)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,7 @@ public async Task<DepartmentStats> GetDepartmentStatsByDepartmentUserIdAsync(int
var dynamicParameters = new DynamicParametersExtension();
dynamicParameters.Add("DepartmentId", departmentId);
dynamicParameters.Add("UserId", userId);
dynamicParameters.Add("CurrentDate", DateTime.UtcNow);

var query = _queryFactory.GetQuery<SelectDepartmentStatsByUserDidQuery>();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,13 @@ public string GetQuery()
_sqlConfiguration.ParameterNotation,
new string[] {
"%DID%",
"%USERID%"
"%USERID%",
"%CURRENTDATE%"
},
new string[] {
"DepartmentId",
"UserId"
"UserId",
"CurrentDate"
},
new string[] {
"%MESSAGESTABLE%",
Expand Down
Loading
Loading