Skip to content
This repository has been archived by the owner on Jul 9, 2024. It is now read-only.

Change profile phone. #18

Merged
merged 5 commits into from
Apr 30, 2024
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
using Logitar.Master.Contracts.Accounts;
using MediatR;

namespace Logitar.Master.Application.Accounts.Commands;

public record ChangePhoneCommand(ChangePhonePayload Payload) : Activity, IRequest<ChangePhoneResult>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
using FluentValidation;
using Logitar.Master.Application.Accounts.Validators;
using Logitar.Master.Contracts.Accounts;
using Logitar.Portal.Contracts.Messages;
using Logitar.Portal.Contracts.Passwords;
using Logitar.Portal.Contracts.Users;
using MediatR;

namespace Logitar.Master.Application.Accounts.Commands;

internal class ChangePhoneCommandHandler : IRequestHandler<ChangePhoneCommand, ChangePhoneResult>
{
private const string ContactVerificationPurpose = "ContactVerification";
private const string ContactVerificationTemplate = "ContactVerification{ContactType}";

private readonly IMessageService _messageService;
private readonly IOneTimePasswordService _oneTimePasswordService;
private readonly IUserService _userService;

public ChangePhoneCommandHandler(IMessageService messageService, IOneTimePasswordService oneTimePasswordService, IUserService userService)
{
_messageService = messageService;
_oneTimePasswordService = oneTimePasswordService;
_userService = userService;
}

public async Task<ChangePhoneResult> Handle(ChangePhoneCommand command, CancellationToken cancellationToken)
{
if (command.User == null)
{
throw new ArgumentException($"An authenticated '{nameof(command.User)}' is required.", nameof(command));
}

ChangePhonePayload payload = command.Payload;
new ChangePhoneValidator().ValidateAndThrow(payload);

if (payload.NewPhone != null)
{
return await HandleNewPhoneAsync(payload.NewPhone, payload.Locale, command.User, cancellationToken);
}
else if (payload.OneTimePassword != null)
{
return await HandleOneTimePasswordAsync(payload.OneTimePassword, command.User, cancellationToken);
}

throw new ArgumentException($"The '{nameof(command.Payload)}' is not valid.", nameof(command));
}

private async Task<ChangePhoneResult> HandleNewPhoneAsync(AccountPhone newPhone, string locale, User user, CancellationToken cancellationToken)
{
Phone phone = newPhone.ToPhone();
OneTimePassword oneTimePassword = await _oneTimePasswordService.CreateAsync(user, ContactVerificationPurpose, phone, cancellationToken);
if (oneTimePassword.Password == null)
{
throw new InvalidOperationException($"The One-Time Password (OTP) 'Id={oneTimePassword.Id}' has no password.");
}
Dictionary<string, string> variables = new()
{
["OneTimePassword"] = oneTimePassword.Password
};
string template = ContactVerificationTemplate.Replace("{ContactType}", ContactType.Phone.ToString());
SentMessages sentMessages = await _messageService.SendAsync(template, phone, locale, variables, cancellationToken);
SentMessage sentMessage = sentMessages.ToSentMessage(phone);
OneTimePasswordValidation oneTimePasswordValidation = new(oneTimePassword, sentMessage);
return new ChangePhoneResult(oneTimePasswordValidation);
}

private async Task<ChangePhoneResult> HandleOneTimePasswordAsync(OneTimePasswordPayload payload, User user, CancellationToken cancellationToken)
{
OneTimePassword oneTimePassword = await _oneTimePasswordService.ValidateAsync(payload, ContactVerificationPurpose, cancellationToken);
Guid userId = oneTimePassword.GetUserId();
if (userId != user.Id)
{
throw new InvalidOneTimePasswordUserException(oneTimePassword, user);
}
Phone phone = oneTimePassword.GetPhone();
phone.IsVerified = true;
user = await _userService.UpdatePhoneAsync(user.Id, phone, cancellationToken);
return new ChangePhoneResult(user.ToUserProfile());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ public async Task<SignInCommandResult> Handle(SignInCommand command, Cancellatio
return await CompleteProfileAsync(payload.Profile, command.CustomAttributes, cancellationToken);
}

throw new InvalidOperationException($"The {nameof(SignInPayload)} is not valid.");
throw new ArgumentException($"The '{nameof(command.Payload)}' is not valid.", nameof(command));
}

private async Task<SignInCommandResult> HandleCredentialsAsync(Credentials credentials, string locale, IEnumerable<CustomAttribute> customAttributes, CancellationToken cancellationToken)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ namespace Logitar.Master.Application.Accounts;
public interface IMessageService
{
Task<SentMessages> SendAsync(string template, Email email, string? locale = null, Dictionary<string, string>? variables = null, CancellationToken cancellationToken = default);
Task<SentMessages> SendAsync(string template, Phone phone, string? locale = null, Dictionary<string, string>? variables = null, CancellationToken cancellationToken = default);
Task<SentMessages> SendAsync(string template, User user, string? locale = null, Dictionary<string, string>? variables = null, CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ namespace Logitar.Master.Application.Accounts;
public interface IOneTimePasswordService
{
Task<OneTimePassword> CreateAsync(User user, string purpose, CancellationToken cancellationToken = default);
Task<OneTimePassword> CreateAsync(User user, string purpose, Phone? phone, CancellationToken cancellationToken = default);
Task<OneTimePassword> ValidateAsync(OneTimePasswordPayload payload, string purpose, CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ public interface IUserService
Task<User?> FindAsync(Guid id, CancellationToken cancellationToken = default);
Task<User> SaveProfileAsync(Guid userId, SaveProfilePayload payload, CancellationToken cancellationToken = default);
Task<User> UpdateEmailAsync(Guid userId, Email email, CancellationToken cancellationToken = default);
Task<User> UpdatePhoneAsync(Guid userId, Phone phone, CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
using Logitar.Master.Contracts.Errors;
using Logitar.Portal.Contracts.Errors;
using Logitar.Identity.Domain.Shared;
using Logitar.Portal.Contracts.Passwords;

namespace Logitar.Master.Application.Accounts;

public class InvalidOneTimePasswordPurposeException : BadRequestException
public class InvalidOneTimePasswordPurposeException : InvalidCredentialsException
{
private const string ErrorMessage = "The specified purpose did not match the expected One-Time Passord (OTP) purpose.";
private new const string ErrorMessage = "The specified purpose did not match the expected One-Time Passord (OTP) purpose.";

public Guid OneTimePasswordId
{
Expand All @@ -24,8 +23,6 @@ public string? ActualPurpose
private set => Data[nameof(ActualPurpose)] = value;
}

public override Error Error => new InvalidCredentialsError();

public InvalidOneTimePasswordPurposeException(OneTimePassword oneTimePassword, string purpose) : base(BuildMessage(oneTimePassword, purpose))
{
OneTimePasswordId = oneTimePassword.Id;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using Logitar.Identity.Domain.Shared;
using Logitar.Portal.Contracts.Passwords;
using Logitar.Portal.Contracts.Users;

namespace Logitar.Master.Application.Accounts;

public class InvalidOneTimePasswordUserException : InvalidCredentialsException
{
private new const string ErrorMessage = "The specified user did not match the expected One-Time Passord (OTP) user.";

public Guid OneTimePasswordId
{
get => (Guid)Data[nameof(OneTimePasswordId)]!;
private set => Data[nameof(OneTimePasswordId)] = value;
}
public Guid? ExpectedUserId
{
get => (Guid?)Data[nameof(ExpectedUserId)];
private set => Data[nameof(ExpectedUserId)] = value;
}
public Guid? ActualUserId
{
get => (Guid?)Data[nameof(ActualUserId)];
private set => Data[nameof(ActualUserId)] = value;
}

public InvalidOneTimePasswordUserException(OneTimePassword oneTimePassword, User? user) : base(BuildMessage(oneTimePassword, user))
{
OneTimePasswordId = oneTimePassword.Id;
ExpectedUserId = oneTimePassword.TryGetUserId();
ActualUserId = user?.Id;
}

private static string BuildMessage(OneTimePassword oneTimePassword, User? user) => new ErrorMessageBuilder(ErrorMessage)
.AddData(nameof(OneTimePasswordId), oneTimePassword.Id)
.AddData(nameof(ExpectedUserId), oneTimePassword.TryGetUserId(), "<null>")
.AddData(nameof(ActualUserId), user?.Id, "<null>")
.Build();
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,65 @@ namespace Logitar.Master.Application.Accounts;

public static class OneTimePasswordExtensions
{
private const string PhoneCountryCodeKey = "PhoneCountryCode";
private const string PhoneNumberKey = "PhoneNumber";
private const string PhoneE164FormattedKey = "PhoneE164Formatted";
private const string PurposeKey = "Purpose";
private const string UserIdKey = "UserId";

public static Guid GetUserId(this OneTimePassword oneTimePassword)
{
CustomAttribute customAttribute = oneTimePassword.CustomAttributes.SingleOrDefault(x => x.Key == UserIdKey)
?? throw new InvalidOperationException($"The One-Time Password (OTP) has no '{UserIdKey}' custom attribute.");
return Guid.Parse(customAttribute.Value);
Guid? userId = oneTimePassword.TryGetUserId();
return userId ?? throw new ArgumentException($"The One-Time Password (OTP) has no '{UserIdKey}' custom attribute.", nameof(oneTimePassword));
}
public static Guid? TryGetUserId(this OneTimePassword oneTimePassword)
{
CustomAttribute? customAttribute = oneTimePassword.CustomAttributes.SingleOrDefault(x => x.Key == UserIdKey);
return customAttribute == null ? null : Guid.Parse(customAttribute.Value);
}
public static void SetUserId(this CreateOneTimePasswordPayload payload, User user)
{
CustomAttribute? customAttribute = payload.CustomAttributes.SingleOrDefault(x => x.Key == UserIdKey);
if (customAttribute == null)
payload.CustomAttributes.Add(new CustomAttribute(UserIdKey, user.Id.ToString()));
}

public static Phone GetPhone(this OneTimePassword oneTimePassword)
{
return oneTimePassword.TryGetPhone()
?? throw new ArgumentException("The One-Time Password (OTP) does not have phone custom attributes.", nameof(oneTimePassword));
}
public static Phone? TryGetPhone(this OneTimePassword oneTimePassword)
{
Phone phone = new();
foreach (CustomAttribute customAttribute in oneTimePassword.CustomAttributes)
{
switch (customAttribute.Key)
{
case PhoneCountryCodeKey:
phone.CountryCode = customAttribute.Value;
break;
case PhoneNumberKey:
phone.Number = customAttribute.Value;
break;
case PhoneE164FormattedKey:
phone.E164Formatted = customAttribute.Value;
break;
}
}

if (string.IsNullOrWhiteSpace(phone.Number) || string.IsNullOrWhiteSpace(phone.E164Formatted))
{
customAttribute = new(UserIdKey, user.Id.ToString());
payload.CustomAttributes.Add(customAttribute);
return null;
}
else
return phone;
}
public static void SetPhone(this CreateOneTimePasswordPayload payload, Phone phone)
{
if (phone.CountryCode != null)
{
customAttribute.Value = user.Id.ToString();
payload.CustomAttributes.Add(new CustomAttribute(PhoneCountryCodeKey, phone.CountryCode));
}
payload.CustomAttributes.Add(new CustomAttribute(PhoneNumberKey, phone.Number));
payload.CustomAttributes.Add(new CustomAttribute(PhoneE164FormattedKey, phone.E164Formatted));
}

public static void EnsurePurpose(this OneTimePassword oneTimePassword, string purpose)
Expand All @@ -43,7 +81,7 @@ public static bool HasPurpose(this OneTimePassword oneTimePassword, string purpo
public static string GetPurpose(this OneTimePassword oneTimePassword)
{
string? purpose = oneTimePassword.TryGetPurpose();
return purpose ?? throw new InvalidOperationException($"The One-Time Password (OTP) has no '{PurposeKey}' custom attribute.");
return purpose ?? throw new ArgumentException($"The One-Time Password (OTP) has no '{PurposeKey}' custom attribute.", nameof(oneTimePassword));
}
public static string? TryGetPurpose(this OneTimePassword oneTimePassword)
{
Expand All @@ -52,15 +90,6 @@ public static string GetPurpose(this OneTimePassword oneTimePassword)
}
public static void SetPurpose(this CreateOneTimePasswordPayload payload, string purpose)
{
CustomAttribute? customAttribute = payload.CustomAttributes.SingleOrDefault(x => x.Key == PurposeKey);
if (customAttribute == null)
{
customAttribute = new(PurposeKey, purpose);
payload.CustomAttributes.Add(customAttribute);
}
else
{
customAttribute.Value = purpose;
}
payload.CustomAttributes.Add(new CustomAttribute(PurposeKey, purpose));
}
}
Original file line number Diff line number Diff line change
@@ -1,20 +1,17 @@
using Logitar.Master.Contracts.Errors;
using Logitar.Portal.Contracts.Errors;
using Logitar.Identity.Domain.Shared;

namespace Logitar.Master.Application.Accounts;

public class OneTimePasswordNotFoundException : BadRequestException
public class OneTimePasswordNotFoundException : InvalidCredentialsException
{
private const string ErrorMessage = "The specified One-Time Password (OTP) could not be found.";
private new const string ErrorMessage = "The specified One-Time Password (OTP) could not be found.";

public Guid OneTimePasswordId
{
get => (Guid)Data[nameof(OneTimePasswordId)]!;
private set => Data[nameof(OneTimePasswordId)] = value;
}

public override Error Error => new InvalidCredentialsError();

public OneTimePasswordNotFoundException(Guid oneTimePasswordId) : base(BuildMessage(oneTimePasswordId))
{
OneTimePasswordId = oneTimePasswordId;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Logitar.Master.Contracts.Accounts;
using Logitar.Identity.Domain.Users;
using Logitar.Master.Contracts.Accounts;
using Logitar.Portal.Contracts;
using Logitar.Portal.Contracts.Users;

Expand Down Expand Up @@ -36,6 +37,13 @@ public static bool IsProfileCompleted(this User user)
return customAttribute == null ? null : DateTime.Parse(customAttribute.Value);
}

public static Phone ToPhone(this AccountPhone phone)
{
Phone result = new(phone.CountryCode?.CleanTrim(), phone.Number.Trim(), extension: null, e164Formatted: string.Empty);
result.E164Formatted = result.FormatToE164();
return result;
}

public static UserProfile ToUserProfile(this User user) => new()
{
CreatedOn = user.CreatedOn,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using FluentValidation;
using Logitar.Identity.Domain.Users;
using Logitar.Master.Contracts.Accounts;
using Logitar.Portal.Contracts.Users;

namespace Logitar.Master.Application.Accounts.Validators;

internal class AccountPhoneValidator : AbstractValidator<AccountPhone>
{
public AccountPhoneValidator()
{
When(x => x.CountryCode != null, () => RuleFor(x => x.CountryCode).NotEmpty().Length(2));
RuleFor(x => x.Number).NotEmpty().MaximumLength(20);

RuleFor(x => x).Must(BeAValidPhone).WithErrorCode("PhoneValidator").WithMessage("'{PropertyName}' must be a valid phone.");
}

private static bool BeAValidPhone(AccountPhone input)
{
Phone phone = new(input.CountryCode, input.Number, extension: null, e164Formatted: string.Empty);
return phone.IsValid();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using FluentValidation;
using Logitar.Identity.Domain.Shared;
using Logitar.Master.Contracts.Accounts;

namespace Logitar.Master.Application.Accounts.Validators;

internal class ChangePhoneValidator : AbstractValidator<ChangePhonePayload>
{
public ChangePhoneValidator()
{
RuleFor(x => x.Locale).SetValidator(new LocaleValidator());

When(x => x.NewPhone != null, () => RuleFor(x => x.NewPhone!).SetValidator(new AccountPhoneValidator()));
When(x => x.OneTimePassword != null, () => RuleFor(x => x.OneTimePassword!).SetValidator(new OneTimePasswordValidator()));

RuleFor(x => x).Must(BeAValidPayload).WithErrorCode(nameof(ChangePhoneValidator))
.WithMessage(x => $"Exactly one of the following must be specified: {nameof(x.NewPhone)}, {nameof(x.OneTimePassword)}.");
}

private static bool BeAValidPayload(ChangePhonePayload payload)
{
int count = 0;
if (payload.NewPhone != null)
{
count++;
}
if (payload.OneTimePassword != null)
{
count++;
}
return count == 1;
}
}
Loading