feat: Enhance member profile management with gender and preferences

- Updated CreateMemberCommand and UpdateMemberProfileCommand to include optional gender and preferences fields.
- Modified Member entity to store gender and preferences, improving member data handling.
- Implemented validation for gender and preferences in command validators.
- Adjusted command handlers to support new fields during member creation and profile updates.
- Updated unit tests to verify new functionality for gender and preferences management.
This commit is contained in:
Ho Ngoc Hai
2026-01-14 19:49:29 +07:00
parent 79bc566b73
commit a68a3b976a
12 changed files with 144 additions and 215 deletions

View File

@@ -21,16 +21,10 @@ public class CreateMemberCommand : IRequest<CreateMemberResult>
public string CountryCode { get; set; } = "VN";
/// <summary>
/// EN: Phone number (optional).
/// VI: Số điện thoại (tùy chọn).
/// EN: Gender (optional).
/// VI: Giới tính (tùy chọn).
/// </summary>
public string? PhoneNumber { get; set; }
/// <summary>
/// EN: Avatar URL (optional).
/// VI: URL avatar (tùy chọn).
/// </summary>
public string? AvatarUrl { get; set; }
public string? Gender { get; set; }
}
/// <summary>

View File

@@ -30,16 +30,9 @@ public class CreateMemberCommandHandler : IRequestHandler<CreateMemberCommand, C
throw new InvalidOperationException($"Member already exists for user {request.UserId}");
}
// EN: Create new member
// VI: Tạo member mới
var member = new Member(request.UserId, request.CountryCode);
// EN: Update optional profile fields
// VI: Cập nhật các trường profile tùy chọn
if (!string.IsNullOrEmpty(request.PhoneNumber) || !string.IsNullOrEmpty(request.AvatarUrl))
{
member.UpdateProfile(request.PhoneNumber, request.AvatarUrl, null, null);
}
// EN: Create new member with gender
// VI: Tạo member mới với gender
var member = new Member(request.UserId, request.CountryCode, request.Gender);
_memberRepository.Add(member);
await _memberRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);

View File

@@ -14,29 +14,23 @@ public class UpdateMemberProfileCommand : IRequest<UpdateMemberProfileResult>
/// </summary>
public Guid MemberId { get; set; }
/// <summary>
/// EN: Phone number.
/// VI: Số điện thoại.
/// </summary>
public string? PhoneNumber { get; set; }
/// <summary>
/// EN: Avatar URL.
/// VI: URL avatar.
/// </summary>
public string? AvatarUrl { get; set; }
/// <summary>
/// EN: Date of birth.
/// VI: Ngày sinh.
/// </summary>
public DateOnly? DateOfBirth { get; set; }
/// <summary>
/// EN: Gender.
/// VI: Giới tính.
/// </summary>
public string? Gender { get; set; }
/// <summary>
/// EN: User preferences (JSON string).
/// VI: Preferences của user (chuỗi JSON).
/// </summary>
public string? Preferences { get; set; }
/// <summary>
/// EN: Country code (ISO 3166-1 alpha-2).
/// VI: Mã quốc gia (ISO 3166-1 alpha-2).
/// </summary>
public string? CountryCode { get; set; }
}
/// <summary>

View File

@@ -28,7 +28,26 @@ public class UpdateMemberProfileCommandHandler : IRequestHandler<UpdateMemberPro
throw new KeyNotFoundException($"Member {request.MemberId} not found");
}
member.UpdateProfile(request.PhoneNumber, request.AvatarUrl, request.DateOfBirth, request.Gender);
// EN: Update gender if provided
// VI: Cập nhật gender nếu được cung cấp
if (request.Gender != null)
{
member.UpdateGender(request.Gender);
}
// EN: Update country code if provided
// VI: Cập nhật country code nếu được cung cấp
if (!string.IsNullOrWhiteSpace(request.CountryCode))
{
member.UpdateCountryCode(request.CountryCode);
}
// EN: Update preferences if provided
// VI: Cập nhật preferences nếu được cung cấp
if (request.Preferences != null)
{
member.UpdatePreferences(request.Preferences);
}
_memberRepository.Update(member);
await _memberRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);

View File

@@ -20,15 +20,17 @@ public class GetMemberByIdQuery : IRequest<MemberDto?>
/// EN: Member DTO.
/// VI: DTO member.
/// </summary>
/// <remarks>
/// EN: Profile fields (avatar, phone, DOB, address) are managed by IAM Service's UserProfile.
/// VI: Các trường profile (avatar, phone, DOB, address) được quản lý bởi UserProfile của IAM Service.
/// </remarks>
public class MemberDto
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public string? PhoneNumber { get; set; }
public string? AvatarUrl { get; set; }
public DateOnly? DateOfBirth { get; set; }
public string? Gender { get; set; }
public string CountryCode { get; set; } = null!;
public string? Preferences { get; set; }
public MembershipLevelDto MembershipLevel { get; set; } = null!;
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }

View File

@@ -33,11 +33,9 @@ public class GetMemberByIdQueryHandler : IRequestHandler<GetMemberByIdQuery, Mem
{
Id = member.Id,
UserId = member.UserId,
PhoneNumber = member.PhoneNumber,
AvatarUrl = member.AvatarUrl,
DateOfBirth = member.DateOfBirth,
Gender = member.Gender,
CountryCode = member.CountryCode,
Preferences = member.Preferences,
MembershipLevel = new MembershipLevelDto
{
Id = member.MembershipLevel.Id,

View File

@@ -28,11 +28,9 @@ public class GetMembersQueryHandler : IRequestHandler<GetMembersQuery, GetMember
{
Id = m.Id,
UserId = m.UserId,
PhoneNumber = m.PhoneNumber,
AvatarUrl = m.AvatarUrl,
DateOfBirth = m.DateOfBirth,
Gender = m.Gender,
CountryCode = m.CountryCode,
Preferences = m.Preferences,
MembershipLevel = new MembershipLevelDto
{
Id = m.MembershipLevel.Id,

View File

@@ -20,23 +20,17 @@ public class CreateMemberCommandValidator : AbstractValidator<CreateMemberComman
.Length(2)
.WithMessage("CountryCode must be 2 characters");
RuleFor(x => x.PhoneNumber)
.MaximumLength(50)
.Matches(@"^\+?[\d\s-]+$")
.When(x => !string.IsNullOrEmpty(x.PhoneNumber))
.WithMessage("Invalid phone number format");
RuleFor(x => x.AvatarUrl)
.MaximumLength(500)
.Must(BeAValidUrl)
.When(x => !string.IsNullOrEmpty(x.AvatarUrl))
.WithMessage("Invalid URL format");
RuleFor(x => x.Gender)
.MaximumLength(10)
.Must(BeAValidGender)
.When(x => !string.IsNullOrEmpty(x.Gender))
.WithMessage("Gender must be Male, Female, or Other");
}
private static bool BeAValidUrl(string? url)
private static bool BeAValidGender(string? gender)
{
if (string.IsNullOrEmpty(url)) return true;
return Uri.TryCreate(url, UriKind.Absolute, out var result)
&& (result.Scheme == Uri.UriSchemeHttp || result.Scheme == Uri.UriSchemeHttps);
if (string.IsNullOrEmpty(gender)) return true;
var validGenders = new[] { "Male", "Female", "Other" };
return validGenders.Contains(gender, StringComparer.OrdinalIgnoreCase);
}
}

View File

@@ -15,30 +15,21 @@ public class UpdateMemberProfileCommandValidator : AbstractValidator<UpdateMembe
.NotEmpty()
.WithMessage("MemberId is required");
RuleFor(x => x.PhoneNumber)
.MaximumLength(50)
.Matches(@"^\+?[\d\s-]+$")
.When(x => !string.IsNullOrEmpty(x.PhoneNumber))
.WithMessage("Invalid phone number format");
RuleFor(x => x.AvatarUrl)
.MaximumLength(500)
.Must(BeAValidUrl)
.When(x => !string.IsNullOrEmpty(x.AvatarUrl))
.WithMessage("Invalid URL format");
RuleFor(x => x.Gender)
.MaximumLength(10)
.Must(BeAValidGender)
.When(x => !string.IsNullOrEmpty(x.Gender))
.WithMessage("Gender must be Male, Female, or Other");
}
private static bool BeAValidUrl(string? url)
{
if (string.IsNullOrEmpty(url)) return true;
return Uri.TryCreate(url, UriKind.Absolute, out var result)
&& (result.Scheme == Uri.UriSchemeHttp || result.Scheme == Uri.UriSchemeHttps);
RuleFor(x => x.CountryCode)
.Length(2)
.When(x => !string.IsNullOrEmpty(x.CountryCode))
.WithMessage("CountryCode must be 2 characters");
RuleFor(x => x.Preferences)
.Must(BeValidJson)
.When(x => !string.IsNullOrEmpty(x.Preferences))
.WithMessage("Preferences must be valid JSON");
}
private static bool BeAValidGender(string? gender)
@@ -47,4 +38,18 @@ public class UpdateMemberProfileCommandValidator : AbstractValidator<UpdateMembe
var validGenders = new[] { "Male", "Female", "Other" };
return validGenders.Contains(gender, StringComparer.OrdinalIgnoreCase);
}
private static bool BeValidJson(string? json)
{
if (string.IsNullOrEmpty(json)) return true;
try
{
System.Text.Json.JsonDocument.Parse(json);
return true;
}
catch
{
return false;
}
}
}

View File

@@ -8,24 +8,16 @@ namespace MembershipService.Domain.AggregatesModel.MemberAggregate;
/// VI: Member aggregate root - mở rộng thông tin user từ IAM Service.
/// </summary>
/// <remarks>
/// EN: This entity stores extended profile information for users registered in IAM Service.
/// The ID is the same as UserId from IAM Service to maintain consistency.
/// VI: Entity này lưu trữ thông tin profile mở rộng cho users đã đăng ký trong IAM Service.
/// ID giống với UserId từ IAM Service để duy trì tính nhất quán.
/// EN: This entity stores membership-specific data. Profile information (avatar, phone, address, DOB)
/// is managed by IAM Service's UserProfile. This service only handles membership level, gender, and preferences.
/// VI: Entity này lưu trữ dữ liệu membership. Thông tin profile (avatar, phone, address, DOB)
/// được quản lý bởi UserProfile của IAM Service. Service này chỉ xử lý membership level, gender, và preferences.
/// </remarks>
public class Member : Entity, IAggregateRoot
{
// EN: Private fields for encapsulation
// VI: Fields private để đóng gói
private string? _phoneNumber;
private string? _avatarUrl;
private string? _addressLine1;
private string? _addressLine2;
private string? _city;
private string? _state;
private string? _postalCode;
private string _countryCode;
private DateOnly? _dateOfBirth;
private string? _gender;
private int _membershipLevelId;
private MembershipLevel _membershipLevel = null!;
@@ -40,60 +32,12 @@ public class Member : Entity, IAggregateRoot
/// </summary>
public Guid UserId => Id;
/// <summary>
/// EN: Phone number with country code.
/// VI: Số điện thoại kèm mã quốc gia.
/// </summary>
public string? PhoneNumber => _phoneNumber;
/// <summary>
/// EN: URL to user's avatar image.
/// VI: URL đến ảnh avatar của user.
/// </summary>
public string? AvatarUrl => _avatarUrl;
/// <summary>
/// EN: Address line 1.
/// VI: Địa chỉ dòng 1.
/// </summary>
public string? AddressLine1 => _addressLine1;
/// <summary>
/// EN: Address line 2.
/// VI: Địa chỉ dòng 2.
/// </summary>
public string? AddressLine2 => _addressLine2;
/// <summary>
/// EN: City name.
/// VI: Tên thành phố.
/// </summary>
public string? City => _city;
/// <summary>
/// EN: State or province.
/// VI: Tỉnh hoặc bang.
/// </summary>
public string? State => _state;
/// <summary>
/// EN: Postal code.
/// VI: Mã bưu chính.
/// </summary>
public string? PostalCode => _postalCode;
/// <summary>
/// EN: Country code (ISO 3166-1 alpha-2).
/// VI: Mã quốc gia (ISO 3166-1 alpha-2).
/// </summary>
public string CountryCode => _countryCode;
/// <summary>
/// EN: Date of birth.
/// VI: Ngày sinh.
/// </summary>
public DateOnly? DateOfBirth => _dateOfBirth;
/// <summary>
/// EN: Gender (male, female, other).
/// VI: Giới tính (nam, nữ, khác).
@@ -151,13 +95,15 @@ public class Member : Entity, IAggregateRoot
/// </summary>
/// <param name="userId">User ID from IAM Service / User ID từ IAM Service</param>
/// <param name="countryCode">Country code (ISO 3166-1 alpha-2) / Mã quốc gia</param>
public Member(Guid userId, string countryCode = "VN") : this()
/// <param name="gender">Gender (optional) / Giới tính (tùy chọn)</param>
public Member(Guid userId, string countryCode = "VN", string? gender = null) : this()
{
if (userId == Guid.Empty)
throw new ArgumentException("User ID cannot be empty", nameof(userId));
Id = userId;
_countryCode = countryCode;
_gender = gender;
_membershipLevelId = MembershipLevel.Free.Id;
_membershipLevel = MembershipLevel.Free;
_createdAt = DateTime.UtcNow;
@@ -169,18 +115,11 @@ public class Member : Entity, IAggregateRoot
}
/// <summary>
/// EN: Update basic profile information.
/// VI: Cập nhật thông tin profile cơ bản.
/// EN: Update gender.
/// VI: Cập nhật giới tính.
/// </summary>
public void UpdateProfile(
string? phoneNumber,
string? avatarUrl,
DateOnly? dateOfBirth,
string? gender)
public void UpdateGender(string? gender)
{
_phoneNumber = phoneNumber;
_avatarUrl = avatarUrl;
_dateOfBirth = dateOfBirth;
_gender = gender;
_updatedAt = DateTime.UtcNow;
@@ -188,25 +127,14 @@ public class Member : Entity, IAggregateRoot
}
/// <summary>
/// EN: Update address information.
/// VI: Cập nhật thông tin địa chỉ.
/// EN: Update country code.
/// VI: Cập nhật mã quốc gia.
/// </summary>
public void UpdateAddress(
string? addressLine1,
string? addressLine2,
string? city,
string? state,
string? postalCode,
string countryCode)
public void UpdateCountryCode(string countryCode)
{
if (string.IsNullOrWhiteSpace(countryCode))
throw new ArgumentException("Country code cannot be empty", nameof(countryCode));
_addressLine1 = addressLine1;
_addressLine2 = addressLine2;
_city = city;
_state = state;
_postalCode = postalCode;
_countryCode = countryCode;
_updatedAt = DateTime.UtcNow;

View File

@@ -22,50 +22,15 @@ public class MemberEntityTypeConfiguration : IEntityTypeConfiguration<Member>
.HasColumnName("id")
.ValueGeneratedNever(); // EN: ID is set from IAM Service / VI: ID được set từ IAM Service
// EN: Phone number
// VI: Số điện thoại
builder.Property("_phoneNumber")
.HasColumnName("phone_number")
.HasMaxLength(50);
// EN: Avatar URL
// VI: URL avatar
builder.Property("_avatarUrl")
.HasColumnName("avatar_url")
.HasMaxLength(500);
// EN: Address fields
// VI: Các trường địa chỉ
builder.Property("_addressLine1")
.HasColumnName("address_line_1")
.HasMaxLength(255);
builder.Property("_addressLine2")
.HasColumnName("address_line_2")
.HasMaxLength(255);
builder.Property("_city")
.HasColumnName("city")
.HasMaxLength(100);
builder.Property("_state")
.HasColumnName("state")
.HasMaxLength(100);
builder.Property("_postalCode")
.HasColumnName("postal_code")
.HasMaxLength(20);
// EN: Country code
// VI: Mã quốc gia
builder.Property("_countryCode")
.HasColumnName("country_code")
.HasMaxLength(2)
.IsRequired();
// EN: Personal info
// VI: Thông tin cá nhân
builder.Property("_dateOfBirth")
.HasColumnName("date_of_birth");
// EN: Gender
// VI: Giới tính
builder.Property("_gender")
.HasColumnName("gender")
.HasMaxLength(10);

View File

@@ -42,24 +42,63 @@ public class MemberAggregateTests
}
[Fact]
public void UpdateProfile_ShouldUpdateFieldsAndRaiseEvent()
public void CreateMember_WithGender_ShouldSetGender()
{
// Arrange
var userId = Guid.NewGuid();
// Act
var member = new Member(userId, "VN", "Male");
// Assert
member.Gender.Should().Be("Male");
member.CountryCode.Should().Be("VN");
}
[Fact]
public void UpdateGender_ShouldUpdateFieldAndRaiseEvent()
{
// Arrange
var member = new Member(Guid.NewGuid());
var phone = "+84901234567";
var avatar = "https://example.com/avatar.jpg";
var dob = new DateOnly(1990, 1, 1);
var gender = "Male";
member.ClearDomainEvents();
// Act
member.UpdateProfile(phone, avatar, dob, gender);
member.UpdateGender("Female");
// Assert
member.PhoneNumber.Should().Be(phone);
member.AvatarUrl.Should().Be(avatar);
member.DateOfBirth.Should().Be(dob);
member.Gender.Should().Be(gender);
member.DomainEvents.Should().HaveCount(2); // Created + Updated
member.Gender.Should().Be("Female");
member.DomainEvents.Should().ContainSingle();
}
[Fact]
public void UpdateCountryCode_ShouldUpdateFieldAndRaiseEvent()
{
// Arrange
var member = new Member(Guid.NewGuid());
member.ClearDomainEvents();
// Act
member.UpdateCountryCode("US");
// Assert
member.CountryCode.Should().Be("US");
member.DomainEvents.Should().ContainSingle();
}
[Fact]
public void UpdatePreferences_ShouldUpdateFieldAndRaiseEvent()
{
// Arrange
var member = new Member(Guid.NewGuid());
member.ClearDomainEvents();
var preferences = "{\"theme\": \"dark\", \"language\": \"vi\"}";
// Act
member.UpdatePreferences(preferences);
// Assert
member.Preferences.Should().Be(preferences);
member.DomainEvents.Should().ContainSingle();
}
[Fact]