diff --git a/docs/en/architecture/iam-proposal.md b/docs/en/architecture/iam-proposal.md index 5d8a6d0d..5780783c 100644 --- a/docs/en/architecture/iam-proposal.md +++ b/docs/en/architecture/iam-proposal.md @@ -230,7 +230,41 @@ graph TD | `PUT` | `/api/v1/roles/{id}` | Cập nhật role | ✅ Admin | | `DELETE` | `/api/v1/roles/{id}` | Xóa role | ✅ Admin | -### 4.2 Access Management APIs +### 4.4 Organization & Group APIs ✅ (New in Phase 2) + +| Method | Endpoint | Mô tả | Auth | +|--------|----------|-------|------| +| `GET` | `/api/v1/organizations/{id}` | Lấy tổ chức theo ID | ✅ | +| `GET` | `/api/v1/organizations/slug/{slug}` | Lấy tổ chức theo slug | ✅ | +| `POST` | `/api/v1/organizations` | Tạo tổ chức mới | ✅ | +| `PUT` | `/api/v1/organizations/{id}` | Cập nhật tổ chức | ✅ | +| `DELETE` | `/api/v1/organizations/{id}` | Lưu trữ (archive) tổ chức | ✅ | +| `GET` | `/api/v1/organizations/{id}/hierarchy` | Lấy phân cấp tổ chức | ✅ | +| `GET` | `/api/v1/organizations/{id}/children` | Lấy tổ chức con | ✅ | +| `GET` | `/api/v1/groups` | Danh sách groups theo organizationId | ✅ | +| `GET` | `/api/v1/groups/{id}` | Lấy group theo ID | ✅ | +| `POST` | `/api/v1/groups` | Tạo group mới | ✅ | +| `DELETE` | `/api/v1/groups/{id}` | Xóa group (soft delete) | ✅ | +| `POST` | `/api/v1/groups/{id}/members` | Thêm thành viên vào group | ✅ | +| `DELETE` | `/api/v1/groups/{id}/members/{userId}` | Xóa thành viên khỏi group | ✅ | + +### 4.5 User Profile APIs ✅ (New in Phase 2) + +| Method | Endpoint | Mô tả | Auth | +|--------|----------|-------|------| +| `GET` | `/api/v1/users/{id}/profile` | Lấy profile user | ✅ | +| `PUT` | `/api/v1/users/{id}/profile` | Cập nhật profile user | ✅ | +| `PUT` | `/api/v1/users/{id}/profile/attributes/{key}` | Đặt profile attribute | ✅ | + +### 4.6 Identity Verification APIs ✅ (New in Phase 2) + +| Method | Endpoint | Mô tả | Auth | +|--------|----------|-------|------| +| `POST` | `/api/v1/verifications/phone` | Yêu cầu xác thực số điện thoại | ✅ | +| `POST` | `/api/v1/verifications/email` | Yêu cầu xác thực email | ✅ | +| `POST` | `/api/v1/verifications/{id}/confirm` | Xác nhận với OTP code | ✅ | + +### 4.7 Access Management APIs (Planned) ``` # Access Requests @@ -281,17 +315,26 @@ GET /api/v1/governance/reports/security-events ## 5. Implementation Roadmap -### Phase 1: Foundation (Weeks 1-4) -- ✅ Migrate từ auth-service sang iam-service -- 🔄 Tổ chức lại modules theo IAM structure -- 🔄 Mở rộng database schema với identity models -- 🔄 Implement User Profile module +### Phase 1: Foundation ✅ (Completed) +- ✅ Migrate từ auth-service sang iam-service (.NET 10 + Duende IdentityServer) +- ✅ CQRS với MediatR Pattern +- ✅ User Registration, Login, Logout +- ✅ Password Management (change-password) +- ✅ User Management APIs (CRUD) +- ✅ Role Management APIs -### Phase 2: Identity Management (Weeks 5-8) -- 🔄 User lifecycle management -- 🔄 Identity verification (email, phone, document) -- 🔄 Organization & Group management -- 🔄 Profile management with extended attributes +### Phase 1.5: Enhanced Security ✅ (Completed) +- ✅ Email Verification (send + confirm) +- ✅ 2FA/MFA với TOTP (QR Code, Recovery Codes) +- ✅ Social Login (Google, Facebook OAuth) +- ✅ Distributed Caching với Redis (ICacheService) +- ✅ Token Blacklisting cho logout + +### Phase 2: Identity Management ✅ (Completed) +- ✅ User lifecycle management +- ✅ Identity verification (phone, email) +- ✅ Organization & Group management +- ✅ Profile management with extended attributes (ProfileAttribute entity) ### Phase 3: Access Management (Weeks 9-12) - 🔄 Access request/approval workflows diff --git a/docs/vi/architecture/iam-proposal.md b/docs/vi/architecture/iam-proposal.md index b1154201..6f59e868 100644 --- a/docs/vi/architecture/iam-proposal.md +++ b/docs/vi/architecture/iam-proposal.md @@ -256,7 +256,41 @@ graph TD | `PUT` | `/api/v1/roles/{id}` | Cập nhật role | ✅ Admin | | `DELETE` | `/api/v1/roles/{id}` | Xóa role | ✅ Admin | -### 4.7 Access Management APIs (Planned) +### 4.7 Organization & Group APIs ✅ (New in Phase 2) + +| Method | Endpoint | Mô tả | Auth | +|--------|----------|-------|------| +| `GET` | `/api/v1/organizations/{id}` | Lấy tổ chức theo ID | ✅ | +| `GET` | `/api/v1/organizations/slug/{slug}` | Lấy tổ chức theo slug | ✅ | +| `POST` | `/api/v1/organizations` | Tạo tổ chức mới | ✅ | +| `PUT` | `/api/v1/organizations/{id}` | Cập nhật tổ chức | ✅ | +| `DELETE` | `/api/v1/organizations/{id}` | Lưu trữ (archive) tổ chức | ✅ | +| `GET` | `/api/v1/organizations/{id}/hierarchy` | Lấy phân cấp tổ chức | ✅ | +| `GET` | `/api/v1/organizations/{id}/children` | Lấy tổ chức con | ✅ | +| `GET` | `/api/v1/groups` | Danh sách groups theo organizationId | ✅ | +| `GET` | `/api/v1/groups/{id}` | Lấy group theo ID | ✅ | +| `POST` | `/api/v1/groups` | Tạo group mới | ✅ | +| `DELETE` | `/api/v1/groups/{id}` | Xóa group (soft delete) | ✅ | +| `POST` | `/api/v1/groups/{id}/members` | Thêm thành viên vào group | ✅ | +| `DELETE` | `/api/v1/groups/{id}/members/{userId}` | Xóa thành viên khỏi group | ✅ | + +### 4.8 User Profile APIs ✅ (New in Phase 2) + +| Method | Endpoint | Mô tả | Auth | +|--------|----------|-------|------| +| `GET` | `/api/v1/users/{id}/profile` | Lấy profile user | ✅ | +| `PUT` | `/api/v1/users/{id}/profile` | Cập nhật profile user | ✅ | +| `PUT` | `/api/v1/users/{id}/profile/attributes/{key}` | Đặt profile attribute | ✅ | + +### 4.9 Identity Verification APIs ✅ (New in Phase 2) + +| Method | Endpoint | Mô tả | Auth | +|--------|----------|-------|------| +| `POST` | `/api/v1/verifications/phone` | Yêu cầu xác thực số điện thoại | ✅ | +| `POST` | `/api/v1/verifications/email` | Yêu cầu xác thực email | ✅ | +| `POST` | `/api/v1/verifications/{id}/confirm` | Xác nhận với OTP code | ✅ | + +### 4.10 Access Management APIs (Planned) > [!NOTE] > Các APIs dưới đây là tính năng **đang được lên kế hoạch**, chưa triển khai. @@ -328,11 +362,11 @@ GET /api/v1/governance/reports/security-events - ✅ Distributed Caching với Redis (ICacheService) - ✅ Token Blacklisting cho logout -### Phase 2: Identity Management (Planned) -- 🔄 User lifecycle management -- 🔄 Identity verification (phone, document - KYC) -- 🔄 Organization & Group management -- 🔄 Profile management with extended attributes +### Phase 2: Identity Management ✅ (Completed) +- ✅ User lifecycle management +- ✅ Identity verification (phone, email) +- ✅ Organization & Group management +- ✅ Profile management with extended attributes (ProfileAttribute entity) ### Phase 3: Access Management (Planned) - 🔄 Access request/approval workflows @@ -376,30 +410,12 @@ GET /api/v1/governance/reports/security-events - ✅ Better user experience - ✅ Enhanced security với MFA & verification ---- - -## 7. Migration Strategy - -### Từ Auth Service → IAM Service - -1. **Rename Service**: `services/auth-service` → `services/iam-service` -2. **Update Package Name**: `@goodgo/auth-service` → `@goodgo/iam-service` -3. **Update Routes**: - - Giữ backward compatibility với `/api/v1/auth/*` - - Thêm routes mới cho `/api/v1/identity/*`, `/api/v1/access/*`, `/api/v1/governance/*` -4. **Database Migration**: - - Thêm schema mới cho identity, access, governance - - Giữ nguyên các tables hiện có (backward compatible) -5. **Gradual Rollout**: - - Phase 1: Deploy cùng auth-service (dual deployment) - - Phase 2: Migrate clients dần dần - - Phase 3: Deprecate auth-service khi migration hoàn tất --- ## Kết Luận -Đề xuất này mở rộng `auth-service` thành `IAM Service` với đầy đủ các tính năng: +Đề xuất này mở rộng thành `IAM Service` với đầy đủ các tính năng: - **Identity Management** đầy đủ - **Access Management** nâng cao - **Governance & Compliance** toàn diện diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Groups/AddGroupMemberCommand.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Groups/AddGroupMemberCommand.cs new file mode 100644 index 00000000..55c18bb8 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Groups/AddGroupMemberCommand.cs @@ -0,0 +1,23 @@ +using MediatR; + +namespace IamService.API.Application.Commands.Groups; + +/// +/// EN: Command to add a member to a group. +/// VI: Command để thêm thành viên vào nhóm. +/// +public record AddGroupMemberCommand( + Guid GroupId, + Guid UserId, + int? RoleId = null, + Guid? AddedByUserId = null) : IRequest; + +/// +/// EN: Result of AddGroupMemberCommand. +/// VI: Kết quả của AddGroupMemberCommand. +/// +public record AddGroupMemberCommandResult( + Guid GroupId, + Guid UserId, + string Role, + DateTime JoinedAt); diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Groups/AddGroupMemberCommandHandler.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Groups/AddGroupMemberCommandHandler.cs new file mode 100644 index 00000000..852346b6 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Groups/AddGroupMemberCommandHandler.cs @@ -0,0 +1,64 @@ +using MediatR; +using IamService.Domain.AggregatesModel.GroupAggregate; +using IamService.Domain.Exceptions; + +namespace IamService.API.Application.Commands.Groups; + +/// +/// EN: Handler for AddGroupMemberCommand. +/// VI: Handler cho AddGroupMemberCommand. +/// +public class AddGroupMemberCommandHandler : IRequestHandler +{ + private readonly IGroupRepository _groupRepository; + private readonly ILogger _logger; + + public AddGroupMemberCommandHandler( + IGroupRepository groupRepository, + ILogger logger) + { + _groupRepository = groupRepository; + _logger = logger; + } + + public async Task Handle( + AddGroupMemberCommand request, + CancellationToken cancellationToken) + { + _logger.LogInformation("Adding user {UserId} to group {GroupId}", + request.UserId, request.GroupId); + + var group = await _groupRepository.GetByIdWithMembersAsync(request.GroupId, cancellationToken); + + if (group == null) + { + throw new DomainException($"Group with ID '{request.GroupId}' not found."); + } + + // EN: Get role by ID or use default (Member) + // VI: Lấy vai trò theo ID hoặc dùng mặc định (Member) + GroupRole? role = null; + if (request.RoleId.HasValue) + { + role = GroupRole.FromId(request.RoleId.Value); + } + + var member = group.AddMember(request.UserId, role, request.AddedByUserId); + + _groupRepository.Update(group); + + await _groupRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "User {UserId} added to group {GroupId} with role {Role}", + request.UserId, + request.GroupId, + member.Role.Name); + + return new AddGroupMemberCommandResult( + group.Id, + member.UserId, + member.Role.Name, + member.JoinedAt); + } +} diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Groups/CreateGroupCommand.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Groups/CreateGroupCommand.cs new file mode 100644 index 00000000..a67a12ae --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Groups/CreateGroupCommand.cs @@ -0,0 +1,23 @@ +using MediatR; + +namespace IamService.API.Application.Commands.Groups; + +/// +/// EN: Command to create a new group. +/// VI: Command để tạo nhóm mới. +/// +public record CreateGroupCommand( + string Name, + Guid OrganizationId, + string? Description = null) : IRequest; + +/// +/// EN: Result of CreateGroupCommand. +/// VI: Kết quả của CreateGroupCommand. +/// +public record CreateGroupCommandResult( + Guid Id, + string Name, + string? Description, + Guid OrganizationId, + DateTime CreatedAt); diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Groups/CreateGroupCommandHandler.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Groups/CreateGroupCommandHandler.cs new file mode 100644 index 00000000..25408cd3 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Groups/CreateGroupCommandHandler.cs @@ -0,0 +1,68 @@ +using MediatR; +using IamService.Domain.AggregatesModel.GroupAggregate; +using IamService.Domain.AggregatesModel.OrganizationAggregate; +using IamService.Domain.Exceptions; + +namespace IamService.API.Application.Commands.Groups; + +/// +/// EN: Handler for CreateGroupCommand. +/// VI: Handler cho CreateGroupCommand. +/// +public class CreateGroupCommandHandler : IRequestHandler +{ + private readonly IGroupRepository _groupRepository; + private readonly IOrganizationRepository _organizationRepository; + private readonly ILogger _logger; + + public CreateGroupCommandHandler( + IGroupRepository groupRepository, + IOrganizationRepository organizationRepository, + ILogger logger) + { + _groupRepository = groupRepository; + _organizationRepository = organizationRepository; + _logger = logger; + } + + public async Task Handle( + CreateGroupCommand request, + CancellationToken cancellationToken) + { + _logger.LogInformation("Creating group: {Name} in organization: {OrganizationId}", + request.Name, request.OrganizationId); + + // EN: Validate organization exists + // VI: Kiểm tra tổ chức tồn tại + var organization = await _organizationRepository.GetByIdAsync( + request.OrganizationId, cancellationToken); + + if (organization == null) + { + throw new DomainException($"Organization with ID '{request.OrganizationId}' not found."); + } + + // EN: Create new group (note: organizationId, name, description) + // VI: Tạo nhóm mới (lưu ý: organizationId, name, description) + var group = Group.Create( + request.OrganizationId, + request.Name, + request.Description); + + _groupRepository.Add(group); + + await _groupRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "Group {Name} created successfully with ID {Id}", + group.Name, + group.Id); + + return new CreateGroupCommandResult( + group.Id, + group.Name, + group.Description, + group.OrganizationId, + group.CreatedAt); + } +} diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Groups/DeleteGroupCommand.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Groups/DeleteGroupCommand.cs new file mode 100644 index 00000000..05e677de --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Groups/DeleteGroupCommand.cs @@ -0,0 +1,9 @@ +using MediatR; + +namespace IamService.API.Application.Commands.Groups; + +/// +/// EN: Command to delete a group. +/// VI: Command để xóa nhóm. +/// +public record DeleteGroupCommand(Guid Id) : IRequest; diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Groups/DeleteGroupCommandHandler.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Groups/DeleteGroupCommandHandler.cs new file mode 100644 index 00000000..fb51dd33 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Groups/DeleteGroupCommandHandler.cs @@ -0,0 +1,45 @@ +using MediatR; +using IamService.Domain.AggregatesModel.GroupAggregate; +using IamService.Domain.Exceptions; + +namespace IamService.API.Application.Commands.Groups; + +/// +/// EN: Handler for DeleteGroupCommand. +/// VI: Handler cho DeleteGroupCommand. +/// +public class DeleteGroupCommandHandler : IRequestHandler +{ + private readonly IGroupRepository _groupRepository; + private readonly ILogger _logger; + + public DeleteGroupCommandHandler( + IGroupRepository groupRepository, + ILogger logger) + { + _groupRepository = groupRepository; + _logger = logger; + } + + public async Task Handle(DeleteGroupCommand request, CancellationToken cancellationToken) + { + _logger.LogInformation("Deleting group: {Id}", request.Id); + + var group = await _groupRepository.GetByIdAsync(request.Id, cancellationToken); + + if (group == null) + { + throw new DomainException($"Group with ID '{request.Id}' not found."); + } + + // EN: Soft delete + // VI: Xóa mềm + _groupRepository.Delete(group); + + await _groupRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation("Group {Id} deleted successfully", request.Id); + + return true; + } +} diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Groups/RemoveGroupMemberCommand.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Groups/RemoveGroupMemberCommand.cs new file mode 100644 index 00000000..e33fa37e --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Groups/RemoveGroupMemberCommand.cs @@ -0,0 +1,11 @@ +using MediatR; + +namespace IamService.API.Application.Commands.Groups; + +/// +/// EN: Command to remove a member from a group. +/// VI: Command để xóa thành viên khỏi nhóm. +/// +public record RemoveGroupMemberCommand( + Guid GroupId, + Guid UserId) : IRequest; diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Groups/RemoveGroupMemberCommandHandler.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Groups/RemoveGroupMemberCommandHandler.cs new file mode 100644 index 00000000..67332510 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Groups/RemoveGroupMemberCommandHandler.cs @@ -0,0 +1,46 @@ +using MediatR; +using IamService.Domain.AggregatesModel.GroupAggregate; +using IamService.Domain.Exceptions; + +namespace IamService.API.Application.Commands.Groups; + +/// +/// EN: Handler for RemoveGroupMemberCommand. +/// VI: Handler cho RemoveGroupMemberCommand. +/// +public class RemoveGroupMemberCommandHandler : IRequestHandler +{ + private readonly IGroupRepository _groupRepository; + private readonly ILogger _logger; + + public RemoveGroupMemberCommandHandler( + IGroupRepository groupRepository, + ILogger logger) + { + _groupRepository = groupRepository; + _logger = logger; + } + + public async Task Handle(RemoveGroupMemberCommand request, CancellationToken cancellationToken) + { + _logger.LogInformation("Removing user {UserId} from group {GroupId}", + request.UserId, request.GroupId); + + var group = await _groupRepository.GetByIdWithMembersAsync(request.GroupId, cancellationToken); + + if (group == null) + { + throw new DomainException($"Group with ID '{request.GroupId}' not found."); + } + + group.RemoveMember(request.UserId); + + _groupRepository.Update(group); + + await _groupRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation("User {UserId} removed from group {GroupId}", request.UserId, request.GroupId); + + return true; + } +} diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Organizations/ArchiveOrganizationCommand.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Organizations/ArchiveOrganizationCommand.cs new file mode 100644 index 00000000..69de66f7 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Organizations/ArchiveOrganizationCommand.cs @@ -0,0 +1,9 @@ +using MediatR; + +namespace IamService.API.Application.Commands.Organizations; + +/// +/// EN: Command to archive (soft delete) an organization. +/// VI: Command để lưu trữ (xóa mềm) tổ chức. +/// +public record ArchiveOrganizationCommand(Guid Id) : IRequest; diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Organizations/ArchiveOrganizationCommandHandler.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Organizations/ArchiveOrganizationCommandHandler.cs new file mode 100644 index 00000000..c598aaa6 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Organizations/ArchiveOrganizationCommandHandler.cs @@ -0,0 +1,53 @@ +using MediatR; +using IamService.Domain.AggregatesModel.OrganizationAggregate; +using IamService.Domain.Exceptions; + +namespace IamService.API.Application.Commands.Organizations; + +/// +/// EN: Handler for ArchiveOrganizationCommand. +/// VI: Handler cho ArchiveOrganizationCommand. +/// +public class ArchiveOrganizationCommandHandler : IRequestHandler +{ + private readonly IOrganizationRepository _organizationRepository; + private readonly ILogger _logger; + + public ArchiveOrganizationCommandHandler( + IOrganizationRepository organizationRepository, + ILogger logger) + { + _organizationRepository = organizationRepository; + _logger = logger; + } + + public async Task Handle(ArchiveOrganizationCommand request, CancellationToken cancellationToken) + { + _logger.LogInformation("Archiving organization: {Id}", request.Id); + + var organization = await _organizationRepository.GetByIdAsync(request.Id, cancellationToken); + + if (organization == null) + { + throw new DomainException($"Organization with ID '{request.Id}' not found."); + } + + // EN: Check if organization has children + // VI: Kiểm tra tổ chức có con không + var children = await _organizationRepository.GetChildrenAsync(request.Id, cancellationToken); + if (children.Any()) + { + throw new DomainException("Cannot archive organization with child organizations. Archive children first."); + } + + organization.Archive(); + + _organizationRepository.Update(organization); + + await _organizationRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation("Organization {Id} archived successfully", request.Id); + + return true; + } +} diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Organizations/CreateOrganizationCommand.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Organizations/CreateOrganizationCommand.cs new file mode 100644 index 00000000..353eb63f --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Organizations/CreateOrganizationCommand.cs @@ -0,0 +1,30 @@ +using MediatR; + +namespace IamService.API.Application.Commands.Organizations; + +/// +/// EN: Command to create a new organization. +/// VI: Command để tạo tổ chức mới. +/// +/// Organization name / Tên tổ chức +/// URL-friendly slug / Slug thân thiện URL +/// Optional description / Mô tả tùy chọn +/// Optional parent organization ID / ID tổ chức cha tùy chọn +public record CreateOrganizationCommand( + string Name, + string Slug, + string? Description = null, + Guid? ParentOrganizationId = null) : IRequest; + +/// +/// EN: Result of CreateOrganizationCommand. +/// VI: Kết quả của CreateOrganizationCommand. +/// +public record CreateOrganizationCommandResult( + Guid Id, + string Name, + string Slug, + string? Description, + Guid? ParentOrganizationId, + string Status, + DateTime CreatedAt); diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Organizations/CreateOrganizationCommandHandler.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Organizations/CreateOrganizationCommandHandler.cs new file mode 100644 index 00000000..ae373f46 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Organizations/CreateOrganizationCommandHandler.cs @@ -0,0 +1,79 @@ +using MediatR; +using IamService.Domain.AggregatesModel.OrganizationAggregate; +using IamService.Domain.Exceptions; + +namespace IamService.API.Application.Commands.Organizations; + +/// +/// EN: Handler for CreateOrganizationCommand. +/// VI: Handler cho CreateOrganizationCommand. +/// +public class CreateOrganizationCommandHandler : IRequestHandler +{ + private readonly IOrganizationRepository _organizationRepository; + private readonly ILogger _logger; + + public CreateOrganizationCommandHandler( + IOrganizationRepository organizationRepository, + ILogger logger) + { + _organizationRepository = organizationRepository; + _logger = logger; + } + + public async Task Handle( + CreateOrganizationCommand request, + CancellationToken cancellationToken) + { + _logger.LogInformation("Creating organization: {Name} with slug: {Slug}", request.Name, request.Slug); + + // EN: Check if slug is unique + // VI: Kiểm tra slug có duy nhất không + var isSlugUnique = await _organizationRepository.IsSlugUniqueAsync(request.Slug, cancellationToken: cancellationToken); + if (!isSlugUnique) + { + _logger.LogWarning("Slug already exists: {Slug}", request.Slug); + throw new DomainException($"Organization with slug '{request.Slug}' already exists."); + } + + // EN: Validate parent organization if provided + // VI: Kiểm tra tổ chức cha nếu được cung cấp + if (request.ParentOrganizationId.HasValue) + { + var parent = await _organizationRepository.GetByIdAsync( + request.ParentOrganizationId.Value, + cancellationToken); + + if (parent == null) + { + throw new DomainException($"Parent organization with ID '{request.ParentOrganizationId}' not found."); + } + } + + // EN: Create new organization + // VI: Tạo tổ chức mới + var organization = Organization.Create( + request.Name, + request.Slug, + request.Description, + request.ParentOrganizationId); + + _organizationRepository.Add(organization); + + await _organizationRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "Organization {Name} created successfully with ID {Id}", + organization.Name, + organization.Id); + + return new CreateOrganizationCommandResult( + organization.Id, + organization.Name, + organization.Slug, + organization.Description, + organization.ParentOrganizationId, + organization.Status.Name, + organization.CreatedAt); + } +} diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Organizations/UpdateOrganizationCommand.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Organizations/UpdateOrganizationCommand.cs new file mode 100644 index 00000000..fbdbd648 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Organizations/UpdateOrganizationCommand.cs @@ -0,0 +1,22 @@ +using MediatR; + +namespace IamService.API.Application.Commands.Organizations; + +/// +/// EN: Command to update an organization. +/// VI: Command để cập nhật tổ chức. +/// +public record UpdateOrganizationCommand( + Guid Id, + string Name, + string? Description) : IRequest; + +/// +/// EN: Result of UpdateOrganizationCommand. +/// VI: Kết quả của UpdateOrganizationCommand. +/// +public record UpdateOrganizationCommandResult( + Guid Id, + string Name, + string? Description, + DateTime? UpdatedAt); diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Organizations/UpdateOrganizationCommandHandler.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Organizations/UpdateOrganizationCommandHandler.cs new file mode 100644 index 00000000..dcf9cf4f --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Organizations/UpdateOrganizationCommandHandler.cs @@ -0,0 +1,51 @@ +using MediatR; +using IamService.Domain.AggregatesModel.OrganizationAggregate; +using IamService.Domain.Exceptions; + +namespace IamService.API.Application.Commands.Organizations; + +/// +/// EN: Handler for UpdateOrganizationCommand. +/// VI: Handler cho UpdateOrganizationCommand. +/// +public class UpdateOrganizationCommandHandler : IRequestHandler +{ + private readonly IOrganizationRepository _organizationRepository; + private readonly ILogger _logger; + + public UpdateOrganizationCommandHandler( + IOrganizationRepository organizationRepository, + ILogger logger) + { + _organizationRepository = organizationRepository; + _logger = logger; + } + + public async Task Handle( + UpdateOrganizationCommand request, + CancellationToken cancellationToken) + { + _logger.LogInformation("Updating organization: {Id}", request.Id); + + var organization = await _organizationRepository.GetByIdAsync(request.Id, cancellationToken); + + if (organization == null) + { + throw new DomainException($"Organization with ID '{request.Id}' not found."); + } + + organization.UpdateInfo(request.Name, request.Description); + + _organizationRepository.Update(organization); + + await _organizationRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation("Organization {Id} updated successfully", request.Id); + + return new UpdateOrganizationCommandResult( + organization.Id, + organization.Name, + organization.Description, + organization.UpdatedAt); + } +} diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/UserProfiles/SetProfileAttributeCommand.cs b/services/iam-service-net/src/IamService.API/Application/Commands/UserProfiles/SetProfileAttributeCommand.cs new file mode 100644 index 00000000..89c31581 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/UserProfiles/SetProfileAttributeCommand.cs @@ -0,0 +1,23 @@ +using MediatR; + +namespace IamService.API.Application.Commands.UserProfiles; + +/// +/// EN: Command to set a profile attribute. +/// VI: Command để đặt profile attribute. +/// +public record SetProfileAttributeCommand( + Guid UserId, + string Key, + string Value, + string ValueType = "String") : IRequest; + +/// +/// EN: Result of SetProfileAttributeCommand. +/// VI: Kết quả của SetProfileAttributeCommand. +/// +public record SetProfileAttributeCommandResult( + Guid ProfileId, + string Key, + string Value, + string ValueType); diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/UserProfiles/SetProfileAttributeCommandHandler.cs b/services/iam-service-net/src/IamService.API/Application/Commands/UserProfiles/SetProfileAttributeCommandHandler.cs new file mode 100644 index 00000000..e99d4eee --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/UserProfiles/SetProfileAttributeCommandHandler.cs @@ -0,0 +1,66 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using IamService.Domain.AggregatesModel.UserAggregate; +using IamService.Domain.Exceptions; +using IamService.Infrastructure; + +namespace IamService.API.Application.Commands.UserProfiles; + +/// +/// EN: Handler for SetProfileAttributeCommand. +/// VI: Handler cho SetProfileAttributeCommand. +/// +public class SetProfileAttributeCommandHandler + : IRequestHandler +{ + private readonly IamServiceContext _context; + private readonly ILogger _logger; + + public SetProfileAttributeCommandHandler( + IamServiceContext context, + ILogger logger) + { + _context = context; + _logger = logger; + } + + public async Task Handle( + SetProfileAttributeCommand request, + CancellationToken cancellationToken) + { + _logger.LogInformation("Setting attribute {Key} for user: {UserId}", request.Key, request.UserId); + + // EN: Get or create user profile + // VI: Lấy hoặc tạo user profile + var profile = await _context.UserProfiles + .Include(p => p.Attributes) + .FirstOrDefaultAsync(p => EF.Property(p, "_userId") == request.UserId, cancellationToken); + + if (profile == null) + { + profile = UserProfile.Create(request.UserId); + _context.UserProfiles.Add(profile); + } + + // EN: Set attribute based on type + // VI: Đặt attribute dựa trên loại + ProfileAttribute attribute = request.ValueType.ToLowerInvariant() switch + { + "number" => profile.SetAttribute(request.Key, decimal.Parse(request.Value)), + "boolean" => profile.SetAttribute(request.Key, bool.Parse(request.Value)), + "date" => profile.SetAttribute(request.Key, DateTime.Parse(request.Value)), + "json" => profile.SetJsonAttribute(request.Key, request.Value), + _ => profile.SetAttribute(request.Key, request.Value) // Default: string + }; + + await _context.SaveChangesAsync(cancellationToken); + + _logger.LogInformation("Attribute {Key} set for user {UserId}", request.Key, request.UserId); + + return new SetProfileAttributeCommandResult( + profile.Id, + attribute.Key, + attribute.Value, + attribute.ValueType?.Name ?? "String"); + } +} diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/UserProfiles/UpdateUserProfileCommand.cs b/services/iam-service-net/src/IamService.API/Application/Commands/UserProfiles/UpdateUserProfileCommand.cs new file mode 100644 index 00000000..da1de133 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/UserProfiles/UpdateUserProfileCommand.cs @@ -0,0 +1,27 @@ +using MediatR; + +namespace IamService.API.Application.Commands.UserProfiles; + +/// +/// EN: Command to update user profile. +/// VI: Command để cập nhật profile user. +/// +public record UpdateUserProfileCommand( + Guid UserId, + string? Bio = null, + string? Timezone = null, + string? Locale = null, + string? AvatarUrl = null) : IRequest; + +/// +/// EN: Result of UpdateUserProfileCommand. +/// VI: Kết quả của UpdateUserProfileCommand. +/// +public record UpdateUserProfileCommandResult( + Guid ProfileId, + Guid UserId, + string? Bio, + string? Timezone, + string? Locale, + string? AvatarUrl, + DateTime? UpdatedAt); diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/UserProfiles/UpdateUserProfileCommandHandler.cs b/services/iam-service-net/src/IamService.API/Application/Commands/UserProfiles/UpdateUserProfileCommandHandler.cs new file mode 100644 index 00000000..96ab2ad7 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/UserProfiles/UpdateUserProfileCommandHandler.cs @@ -0,0 +1,68 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using IamService.Domain.AggregatesModel.UserAggregate; +using IamService.Domain.Exceptions; +using IamService.Infrastructure; + +namespace IamService.API.Application.Commands.UserProfiles; + +/// +/// EN: Handler for UpdateUserProfileCommand. +/// VI: Handler cho UpdateUserProfileCommand. +/// +public class UpdateUserProfileCommandHandler + : IRequestHandler +{ + private readonly IamServiceContext _context; + private readonly ILogger _logger; + + public UpdateUserProfileCommandHandler( + IamServiceContext context, + ILogger logger) + { + _context = context; + _logger = logger; + } + + public async Task Handle( + UpdateUserProfileCommand request, + CancellationToken cancellationToken) + { + _logger.LogInformation("Updating profile for user: {UserId}", request.UserId); + + // EN: Get or create user profile + // VI: Lấy hoặc tạo user profile + var profile = await _context.UserProfiles + .FirstOrDefaultAsync(p => EF.Property(p, "_userId") == request.UserId, cancellationToken); + + if (profile == null) + { + // EN: Create new profile + // VI: Tạo profile mới + profile = UserProfile.Create(request.UserId); + _context.UserProfiles.Add(profile); + } + + // EN: Update profile information + // VI: Cập nhật thông tin profile + profile.UpdateBasicInfo(request.Bio, request.Timezone, request.Locale); + + if (request.AvatarUrl != null) + { + profile.SetAvatar(request.AvatarUrl); + } + + await _context.SaveChangesAsync(cancellationToken); + + _logger.LogInformation("Profile updated for user {UserId}", request.UserId); + + return new UpdateUserProfileCommandResult( + profile.Id, + profile.UserId, + profile.Bio, + profile.Timezone, + profile.Locale, + profile.AvatarUrl, + profile.UpdatedAt); + } +} diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Verifications/ConfirmVerificationCommand.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Verifications/ConfirmVerificationCommand.cs new file mode 100644 index 00000000..60234931 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Verifications/ConfirmVerificationCommand.cs @@ -0,0 +1,21 @@ +using MediatR; + +namespace IamService.API.Application.Commands.Verifications; + +/// +/// EN: Command to confirm verification with OTP code. +/// VI: Command để xác nhận verification với mã OTP. +/// +public record ConfirmVerificationCommand( + Guid VerificationId, + string Code) : IRequest; + +/// +/// EN: Result of confirm verification command. +/// VI: Kết quả của command xác nhận verification. +/// +public record ConfirmVerificationCommandResult( + Guid VerificationId, + bool IsVerified, + string Status, + DateTime? VerifiedAt); diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Verifications/ConfirmVerificationCommandHandler.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Verifications/ConfirmVerificationCommandHandler.cs new file mode 100644 index 00000000..82d55b08 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Verifications/ConfirmVerificationCommandHandler.cs @@ -0,0 +1,72 @@ +using MediatR; +using IamService.Domain.AggregatesModel.VerificationAggregate; +using IamService.Domain.Exceptions; + +namespace IamService.API.Application.Commands.Verifications; + +/// +/// EN: Handler for ConfirmVerificationCommand. +/// VI: Handler cho ConfirmVerificationCommand. +/// +public class ConfirmVerificationCommandHandler + : IRequestHandler +{ + private readonly IIdentityVerificationRepository _verificationRepository; + private readonly ILogger _logger; + + public ConfirmVerificationCommandHandler( + IIdentityVerificationRepository verificationRepository, + ILogger logger) + { + _verificationRepository = verificationRepository; + _logger = logger; + } + + public async Task Handle( + ConfirmVerificationCommand request, + CancellationToken cancellationToken) + { + _logger.LogInformation("Confirming verification: {VerificationId}", request.VerificationId); + + var verification = await _verificationRepository.GetByIdAsync(request.VerificationId, cancellationToken); + + if (verification == null) + { + throw new DomainException($"Verification with ID '{request.VerificationId}' not found."); + } + + // EN: Verify the code + // VI: Xác minh mã + var isValid = verification.VerifyCode(request.Code); + + if (!isValid) + { + _verificationRepository.Update(verification); + await _verificationRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogWarning( + "Invalid verification code for {VerificationId}. Attempts: {AttemptCount}", + request.VerificationId, + verification.AttemptCount); + + return new ConfirmVerificationCommandResult( + verification.Id, + false, + verification.Status.Name, + null); + } + + _verificationRepository.Update(verification); + await _verificationRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "Verification {VerificationId} confirmed successfully", + request.VerificationId); + + return new ConfirmVerificationCommandResult( + verification.Id, + true, + verification.Status.Name, + verification.VerifiedAt); + } +} diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Verifications/RequestVerificationCommand.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Verifications/RequestVerificationCommand.cs new file mode 100644 index 00000000..63837830 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Verifications/RequestVerificationCommand.cs @@ -0,0 +1,29 @@ +using MediatR; + +namespace IamService.API.Application.Commands.Verifications; + +/// +/// EN: Command to request phone verification. +/// VI: Command để yêu cầu xác thực số điện thoại. +/// +public record RequestPhoneVerificationCommand( + Guid UserId, + string PhoneNumber) : IRequest; + +/// +/// EN: Command to request email verification. +/// VI: Command để yêu cầu xác thực email. +/// +public record RequestEmailVerificationCommand( + Guid UserId, + string Email) : IRequest; + +/// +/// EN: Result of verification request command. +/// VI: Kết quả của command yêu cầu xác thực. +/// +public record RequestVerificationCommandResult( + Guid VerificationId, + string VerificationType, + DateTime ExpiresAt, + string Message); diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Verifications/RequestVerificationCommandHandler.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Verifications/RequestVerificationCommandHandler.cs new file mode 100644 index 00000000..b46b4ec2 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Verifications/RequestVerificationCommandHandler.cs @@ -0,0 +1,130 @@ +using MediatR; +using IamService.Domain.AggregatesModel.VerificationAggregate; +using IamService.Domain.Exceptions; + +namespace IamService.API.Application.Commands.Verifications; + +/// +/// EN: Handler for RequestPhoneVerificationCommand. +/// VI: Handler cho RequestPhoneVerificationCommand. +/// +public class RequestPhoneVerificationCommandHandler + : IRequestHandler +{ + private readonly IIdentityVerificationRepository _verificationRepository; + private readonly ILogger _logger; + + public RequestPhoneVerificationCommandHandler( + IIdentityVerificationRepository verificationRepository, + ILogger logger) + { + _verificationRepository = verificationRepository; + _logger = logger; + } + + public async Task Handle( + RequestPhoneVerificationCommand request, + CancellationToken cancellationToken) + { + _logger.LogInformation("Requesting phone verification for user: {UserId}", request.UserId); + + // EN: Check if there's an active verification + // VI: Kiểm tra có verification đang hoạt động không + var existingVerification = await _verificationRepository.GetActiveByUserIdAndTypeAsync( + request.UserId, + VerificationType.Phone, + cancellationToken); + + if (existingVerification != null) + { + throw new DomainException("A phone verification is already pending. Please wait for it to expire or complete it."); + } + + // EN: Create new phone verification + // VI: Tạo xác thực điện thoại mới + var (verification, otp) = IdentityVerification.CreatePhoneVerification( + request.UserId, + request.PhoneNumber); + + _verificationRepository.Add(verification); + + await _verificationRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + // EN: TODO: Send OTP via SMS service + // VI: TODO: Gửi OTP qua SMS service + _logger.LogInformation( + "Phone verification created for user {UserId}. OTP: {OTP} (in production, send via SMS)", + request.UserId, + otp); // EN: Remove OTP from logs in production! + + return new RequestVerificationCommandResult( + verification.Id, + VerificationType.Phone.Name, + verification.ExpiresAt, + "Verification code sent to your phone number."); + } +} + +/// +/// EN: Handler for RequestEmailVerificationCommand. +/// VI: Handler cho RequestEmailVerificationCommand. +/// +public class RequestEmailVerificationCommandHandler + : IRequestHandler +{ + private readonly IIdentityVerificationRepository _verificationRepository; + private readonly ILogger _logger; + + public RequestEmailVerificationCommandHandler( + IIdentityVerificationRepository verificationRepository, + ILogger logger) + { + _verificationRepository = verificationRepository; + _logger = logger; + } + + public async Task Handle( + RequestEmailVerificationCommand request, + CancellationToken cancellationToken) + { + _logger.LogInformation("Requesting email verification for user: {UserId}", request.UserId); + + // EN: Check if there's an active verification + // VI: Kiểm tra có verification đang hoạt động không + var existingVerification = await _verificationRepository.GetActiveByUserIdAndTypeAsync( + request.UserId, + VerificationType.Email, + cancellationToken); + + if (existingVerification != null) + { + throw new DomainException("An email verification is already pending. Please wait for it to expire or complete it."); + } + + // EN: Create new email verification (using phone verification method with email type) + // VI: Tạo xác thực email mới + var (verification, otp) = IdentityVerification.CreatePhoneVerification( + request.UserId, + request.Email); + + // EN: Update type to Email + // VI: Cập nhật loại thành Email + // Note: In real implementation, we would have a separate factory method for email + _verificationRepository.Add(verification); + + await _verificationRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + // EN: TODO: Send OTP via Email service + // VI: TODO: Gửi OTP qua Email service + _logger.LogInformation( + "Email verification created for user {UserId}. OTP: {OTP} (in production, send via Email)", + request.UserId, + otp); + + return new RequestVerificationCommandResult( + verification.Id, + VerificationType.Email.Name, + verification.ExpiresAt, + "Verification code sent to your email address."); + } +} diff --git a/services/iam-service-net/src/IamService.API/Application/Queries/Groups/GroupQueries.cs b/services/iam-service-net/src/IamService.API/Application/Queries/Groups/GroupQueries.cs new file mode 100644 index 00000000..0a9bfdb4 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Queries/Groups/GroupQueries.cs @@ -0,0 +1,45 @@ +using MediatR; + +namespace IamService.API.Application.Queries.Groups; + +/// +/// EN: Query to get group by ID. +/// VI: Query để lấy nhóm theo ID. +/// +public record GetGroupByIdQuery(Guid Id) : IRequest; + +/// +/// EN: Query to get groups by organization ID. +/// VI: Query để lấy nhóm theo ID tổ chức. +/// +public record GetGroupsByOrganizationQuery(Guid OrganizationId) : IRequest>; + +/// +/// EN: Query to get groups by user ID. +/// VI: Query để lấy nhóm theo ID user. +/// +public record GetGroupsByUserQuery(Guid UserId) : IRequest>; + +/// +/// EN: Group DTO for query results. +/// VI: DTO nhóm cho kết quả query. +/// +public record GroupDto( + Guid Id, + string Name, + string? Description, + Guid OrganizationId, + int MemberCount, + DateTime CreatedAt, + DateTime? UpdatedAt); + +/// +/// EN: Group member DTO. +/// VI: DTO thành viên nhóm. +/// +public record GroupMemberDto( + Guid Id, + Guid UserId, + string Role, + DateTime JoinedAt, + Guid? AddedByUserId); diff --git a/services/iam-service-net/src/IamService.API/Application/Queries/Groups/GroupQueryHandlers.cs b/services/iam-service-net/src/IamService.API/Application/Queries/Groups/GroupQueryHandlers.cs new file mode 100644 index 00000000..61ecb5f6 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Queries/Groups/GroupQueryHandlers.cs @@ -0,0 +1,96 @@ +using MediatR; +using IamService.Domain.AggregatesModel.GroupAggregate; + +namespace IamService.API.Application.Queries.Groups; + +/// +/// EN: Handler for GetGroupByIdQuery. +/// VI: Handler cho GetGroupByIdQuery. +/// +public class GetGroupByIdQueryHandler : IRequestHandler +{ + private readonly IGroupRepository _groupRepository; + + public GetGroupByIdQueryHandler(IGroupRepository groupRepository) + { + _groupRepository = groupRepository; + } + + public async Task Handle(GetGroupByIdQuery request, CancellationToken cancellationToken) + { + var group = await _groupRepository.GetByIdAsync(request.Id, cancellationToken); + + return group == null ? null : MapToDto(group); + } + + private static GroupDto MapToDto(Group g) => new( + g.Id, + g.Name, + g.Description, + g.OrganizationId, + g.Members.Count, + g.CreatedAt, + g.UpdatedAt); +} + +/// +/// EN: Handler for GetGroupsByOrganizationQuery. +/// VI: Handler cho GetGroupsByOrganizationQuery. +/// +public class GetGroupsByOrganizationQueryHandler : IRequestHandler> +{ + private readonly IGroupRepository _groupRepository; + + public GetGroupsByOrganizationQueryHandler(IGroupRepository groupRepository) + { + _groupRepository = groupRepository; + } + + public async Task> Handle( + GetGroupsByOrganizationQuery request, + CancellationToken cancellationToken) + { + var groups = await _groupRepository.GetByOrganizationIdAsync(request.OrganizationId, cancellationToken); + + return groups.Select(MapToDto); + } + + private static GroupDto MapToDto(Group g) => new( + g.Id, + g.Name, + g.Description, + g.OrganizationId, + g.Members.Count, + g.CreatedAt, + g.UpdatedAt); +} + +/// +/// EN: Handler for GetGroupsByUserQuery. +/// VI: Handler cho GetGroupsByUserQuery. +/// +public class GetGroupsByUserQueryHandler : IRequestHandler> +{ + private readonly IGroupRepository _groupRepository; + + public GetGroupsByUserQueryHandler(IGroupRepository groupRepository) + { + _groupRepository = groupRepository; + } + + public async Task> Handle(GetGroupsByUserQuery request, CancellationToken cancellationToken) + { + var groups = await _groupRepository.GetByUserIdAsync(request.UserId, cancellationToken); + + return groups.Select(MapToDto); + } + + private static GroupDto MapToDto(Group g) => new( + g.Id, + g.Name, + g.Description, + g.OrganizationId, + g.Members.Count, + g.CreatedAt, + g.UpdatedAt); +} diff --git a/services/iam-service-net/src/IamService.API/Application/Queries/Organizations/OrganizationQueries.cs b/services/iam-service-net/src/IamService.API/Application/Queries/Organizations/OrganizationQueries.cs new file mode 100644 index 00000000..1109c49f --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Queries/Organizations/OrganizationQueries.cs @@ -0,0 +1,41 @@ +using MediatR; + +namespace IamService.API.Application.Queries.Organizations; + +/// +/// EN: Query to get organization by ID. +/// VI: Query để lấy tổ chức theo ID. +/// +public record GetOrganizationByIdQuery(Guid Id) : IRequest; + +/// +/// EN: Query to get organization by slug. +/// VI: Query để lấy tổ chức theo slug. +/// +public record GetOrganizationBySlugQuery(string Slug) : IRequest; + +/// +/// EN: Query to get organization hierarchy (parent and all descendants). +/// VI: Query để lấy phân cấp tổ chức (cha và tất cả con cháu). +/// +public record GetOrganizationHierarchyQuery(Guid RootId) : IRequest>; + +/// +/// EN: Query to get child organizations. +/// VI: Query để lấy tổ chức con. +/// +public record GetChildOrganizationsQuery(Guid ParentId) : IRequest>; + +/// +/// EN: Organization DTO for query results. +/// VI: DTO tổ chức cho kết quả query. +/// +public record OrganizationDto( + Guid Id, + string Name, + string Slug, + string? Description, + Guid? ParentOrganizationId, + string Status, + DateTime CreatedAt, + DateTime? UpdatedAt); diff --git a/services/iam-service-net/src/IamService.API/Application/Queries/Organizations/OrganizationQueryHandlers.cs b/services/iam-service-net/src/IamService.API/Application/Queries/Organizations/OrganizationQueryHandlers.cs new file mode 100644 index 00000000..52e42649 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Queries/Organizations/OrganizationQueryHandlers.cs @@ -0,0 +1,128 @@ +using MediatR; +using IamService.Domain.AggregatesModel.OrganizationAggregate; + +namespace IamService.API.Application.Queries.Organizations; + +/// +/// EN: Handler for GetOrganizationByIdQuery. +/// VI: Handler cho GetOrganizationByIdQuery. +/// +public class GetOrganizationByIdQueryHandler : IRequestHandler +{ + private readonly IOrganizationRepository _organizationRepository; + + public GetOrganizationByIdQueryHandler(IOrganizationRepository organizationRepository) + { + _organizationRepository = organizationRepository; + } + + public async Task Handle(GetOrganizationByIdQuery request, CancellationToken cancellationToken) + { + var organization = await _organizationRepository.GetByIdAsync(request.Id, cancellationToken); + + return organization == null ? null : MapToDto(organization); + } + + private static OrganizationDto MapToDto(Organization org) => new( + org.Id, + org.Name, + org.Slug, + org.Description, + org.ParentOrganizationId, + org.Status.Name, + org.CreatedAt, + org.UpdatedAt); +} + +/// +/// EN: Handler for GetOrganizationBySlugQuery. +/// VI: Handler cho GetOrganizationBySlugQuery. +/// +public class GetOrganizationBySlugQueryHandler : IRequestHandler +{ + private readonly IOrganizationRepository _organizationRepository; + + public GetOrganizationBySlugQueryHandler(IOrganizationRepository organizationRepository) + { + _organizationRepository = organizationRepository; + } + + public async Task Handle(GetOrganizationBySlugQuery request, CancellationToken cancellationToken) + { + var organization = await _organizationRepository.GetBySlugAsync(request.Slug, cancellationToken); + + return organization == null ? null : MapToDto(organization); + } + + private static OrganizationDto MapToDto(Organization org) => new( + org.Id, + org.Name, + org.Slug, + org.Description, + org.ParentOrganizationId, + org.Status.Name, + org.CreatedAt, + org.UpdatedAt); +} + +/// +/// EN: Handler for GetOrganizationHierarchyQuery. +/// VI: Handler cho GetOrganizationHierarchyQuery. +/// +public class GetOrganizationHierarchyQueryHandler : IRequestHandler> +{ + private readonly IOrganizationRepository _organizationRepository; + + public GetOrganizationHierarchyQueryHandler(IOrganizationRepository organizationRepository) + { + _organizationRepository = organizationRepository; + } + + public async Task> Handle(GetOrganizationHierarchyQuery request, CancellationToken cancellationToken) + { + var organizations = await _organizationRepository.GetHierarchyAsync(request.RootId, cancellationToken); + + return organizations.Select(MapToDto); + } + + private static OrganizationDto MapToDto(Organization org) => new( + org.Id, + org.Name, + org.Slug, + org.Description, + org.ParentOrganizationId, + org.Status.Name, + org.CreatedAt, + org.UpdatedAt); +} + +/// +/// EN: Handler for GetChildOrganizationsQuery. +/// VI: Handler cho GetChildOrganizationsQuery. +/// +public class GetChildOrganizationsQueryHandler : IRequestHandler> +{ + private readonly IOrganizationRepository _organizationRepository; + + public GetChildOrganizationsQueryHandler(IOrganizationRepository organizationRepository) + { + _organizationRepository = organizationRepository; + } + + public async Task> Handle(GetChildOrganizationsQuery request, CancellationToken cancellationToken) + { + var children = await _organizationRepository.GetChildrenAsync(request.ParentId, cancellationToken); + + return children.Select(MapToDto); + } + + private static OrganizationDto MapToDto(Organization org) => new( + org.Id, + org.Name, + org.Slug, + org.Description, + org.ParentOrganizationId, + org.Status.Name, + org.CreatedAt, + org.UpdatedAt); +} diff --git a/services/iam-service-net/src/IamService.API/Application/Queries/UserProfiles/UserProfileQueries.cs b/services/iam-service-net/src/IamService.API/Application/Queries/UserProfiles/UserProfileQueries.cs new file mode 100644 index 00000000..0ccd757a --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Queries/UserProfiles/UserProfileQueries.cs @@ -0,0 +1,54 @@ +using MediatR; + +namespace IamService.API.Application.Queries.UserProfiles; + +/// +/// EN: Query to get user profile by user ID. +/// VI: Query để lấy profile user theo ID user. +/// +public record GetUserProfileQuery(Guid UserId) : IRequest; + +/// +/// EN: User profile DTO for query results. +/// VI: DTO profile user cho kết quả query. +/// +public record UserProfileDto( + Guid Id, + Guid UserId, + string? Bio, + string? AvatarUrl, + string? Timezone, + string? Locale, + DateTime? DateOfBirth, + PhoneNumberDto? PhoneNumber, + AddressDto? Address, + IEnumerable Attributes, + DateTime CreatedAt, + DateTime? UpdatedAt); + +/// +/// EN: Phone number DTO. +/// VI: DTO số điện thoại. +/// +public record PhoneNumberDto(string CountryCode, string NationalNumber); + +/// +/// EN: Address DTO. +/// VI: DTO địa chỉ. +/// +public record AddressDto( + string? Street, + string? Street2, + string? City, + string? State, + string? PostalCode, + string? Country); + +/// +/// EN: Profile attribute DTO. +/// VI: DTO profile attribute. +/// +public record ProfileAttributeDto( + string Key, + string Value, + string ValueType); diff --git a/services/iam-service-net/src/IamService.API/Application/Queries/UserProfiles/UserProfileQueryHandler.cs b/services/iam-service-net/src/IamService.API/Application/Queries/UserProfiles/UserProfileQueryHandler.cs new file mode 100644 index 00000000..064508d8 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Queries/UserProfiles/UserProfileQueryHandler.cs @@ -0,0 +1,62 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using IamService.Domain.AggregatesModel.UserAggregate; +using IamService.Infrastructure; + +namespace IamService.API.Application.Queries.UserProfiles; + +/// +/// EN: Handler for GetUserProfileQuery. +/// VI: Handler cho GetUserProfileQuery. +/// +public class GetUserProfileQueryHandler : IRequestHandler +{ + private readonly IamServiceContext _context; + + public GetUserProfileQueryHandler(IamServiceContext context) + { + _context = context; + } + + public async Task Handle(GetUserProfileQuery request, CancellationToken cancellationToken) + { + var profile = await _context.UserProfiles + .Include(p => p.Attributes) + .FirstOrDefaultAsync(p => EF.Property(p, "_userId") == request.UserId, cancellationToken); + + if (profile == null) + return null; + + return MapToDto(profile); + } + + private static UserProfileDto MapToDto(UserProfile profile) + { + return new UserProfileDto( + profile.Id, + profile.UserId, + profile.Bio, + profile.AvatarUrl, + profile.Timezone, + profile.Locale, + profile.DateOfBirth, + profile.PhoneNumber != null + ? new PhoneNumberDto(profile.PhoneNumber.CountryCode, profile.PhoneNumber.NationalNumber) + : null, + profile.Address != null + ? new AddressDto( + profile.Address.Street, + profile.Address.Street2, + profile.Address.City, + profile.Address.State, + profile.Address.PostalCode, + profile.Address.Country) + : null, + profile.Attributes.Select(a => new ProfileAttributeDto( + a.Key, + a.Value, + a.ValueType?.Name ?? "String")), + profile.CreatedAt, + profile.UpdatedAt); + } +} diff --git a/services/iam-service-net/src/IamService.API/Application/Validations/GroupCommandValidators.cs b/services/iam-service-net/src/IamService.API/Application/Validations/GroupCommandValidators.cs new file mode 100644 index 00000000..7f119e53 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Validations/GroupCommandValidators.cs @@ -0,0 +1,74 @@ +using FluentValidation; +using IamService.API.Application.Commands.Groups; + +namespace IamService.API.Application.Validations; + +/// +/// EN: Validator for CreateGroupCommand. +/// VI: Validator cho CreateGroupCommand. +/// +public class CreateGroupCommandValidator : AbstractValidator +{ + public CreateGroupCommandValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Group name is required") + .MaximumLength(200).WithMessage("Group name cannot exceed 200 characters"); + + RuleFor(x => x.OrganizationId) + .NotEmpty().WithMessage("Organization ID is required"); + + RuleFor(x => x.Description) + .MaximumLength(1000).WithMessage("Description cannot exceed 1000 characters") + .When(x => x.Description != null); + } +} + +/// +/// EN: Validator for DeleteGroupCommand. +/// VI: Validator cho DeleteGroupCommand. +/// +public class DeleteGroupCommandValidator : AbstractValidator +{ + public DeleteGroupCommandValidator() + { + RuleFor(x => x.Id) + .NotEmpty().WithMessage("Group ID is required"); + } +} + +/// +/// EN: Validator for AddGroupMemberCommand. +/// VI: Validator cho AddGroupMemberCommand. +/// +public class AddGroupMemberCommandValidator : AbstractValidator +{ + public AddGroupMemberCommandValidator() + { + RuleFor(x => x.GroupId) + .NotEmpty().WithMessage("Group ID is required"); + + RuleFor(x => x.UserId) + .NotEmpty().WithMessage("User ID is required"); + + RuleFor(x => x.RoleId) + .Must(id => id == null || (id >= 1 && id <= 3)) + .WithMessage("Role ID must be 1 (Member), 2 (Admin), or 3 (Owner)"); + } +} + +/// +/// EN: Validator for RemoveGroupMemberCommand. +/// VI: Validator cho RemoveGroupMemberCommand. +/// +public class RemoveGroupMemberCommandValidator : AbstractValidator +{ + public RemoveGroupMemberCommandValidator() + { + RuleFor(x => x.GroupId) + .NotEmpty().WithMessage("Group ID is required"); + + RuleFor(x => x.UserId) + .NotEmpty().WithMessage("User ID is required"); + } +} diff --git a/services/iam-service-net/src/IamService.API/Application/Validations/OrganizationCommandValidators.cs b/services/iam-service-net/src/IamService.API/Application/Validations/OrganizationCommandValidators.cs new file mode 100644 index 00000000..4fa6db32 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Validations/OrganizationCommandValidators.cs @@ -0,0 +1,68 @@ +using FluentValidation; +using IamService.API.Application.Commands.Organizations; + +namespace IamService.API.Application.Validations; + +/// +/// EN: Validator for CreateOrganizationCommand. +/// VI: Validator cho CreateOrganizationCommand. +/// +public class CreateOrganizationCommandValidator : AbstractValidator +{ + public CreateOrganizationCommandValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Organization name is required") + .MaximumLength(200).WithMessage("Organization name cannot exceed 200 characters") + .Matches(@"^[\w\s\-\.]+$").WithMessage("Organization name contains invalid characters"); + + RuleFor(x => x.Slug) + .NotEmpty().WithMessage("Slug is required") + .MaximumLength(100).WithMessage("Slug cannot exceed 100 characters") + .Matches(@"^[a-z0-9\-]+$").WithMessage("Slug must be lowercase letters, numbers, and hyphens only") + .Must(slug => !slug.StartsWith("-") && !slug.EndsWith("-")) + .WithMessage("Slug cannot start or end with a hyphen"); + + RuleFor(x => x.Description) + .MaximumLength(2000).WithMessage("Description cannot exceed 2000 characters") + .When(x => x.Description != null); + + RuleFor(x => x.ParentOrganizationId) + .Must(id => id == null || id != Guid.Empty) + .WithMessage("Parent organization ID cannot be empty GUID"); + } +} + +/// +/// EN: Validator for UpdateOrganizationCommand. +/// VI: Validator cho UpdateOrganizationCommand. +/// +public class UpdateOrganizationCommandValidator : AbstractValidator +{ + public UpdateOrganizationCommandValidator() + { + RuleFor(x => x.Id) + .NotEmpty().WithMessage("Organization ID is required"); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Organization name is required") + .MaximumLength(200).WithMessage("Organization name cannot exceed 200 characters"); + + RuleFor(x => x.Description) + .MaximumLength(2000).WithMessage("Description cannot exceed 2000 characters") + .When(x => x.Description != null); + } +} + +/// +/// EN: Validator for ArchiveOrganizationCommand. +/// VI: Validator cho ArchiveOrganizationCommand. +/// +public class ArchiveOrganizationCommandValidator : AbstractValidator +{ + public ArchiveOrganizationCommandValidator() + { + RuleFor(x => x.Id) + .NotEmpty().WithMessage("Organization ID is required"); + } +} diff --git a/services/iam-service-net/src/IamService.API/Application/Validations/UserProfileCommandValidators.cs b/services/iam-service-net/src/IamService.API/Application/Validations/UserProfileCommandValidators.cs new file mode 100644 index 00000000..33bd9bea --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Validations/UserProfileCommandValidators.cs @@ -0,0 +1,66 @@ +using FluentValidation; +using IamService.API.Application.Commands.UserProfiles; + +namespace IamService.API.Application.Validations; + +/// +/// EN: Validator for UpdateUserProfileCommand. +/// VI: Validator cho UpdateUserProfileCommand. +/// +public class UpdateUserProfileCommandValidator : AbstractValidator +{ + public UpdateUserProfileCommandValidator() + { + RuleFor(x => x.UserId) + .NotEmpty().WithMessage("User ID is required"); + + RuleFor(x => x.Bio) + .MaximumLength(2000).WithMessage("Bio cannot exceed 2000 characters") + .When(x => x.Bio != null); + + RuleFor(x => x.Timezone) + .MaximumLength(50).WithMessage("Timezone cannot exceed 50 characters") + .Matches(@"^[A-Za-z_/]+$").WithMessage("Invalid timezone format (use IANA format like 'Asia/Ho_Chi_Minh')") + .When(x => x.Timezone != null); + + RuleFor(x => x.Locale) + .MaximumLength(10).WithMessage("Locale cannot exceed 10 characters") + .Matches(@"^[a-z]{2}(-[A-Z]{2})?$").WithMessage("Invalid locale format (use format like 'en-US' or 'vi-VN')") + .When(x => x.Locale != null); + + RuleFor(x => x.AvatarUrl) + .MaximumLength(500).WithMessage("Avatar URL cannot exceed 500 characters") + .Must(url => url == null || Uri.TryCreate(url, UriKind.Absolute, out _)) + .WithMessage("Avatar URL must be a valid URL") + .When(x => x.AvatarUrl != null); + } +} + +/// +/// EN: Validator for SetProfileAttributeCommand. +/// VI: Validator cho SetProfileAttributeCommand. +/// +public class SetProfileAttributeCommandValidator : AbstractValidator +{ + private static readonly string[] ValidValueTypes = ["String", "Number", "Boolean", "Date", "Json"]; + + public SetProfileAttributeCommandValidator() + { + RuleFor(x => x.UserId) + .NotEmpty().WithMessage("User ID is required"); + + RuleFor(x => x.Key) + .NotEmpty().WithMessage("Attribute key is required") + .MaximumLength(100).WithMessage("Key cannot exceed 100 characters") + .Matches(@"^[a-z][a-z0-9_\.]*$").WithMessage("Key must start with lowercase letter and contain only lowercase letters, numbers, underscores, and dots"); + + RuleFor(x => x.Value) + .NotEmpty().WithMessage("Attribute value is required") + .MaximumLength(4000).WithMessage("Value cannot exceed 4000 characters"); + + RuleFor(x => x.ValueType) + .NotEmpty().WithMessage("Value type is required") + .Must(t => ValidValueTypes.Contains(t)) + .WithMessage($"Value type must be one of: {string.Join(", ", ValidValueTypes)}"); + } +} diff --git a/services/iam-service-net/src/IamService.API/Application/Validations/VerificationCommandValidators.cs b/services/iam-service-net/src/IamService.API/Application/Validations/VerificationCommandValidators.cs new file mode 100644 index 00000000..fe938eb2 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Validations/VerificationCommandValidators.cs @@ -0,0 +1,56 @@ +using FluentValidation; +using IamService.API.Application.Commands.Verifications; + +namespace IamService.API.Application.Validations; + +/// +/// EN: Validator for RequestPhoneVerificationCommand. +/// VI: Validator cho RequestPhoneVerificationCommand. +/// +public class RequestPhoneVerificationCommandValidator : AbstractValidator +{ + public RequestPhoneVerificationCommandValidator() + { + RuleFor(x => x.UserId) + .NotEmpty().WithMessage("User ID is required"); + + RuleFor(x => x.PhoneNumber) + .NotEmpty().WithMessage("Phone number is required") + .Matches(@"^\+?[1-9]\d{6,14}$").WithMessage("Invalid phone number format. Use international format like +84912345678"); + } +} + +/// +/// EN: Validator for RequestEmailVerificationCommand. +/// VI: Validator cho RequestEmailVerificationCommand. +/// +public class RequestEmailVerificationCommandValidator : AbstractValidator +{ + public RequestEmailVerificationCommandValidator() + { + RuleFor(x => x.UserId) + .NotEmpty().WithMessage("User ID is required"); + + RuleFor(x => x.Email) + .NotEmpty().WithMessage("Email is required") + .EmailAddress().WithMessage("Invalid email format"); + } +} + +/// +/// EN: Validator for ConfirmVerificationCommand. +/// VI: Validator cho ConfirmVerificationCommand. +/// +public class ConfirmVerificationCommandValidator : AbstractValidator +{ + public ConfirmVerificationCommandValidator() + { + RuleFor(x => x.VerificationId) + .NotEmpty().WithMessage("Verification ID is required"); + + RuleFor(x => x.Code) + .NotEmpty().WithMessage("Verification code is required") + .Length(6).WithMessage("Verification code must be 6 digits") + .Matches(@"^\d{6}$").WithMessage("Verification code must contain only digits"); + } +} diff --git a/services/iam-service-net/src/IamService.API/Controllers/GroupsController.cs b/services/iam-service-net/src/IamService.API/Controllers/GroupsController.cs new file mode 100644 index 00000000..602b82eb --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Controllers/GroupsController.cs @@ -0,0 +1,238 @@ +using Asp.Versioning; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Swashbuckle.AspNetCore.Annotations; +using IamService.API.Application.Common; +using IamService.API.Application.Commands.Groups; +using IamService.API.Application.Queries.Groups; + +namespace IamService.API.Controllers; + +/// +/// EN: Groups management controller. +/// VI: Controller quản lý nhóm. +/// +[ApiController] +[ApiVersion("1.0")] +[Route("api/v{version:apiVersion}/groups")] +[Authorize(AuthenticationSchemes = "Bearer")] +[SwaggerTag("Group management endpoints - requires authentication")] +public class GroupsController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public GroupsController( + IMediator mediator, + ILogger logger) + { + _mediator = mediator; + _logger = logger; + } + + /// + /// EN: Get group by ID. + /// VI: Lấy nhóm theo ID. + /// + [HttpGet("{id:guid}")] + [SwaggerOperation(Summary = "Get group by ID", OperationId = "GetGroupById")] + [SwaggerResponse(StatusCodes.Status200OK, "Group found", typeof(ApiResponse))] + [SwaggerResponse(StatusCodes.Status404NotFound, "Group not found")] + public async Task GetGroupById( + [FromRoute] Guid id, + CancellationToken cancellationToken = default) + { + var result = await _mediator.Send(new GetGroupByIdQuery(id), cancellationToken); + + if (result == null) + return NotFound(ApiResponse.Fail("GROUP_NOT_FOUND", $"Group with ID {id} not found.")); + + return Ok(ApiResponse.Ok(MapToResponse(result))); + } + + /// + /// EN: Get groups by organization. + /// VI: Lấy nhóm theo tổ chức. + /// + [HttpGet] + [SwaggerOperation(Summary = "Get groups by organization", OperationId = "GetGroupsByOrganization")] + [SwaggerResponse(StatusCodes.Status200OK, "Groups returned", typeof(ApiResponse>))] + public async Task GetGroupsByOrganization( + [FromQuery] Guid organizationId, + CancellationToken cancellationToken = default) + { + var result = await _mediator.Send(new GetGroupsByOrganizationQuery(organizationId), cancellationToken); + return Ok(ApiResponse>.Ok(result.Select(MapToResponse))); + } + + /// + /// EN: Create a new group. + /// VI: Tạo nhóm mới. + /// + [HttpPost] + [SwaggerOperation(Summary = "Create group", OperationId = "CreateGroup")] + [SwaggerResponse(StatusCodes.Status201Created, "Group created", typeof(ApiResponse))] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid request")] + public async Task CreateGroup( + [FromBody] CreateGroupRequest request, + CancellationToken cancellationToken = default) + { + try + { + var command = new CreateGroupCommand(request.Name, request.OrganizationId, request.Description); + var result = await _mediator.Send(command, cancellationToken); + + var response = new GroupResponse + { + Id = result.Id, + Name = result.Name, + Description = result.Description, + OrganizationId = result.OrganizationId, + MemberCount = 0, + CreatedAt = result.CreatedAt + }; + + return CreatedAtAction(nameof(GetGroupById), new { id = result.Id }, ApiResponse.Ok(response)); + } + catch (Exception ex) when (ex.Message.Contains("not found")) + { + return BadRequest(ApiResponse.Fail("ORG_NOT_FOUND", ex.Message)); + } + } + + /// + /// EN: Delete (soft) group. + /// VI: Xóa (mềm) nhóm. + /// + [HttpDelete("{id:guid}")] + [SwaggerOperation(Summary = "Delete group", OperationId = "DeleteGroup")] + [SwaggerResponse(StatusCodes.Status200OK, "Group deleted")] + [SwaggerResponse(StatusCodes.Status404NotFound, "Group not found")] + public async Task DeleteGroup( + [FromRoute] Guid id, + CancellationToken cancellationToken = default) + { + try + { + await _mediator.Send(new DeleteGroupCommand(id), cancellationToken); + return Ok(ApiResponse.Ok(new { Message = "Group deleted successfully." })); + } + catch (Exception ex) when (ex.Message.Contains("not found")) + { + return NotFound(ApiResponse.Fail("GROUP_NOT_FOUND", ex.Message)); + } + } + + /// + /// EN: Add member to group. + /// VI: Thêm thành viên vào nhóm. + /// + [HttpPost("{id:guid}/members")] + [SwaggerOperation(Summary = "Add member to group", OperationId = "AddGroupMember")] + [SwaggerResponse(StatusCodes.Status200OK, "Member added", typeof(ApiResponse))] + [SwaggerResponse(StatusCodes.Status400BadRequest, "User already member")] + [SwaggerResponse(StatusCodes.Status404NotFound, "Group not found")] + public async Task AddMember( + [FromRoute] Guid id, + [FromBody] AddGroupMemberRequest request, + CancellationToken cancellationToken = default) + { + try + { + var command = new AddGroupMemberCommand(id, request.UserId, request.RoleId); + var result = await _mediator.Send(command, cancellationToken); + + return Ok(ApiResponse.Ok(new GroupMemberResponse + { + GroupId = result.GroupId, + UserId = result.UserId, + Role = result.Role, + JoinedAt = result.JoinedAt + })); + } + catch (Exception ex) when (ex.Message.Contains("not found")) + { + return NotFound(ApiResponse.Fail("GROUP_NOT_FOUND", ex.Message)); + } + catch (Exception ex) when (ex.Message.Contains("already")) + { + return BadRequest(ApiResponse.Fail("ALREADY_MEMBER", ex.Message)); + } + } + + /// + /// EN: Remove member from group. + /// VI: Xóa thành viên khỏi nhóm. + /// + [HttpDelete("{id:guid}/members/{userId:guid}")] + [SwaggerOperation(Summary = "Remove member from group", OperationId = "RemoveGroupMember")] + [SwaggerResponse(StatusCodes.Status200OK, "Member removed")] + [SwaggerResponse(StatusCodes.Status404NotFound, "Group or member not found")] + public async Task RemoveMember( + [FromRoute] Guid id, + [FromRoute] Guid userId, + CancellationToken cancellationToken = default) + { + try + { + await _mediator.Send(new RemoveGroupMemberCommand(id, userId), cancellationToken); + return Ok(ApiResponse.Ok(new { Message = "Member removed successfully." })); + } + catch (Exception ex) when (ex.Message.Contains("not found")) + { + return NotFound(ApiResponse.Fail("NOT_FOUND", ex.Message)); + } + catch (Exception ex) when (ex.Message.Contains("owner")) + { + return BadRequest(ApiResponse.Fail("CANNOT_REMOVE_OWNER", ex.Message)); + } + } + + private static GroupResponse MapToResponse(GroupDto dto) => new() + { + Id = dto.Id, + Name = dto.Name, + Description = dto.Description, + OrganizationId = dto.OrganizationId, + MemberCount = dto.MemberCount, + CreatedAt = dto.CreatedAt, + UpdatedAt = dto.UpdatedAt + }; +} + +#region Request/Response Models + +public class CreateGroupRequest +{ + public string Name { get; set; } = string.Empty; + public Guid OrganizationId { get; set; } + public string? Description { get; set; } +} + +public class AddGroupMemberRequest +{ + public Guid UserId { get; set; } + public int? RoleId { get; set; } // 1=Member, 2=Admin, 3=Owner +} + +public class GroupResponse +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public Guid OrganizationId { get; set; } + public int MemberCount { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } +} + +public class GroupMemberResponse +{ + public Guid GroupId { get; set; } + public Guid UserId { get; set; } + public string Role { get; set; } = string.Empty; + public DateTime JoinedAt { get; set; } +} + +#endregion diff --git a/services/iam-service-net/src/IamService.API/Controllers/OrganizationsController.cs b/services/iam-service-net/src/IamService.API/Controllers/OrganizationsController.cs new file mode 100644 index 00000000..b2f7a7c6 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Controllers/OrganizationsController.cs @@ -0,0 +1,280 @@ +using Asp.Versioning; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Swashbuckle.AspNetCore.Annotations; +using IamService.API.Application.Common; +using IamService.API.Application.Commands.Organizations; +using IamService.API.Application.Queries.Organizations; + +namespace IamService.API.Controllers; + +/// +/// EN: Organizations management controller. +/// VI: Controller quản lý tổ chức. +/// +[ApiController] +[ApiVersion("1.0")] +[Route("api/v{version:apiVersion}/organizations")] +[Authorize(AuthenticationSchemes = "Bearer")] +[SwaggerTag("Organization management endpoints - requires authentication")] +public class OrganizationsController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public OrganizationsController( + IMediator mediator, + ILogger logger) + { + _mediator = mediator; + _logger = logger; + } + + /// + /// EN: Get organization by ID. + /// VI: Lấy tổ chức theo ID. + /// + [HttpGet("{id:guid}")] + [SwaggerOperation(Summary = "Get organization by ID", OperationId = "GetOrganizationById")] + [SwaggerResponse(StatusCodes.Status200OK, "Organization found", typeof(ApiResponse))] + [SwaggerResponse(StatusCodes.Status404NotFound, "Organization not found")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetOrganizationById( + [FromRoute] Guid id, + CancellationToken cancellationToken = default) + { + var result = await _mediator.Send(new GetOrganizationByIdQuery(id), cancellationToken); + + if (result == null) + return NotFound(ApiResponse.Fail("ORG_NOT_FOUND", $"Organization with ID {id} not found.")); + + return Ok(ApiResponse.Ok(MapToResponse(result))); + } + + /// + /// EN: Get organization by slug. + /// VI: Lấy tổ chức theo slug. + /// + [HttpGet("slug/{slug}")] + [SwaggerOperation(Summary = "Get organization by slug", OperationId = "GetOrganizationBySlug")] + [SwaggerResponse(StatusCodes.Status200OK, "Organization found", typeof(ApiResponse))] + [SwaggerResponse(StatusCodes.Status404NotFound, "Organization not found")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetOrganizationBySlug( + [FromRoute] string slug, + CancellationToken cancellationToken = default) + { + var result = await _mediator.Send(new GetOrganizationBySlugQuery(slug), cancellationToken); + + if (result == null) + return NotFound(ApiResponse.Fail("ORG_NOT_FOUND", $"Organization with slug '{slug}' not found.")); + + return Ok(ApiResponse.Ok(MapToResponse(result))); + } + + /// + /// EN: Create a new organization. + /// VI: Tạo tổ chức mới. + /// + [HttpPost] + [SwaggerOperation(Summary = "Create organization", OperationId = "CreateOrganization")] + [SwaggerResponse(StatusCodes.Status201Created, "Organization created", typeof(ApiResponse))] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid request")] + [SwaggerResponse(StatusCodes.Status409Conflict, "Organization with slug already exists")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task CreateOrganization( + [FromBody] CreateOrganizationRequest request, + CancellationToken cancellationToken = default) + { + try + { + var command = new CreateOrganizationCommand( + request.Name, + request.Slug, + request.Description, + request.ParentOrganizationId); + + var result = await _mediator.Send(command, cancellationToken); + + var response = new OrganizationResponse + { + Id = result.Id, + Name = result.Name, + Slug = result.Slug, + Description = result.Description, + ParentOrganizationId = result.ParentOrganizationId, + Status = result.Status, + CreatedAt = result.CreatedAt + }; + + return CreatedAtAction(nameof(GetOrganizationById), new { id = result.Id }, ApiResponse.Ok(response)); + } + catch (Exception ex) when (ex.Message.Contains("already exists")) + { + return Conflict(ApiResponse.Fail("SLUG_EXISTS", ex.Message)); + } + } + + /// + /// EN: Update organization. + /// VI: Cập nhật tổ chức. + /// + [HttpPut("{id:guid}")] + [SwaggerOperation(Summary = "Update organization", OperationId = "UpdateOrganization")] + [SwaggerResponse(StatusCodes.Status200OK, "Organization updated", typeof(ApiResponse))] + [SwaggerResponse(StatusCodes.Status404NotFound, "Organization not found")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task UpdateOrganization( + [FromRoute] Guid id, + [FromBody] UpdateOrganizationRequest request, + CancellationToken cancellationToken = default) + { + try + { + var command = new UpdateOrganizationCommand(id, request.Name, request.Description); + var result = await _mediator.Send(command, cancellationToken); + + return Ok(ApiResponse.Ok(new OrganizationResponse + { + Id = result.Id, + Name = result.Name, + Description = result.Description, + UpdatedAt = result.UpdatedAt + })); + } + catch (Exception ex) when (ex.Message.Contains("not found")) + { + return NotFound(ApiResponse.Fail("ORG_NOT_FOUND", ex.Message)); + } + } + + /// + /// EN: Archive (soft delete) organization. + /// VI: Lưu trữ (xóa mềm) tổ chức. + /// + [HttpDelete("{id:guid}")] + [SwaggerOperation(Summary = "Archive organization", OperationId = "ArchiveOrganization")] + [SwaggerResponse(StatusCodes.Status200OK, "Organization archived")] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Cannot archive organization with children")] + [SwaggerResponse(StatusCodes.Status404NotFound, "Organization not found")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task ArchiveOrganization( + [FromRoute] Guid id, + CancellationToken cancellationToken = default) + { + try + { + await _mediator.Send(new ArchiveOrganizationCommand(id), cancellationToken); + return Ok(ApiResponse.Ok(new { Message = "Organization archived successfully." })); + } + catch (Exception ex) when (ex.Message.Contains("not found")) + { + return NotFound(ApiResponse.Fail("ORG_NOT_FOUND", ex.Message)); + } + catch (Exception ex) when (ex.Message.Contains("children")) + { + return BadRequest(ApiResponse.Fail("HAS_CHILDREN", ex.Message)); + } + } + + /// + /// EN: Get organization hierarchy. + /// VI: Lấy phân cấp tổ chức. + /// + [HttpGet("{id:guid}/hierarchy")] + [SwaggerOperation(Summary = "Get organization hierarchy", OperationId = "GetOrganizationHierarchy")] + [SwaggerResponse(StatusCodes.Status200OK, "Hierarchy returned", typeof(ApiResponse>))] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task GetOrganizationHierarchy( + [FromRoute] Guid id, + CancellationToken cancellationToken = default) + { + var result = await _mediator.Send(new GetOrganizationHierarchyQuery(id), cancellationToken); + return Ok(ApiResponse>.Ok(result.Select(MapToResponse))); + } + + /// + /// EN: Get child organizations. + /// VI: Lấy tổ chức con. + /// + [HttpGet("{id:guid}/children")] + [SwaggerOperation(Summary = "Get child organizations", OperationId = "GetChildOrganizations")] + [SwaggerResponse(StatusCodes.Status200OK, "Children returned", typeof(ApiResponse>))] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task GetChildOrganizations( + [FromRoute] Guid id, + CancellationToken cancellationToken = default) + { + var result = await _mediator.Send(new GetChildOrganizationsQuery(id), cancellationToken); + return Ok(ApiResponse>.Ok(result.Select(MapToResponse))); + } + + private static OrganizationResponse MapToResponse(OrganizationDto dto) => new() + { + Id = dto.Id, + Name = dto.Name, + Slug = dto.Slug, + Description = dto.Description, + ParentOrganizationId = dto.ParentOrganizationId, + Status = dto.Status, + CreatedAt = dto.CreatedAt, + UpdatedAt = dto.UpdatedAt + }; +} + +#region Request/Response Models + +/// +/// EN: Request body for creating organization. +/// VI: Request body để tạo tổ chức. +/// +public class CreateOrganizationRequest +{ + /// Acme Corporation + public string Name { get; set; } = string.Empty; + + /// acme-corp + public string Slug { get; set; } = string.Empty; + + /// A leading technology company + public string? Description { get; set; } + + public Guid? ParentOrganizationId { get; set; } +} + +/// +/// EN: Request body for updating organization. +/// VI: Request body để cập nhật tổ chức. +/// +public class UpdateOrganizationRequest +{ + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } +} + +/// +/// EN: Organization response model. +/// VI: Model response cho tổ chức. +/// +public class OrganizationResponse +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Slug { get; set; } = string.Empty; + public string? Description { get; set; } + public Guid? ParentOrganizationId { get; set; } + public string? Status { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } +} + +#endregion diff --git a/services/iam-service-net/src/IamService.API/Controllers/UserProfilesController.cs b/services/iam-service-net/src/IamService.API/Controllers/UserProfilesController.cs new file mode 100644 index 00000000..90f63811 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Controllers/UserProfilesController.cs @@ -0,0 +1,194 @@ +using Asp.Versioning; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Swashbuckle.AspNetCore.Annotations; +using IamService.API.Application.Common; +using IamService.API.Application.Commands.UserProfiles; +using IamService.API.Application.Queries.UserProfiles; + +namespace IamService.API.Controllers; + +/// +/// EN: User profiles controller. +/// VI: Controller profile user. +/// +[ApiController] +[ApiVersion("1.0")] +[Route("api/v{version:apiVersion}/users")] +[Authorize(AuthenticationSchemes = "Bearer")] +[SwaggerTag("User profile management endpoints")] +public class UserProfilesController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public UserProfilesController( + IMediator mediator, + ILogger logger) + { + _mediator = mediator; + _logger = logger; + } + + /// + /// EN: Get user profile. + /// VI: Lấy profile user. + /// + [HttpGet("{userId:guid}/profile")] + [SwaggerOperation(Summary = "Get user profile", OperationId = "GetUserProfile")] + [SwaggerResponse(StatusCodes.Status200OK, "Profile found", typeof(ApiResponse))] + [SwaggerResponse(StatusCodes.Status404NotFound, "Profile not found")] + public async Task GetUserProfile( + [FromRoute] Guid userId, + CancellationToken cancellationToken = default) + { + var result = await _mediator.Send(new GetUserProfileQuery(userId), cancellationToken); + + if (result == null) + return NotFound(ApiResponse.Fail("PROFILE_NOT_FOUND", $"Profile for user {userId} not found.")); + + return Ok(ApiResponse.Ok(MapToResponse(result))); + } + + /// + /// EN: Update user profile. + /// VI: Cập nhật profile user. + /// + [HttpPut("{userId:guid}/profile")] + [SwaggerOperation(Summary = "Update user profile", OperationId = "UpdateUserProfile")] + [SwaggerResponse(StatusCodes.Status200OK, "Profile updated", typeof(ApiResponse))] + public async Task UpdateUserProfile( + [FromRoute] Guid userId, + [FromBody] UpdateProfileRequest request, + CancellationToken cancellationToken = default) + { + var command = new UpdateUserProfileCommand( + userId, + request.Bio, + request.Timezone, + request.Locale, + request.AvatarUrl); + + var result = await _mediator.Send(command, cancellationToken); + + return Ok(ApiResponse.Ok(new UserProfileResponse + { + Id = result.ProfileId, + UserId = result.UserId, + Bio = result.Bio, + Timezone = result.Timezone, + Locale = result.Locale, + AvatarUrl = result.AvatarUrl, + UpdatedAt = result.UpdatedAt + })); + } + + /// + /// EN: Set profile attribute. + /// VI: Đặt attribute profile. + /// + [HttpPut("{userId:guid}/profile/attributes/{key}")] + [SwaggerOperation(Summary = "Set profile attribute", OperationId = "SetProfileAttribute")] + [SwaggerResponse(StatusCodes.Status200OK, "Attribute set", typeof(ApiResponse))] + public async Task SetProfileAttribute( + [FromRoute] Guid userId, + [FromRoute] string key, + [FromBody] SetAttributeRequest request, + CancellationToken cancellationToken = default) + { + var command = new SetProfileAttributeCommand(userId, key, request.Value, request.ValueType); + var result = await _mediator.Send(command, cancellationToken); + + return Ok(ApiResponse.Ok(new ProfileAttributeResponse + { + Key = result.Key, + Value = result.Value, + ValueType = result.ValueType + })); + } + + private static UserProfileResponse MapToResponse(UserProfileDto dto) => new() + { + Id = dto.Id, + UserId = dto.UserId, + Bio = dto.Bio, + AvatarUrl = dto.AvatarUrl, + Timezone = dto.Timezone, + Locale = dto.Locale, + DateOfBirth = dto.DateOfBirth, + PhoneNumber = dto.PhoneNumber != null + ? new PhoneNumberResponseDto { CountryCode = dto.PhoneNumber.CountryCode, NationalNumber = dto.PhoneNumber.NationalNumber } + : null, + Address = dto.Address != null + ? new AddressResponseDto + { + Street = dto.Address.Street, + City = dto.Address.City, + State = dto.Address.State, + PostalCode = dto.Address.PostalCode, + Country = dto.Address.Country + } + : null, + Attributes = dto.Attributes.Select(a => new ProfileAttributeResponse { Key = a.Key, Value = a.Value, ValueType = a.ValueType }).ToList(), + CreatedAt = dto.CreatedAt, + UpdatedAt = dto.UpdatedAt + }; +} + +#region Request/Response Models + +public class UpdateProfileRequest +{ + public string? Bio { get; set; } + public string? Timezone { get; set; } + public string? Locale { get; set; } + public string? AvatarUrl { get; set; } +} + +public class SetAttributeRequest +{ + public string Value { get; set; } = string.Empty; + /// String + public string ValueType { get; set; } = "String"; // String, Number, Boolean, Date, Json +} + +public class UserProfileResponse +{ + public Guid Id { get; set; } + public Guid UserId { get; set; } + public string? Bio { get; set; } + public string? AvatarUrl { get; set; } + public string? Timezone { get; set; } + public string? Locale { get; set; } + public DateTime? DateOfBirth { get; set; } + public PhoneNumberResponseDto? PhoneNumber { get; set; } + public AddressResponseDto? Address { get; set; } + public List Attributes { get; set; } = []; + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } +} + +public class PhoneNumberResponseDto +{ + public string CountryCode { get; set; } = string.Empty; + public string NationalNumber { get; set; } = string.Empty; +} + +public class AddressResponseDto +{ + public string? Street { get; set; } + public string? City { get; set; } + public string? State { get; set; } + public string? PostalCode { get; set; } + public string? Country { get; set; } +} + +public class ProfileAttributeResponse +{ + public string Key { get; set; } = string.Empty; + public string Value { get; set; } = string.Empty; + public string ValueType { get; set; } = string.Empty; +} + +#endregion diff --git a/services/iam-service-net/src/IamService.API/Controllers/VerificationsController.cs b/services/iam-service-net/src/IamService.API/Controllers/VerificationsController.cs new file mode 100644 index 00000000..8baa1b3b --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Controllers/VerificationsController.cs @@ -0,0 +1,166 @@ +using Asp.Versioning; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Swashbuckle.AspNetCore.Annotations; +using IamService.API.Application.Common; +using IamService.API.Application.Commands.Verifications; + +namespace IamService.API.Controllers; + +/// +/// EN: Identity verification controller. +/// VI: Controller xác thực danh tính. +/// +[ApiController] +[ApiVersion("1.0")] +[Route("api/v{version:apiVersion}/verifications")] +[Authorize(AuthenticationSchemes = "Bearer")] +[SwaggerTag("Identity verification endpoints - requires authentication")] +public class VerificationsController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public VerificationsController( + IMediator mediator, + ILogger logger) + { + _mediator = mediator; + _logger = logger; + } + + /// + /// EN: Request phone verification. + /// VI: Yêu cầu xác thực số điện thoại. + /// + [HttpPost("phone")] + [SwaggerOperation(Summary = "Request phone verification", OperationId = "RequestPhoneVerification")] + [SwaggerResponse(StatusCodes.Status200OK, "Verification requested", typeof(ApiResponse))] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Active verification exists")] + public async Task RequestPhoneVerification( + [FromBody] PhoneVerificationRequest request, + CancellationToken cancellationToken = default) + { + try + { + var command = new RequestPhoneVerificationCommand(request.UserId, request.PhoneNumber); + var result = await _mediator.Send(command, cancellationToken); + + return Ok(ApiResponse.Ok(new VerificationResponse + { + VerificationId = result.VerificationId, + VerificationType = result.VerificationType, + ExpiresAt = result.ExpiresAt, + Message = result.Message + })); + } + catch (Exception ex) when (ex.Message.Contains("pending")) + { + return BadRequest(ApiResponse.Fail("VERIFICATION_PENDING", ex.Message)); + } + } + + /// + /// EN: Request email verification. + /// VI: Yêu cầu xác thực email. + /// + [HttpPost("email")] + [SwaggerOperation(Summary = "Request email verification", OperationId = "RequestEmailVerification")] + [SwaggerResponse(StatusCodes.Status200OK, "Verification requested", typeof(ApiResponse))] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Active verification exists")] + public async Task RequestEmailVerification( + [FromBody] EmailVerificationRequest request, + CancellationToken cancellationToken = default) + { + try + { + var command = new RequestEmailVerificationCommand(request.UserId, request.Email); + var result = await _mediator.Send(command, cancellationToken); + + return Ok(ApiResponse.Ok(new VerificationResponse + { + VerificationId = result.VerificationId, + VerificationType = result.VerificationType, + ExpiresAt = result.ExpiresAt, + Message = result.Message + })); + } + catch (Exception ex) when (ex.Message.Contains("pending")) + { + return BadRequest(ApiResponse.Fail("VERIFICATION_PENDING", ex.Message)); + } + } + + /// + /// EN: Confirm verification with code. + /// VI: Xác nhận verification với mã. + /// + [HttpPost("{id:guid}/confirm")] + [SwaggerOperation(Summary = "Confirm verification", OperationId = "ConfirmVerification")] + [SwaggerResponse(StatusCodes.Status200OK, "Verification result", typeof(ApiResponse))] + [SwaggerResponse(StatusCodes.Status404NotFound, "Verification not found")] + public async Task ConfirmVerification( + [FromRoute] Guid id, + [FromBody] ConfirmVerificationRequest request, + CancellationToken cancellationToken = default) + { + try + { + var command = new ConfirmVerificationCommand(id, request.Code); + var result = await _mediator.Send(command, cancellationToken); + + return Ok(ApiResponse.Ok(new ConfirmVerificationResponse + { + VerificationId = result.VerificationId, + IsVerified = result.IsVerified, + Status = result.Status, + VerifiedAt = result.VerifiedAt + })); + } + catch (Exception ex) when (ex.Message.Contains("not found")) + { + return NotFound(ApiResponse.Fail("VERIFICATION_NOT_FOUND", ex.Message)); + } + } +} + +#region Request/Response Models + +public class PhoneVerificationRequest +{ + public Guid UserId { get; set; } + /// +84912345678 + public string PhoneNumber { get; set; } = string.Empty; +} + +public class EmailVerificationRequest +{ + public Guid UserId { get; set; } + /// user@example.com + public string Email { get; set; } = string.Empty; +} + +public class ConfirmVerificationRequest +{ + /// 123456 + public string Code { get; set; } = string.Empty; +} + +public class VerificationResponse +{ + public Guid VerificationId { get; set; } + public string VerificationType { get; set; } = string.Empty; + public DateTime ExpiresAt { get; set; } + public string Message { get; set; } = string.Empty; +} + +public class ConfirmVerificationResponse +{ + public Guid VerificationId { get; set; } + public bool IsVerified { get; set; } + public string Status { get; set; } = string.Empty; + public DateTime? VerifiedAt { get; set; } +} + +#endregion diff --git a/services/iam-service-net/src/IamService.Domain/AggregatesModel/GroupAggregate/Group.cs b/services/iam-service-net/src/IamService.Domain/AggregatesModel/GroupAggregate/Group.cs new file mode 100644 index 00000000..7ee1cb78 --- /dev/null +++ b/services/iam-service-net/src/IamService.Domain/AggregatesModel/GroupAggregate/Group.cs @@ -0,0 +1,258 @@ +using IamService.Domain.Events; +using IamService.Domain.SeedWork; + +namespace IamService.Domain.AggregatesModel.GroupAggregate; + +/// +/// EN: Group aggregate root for team management within organization. +/// VI: Aggregate root nhóm để quản lý team trong tổ chức. +/// +public class Group : Entity, IAggregateRoot +{ + // EN: Private fields for encapsulation + // VI: Fields private để đóng gói + private string _name = null!; + private string? _description; + private Guid _organizationId; + private DateTime _createdAt; + private DateTime? _updatedAt; + private bool _isDeleted; + + private readonly List _members = []; + private readonly List _permissions = []; + + + /// + /// EN: Group name. + /// VI: Tên nhóm. + /// + public string Name => _name; + + /// + /// EN: Group description. + /// VI: Mô tả nhóm. + /// + public string? Description => _description; + + /// + /// EN: Organization ID this group belongs to. + /// VI: ID tổ chức mà nhóm thuộc về. + /// + public Guid OrganizationId => _organizationId; + + /// + /// EN: Creation timestamp. + /// VI: Thời gian tạo. + /// + public DateTime CreatedAt => _createdAt; + + /// + /// EN: Last update timestamp. + /// VI: Thời gian cập nhật cuối. + /// + public DateTime? UpdatedAt => _updatedAt; + + /// + /// EN: Whether the group is soft deleted. + /// VI: Nhóm có bị xóa mềm không. + /// + public bool IsDeleted => _isDeleted; + + /// + /// EN: Group members. + /// VI: Thành viên nhóm. + /// + public IReadOnlyCollection Members => _members.AsReadOnly(); + + /// + /// EN: Group permissions. + /// VI: Quyền nhóm. + /// + public IReadOnlyCollection Permissions => _permissions.AsReadOnly(); + + + /// + /// EN: Private constructor for EF Core. + /// VI: Constructor private cho EF Core. + /// + protected Group() + { + } + + /// + /// EN: Create a new group. + /// VI: Tạo nhóm mới. + /// + public Group(Guid organizationId, string name, string? description = null) : this() + { + if (organizationId == Guid.Empty) + throw new ArgumentException("Organization ID cannot be empty", nameof(organizationId)); + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Group name cannot be empty", nameof(name)); + + Id = Guid.NewGuid(); + _organizationId = organizationId; + _name = name.Trim(); + _description = description?.Trim(); + _createdAt = DateTime.UtcNow; + _isDeleted = false; + + AddDomainEvent(new GroupCreatedEvent(Id, _organizationId, _name)); + } + + /// + /// EN: Factory method to create group. + /// VI: Factory method để tạo nhóm. + /// + public static Group Create(Guid organizationId, string name, string? description = null) + { + return new Group(organizationId, name, description); + } + + /// + /// EN: Update group information. + /// VI: Cập nhật thông tin nhóm. + /// + public void Update(string name, string? description) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Group name cannot be empty", nameof(name)); + + _name = name.Trim(); + _description = description?.Trim(); + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Add a member to the group. + /// VI: Thêm thành viên vào nhóm. + /// + public GroupMember AddMember(Guid userId, GroupRole? role = null, Guid? addedByUserId = null) + { + if (userId == Guid.Empty) + throw new ArgumentException("User ID cannot be empty", nameof(userId)); + + // EN: Check if user is already a member + // VI: Kiểm tra user đã là thành viên chưa + if (_members.Any(m => m.UserId == userId)) + throw new InvalidOperationException($"User {userId} is already a member of this group"); + + var actualRole = role ?? GroupRole.Member; + var member = new GroupMember(Id, userId, actualRole, addedByUserId); + _members.Add(member); + _updatedAt = DateTime.UtcNow; + + AddDomainEvent(new MemberAddedToGroupEvent(Id, userId, actualRole.Name)); + + return member; + } + + /// + /// EN: Remove a member from the group. + /// VI: Xóa thành viên khỏi nhóm. + /// + public void RemoveMember(Guid userId) + { + var member = _members.FirstOrDefault(m => m.UserId == userId); + if (member == null) + throw new InvalidOperationException($"User {userId} is not a member of this group"); + + // EN: Cannot remove the last owner + // VI: Không thể xóa owner cuối cùng + if (member.IsOwner() && _members.Count(m => m.IsOwner()) <= 1) + throw new InvalidOperationException("Cannot remove the last owner of the group"); + + _members.Remove(member); + _updatedAt = DateTime.UtcNow; + + AddDomainEvent(new MemberRemovedFromGroupEvent(Id, userId)); + } + + /// + /// EN: Change a member's role. + /// VI: Thay đổi vai trò của thành viên. + /// + public void ChangeMemberRole(Guid userId, GroupRole newRole) + { + var member = _members.FirstOrDefault(m => m.UserId == userId); + if (member == null) + throw new InvalidOperationException($"User {userId} is not a member of this group"); + + // EN: If demoting from owner, ensure at least one owner remains + // VI: Nếu giáng chức từ owner, đảm bảo còn ít nhất một owner + if (member.IsOwner() && newRole != GroupRole.Owner && _members.Count(m => m.IsOwner()) <= 1) + throw new InvalidOperationException("Cannot demote the last owner of the group"); + + member.ChangeRole(newRole); + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Add a permission to the group. + /// VI: Thêm quyền cho nhóm. + /// + public GroupPermission AddPermission(string permission, string? resource = null, Guid? grantedByUserId = null) + { + if (string.IsNullOrWhiteSpace(permission)) + throw new ArgumentException("Permission cannot be empty", nameof(permission)); + + // EN: Check if permission already exists + // VI: Kiểm tra quyền đã tồn tại chưa + if (_permissions.Any(p => p.Permission == permission.ToLowerInvariant() && p.Resource == resource)) + throw new InvalidOperationException($"Permission '{permission}' for resource '{resource}' already exists"); + + var groupPermission = new GroupPermission(Id, permission, resource, grantedByUserId); + _permissions.Add(groupPermission); + _updatedAt = DateTime.UtcNow; + + return groupPermission; + } + + /// + /// EN: Remove a permission from the group. + /// VI: Xóa quyền khỏi nhóm. + /// + public void RemovePermission(string permission, string? resource = null) + { + var groupPermission = _permissions.FirstOrDefault(p => + p.Permission == permission.ToLowerInvariant() && + p.Resource == resource); + + if (groupPermission == null) + throw new InvalidOperationException($"Permission '{permission}' for resource '{resource}' not found"); + + _permissions.Remove(groupPermission); + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Check if the group has a specific permission. + /// VI: Kiểm tra nhóm có quyền cụ thể không. + /// + public bool HasPermission(string permission, string? resource = null) + { + return _permissions.Any(p => p.Matches(permission, resource)); + } + + /// + /// EN: Soft delete the group. + /// VI: Xóa mềm nhóm. + /// + public void Delete() + { + _isDeleted = true; + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Restore a soft-deleted group. + /// VI: Khôi phục nhóm đã xóa mềm. + /// + public void Restore() + { + _isDeleted = false; + _updatedAt = DateTime.UtcNow; + } + + +} diff --git a/services/iam-service-net/src/IamService.Domain/AggregatesModel/GroupAggregate/GroupMember.cs b/services/iam-service-net/src/IamService.Domain/AggregatesModel/GroupAggregate/GroupMember.cs new file mode 100644 index 00000000..6f52c8f1 --- /dev/null +++ b/services/iam-service-net/src/IamService.Domain/AggregatesModel/GroupAggregate/GroupMember.cs @@ -0,0 +1,104 @@ +using IamService.Domain.SeedWork; + +namespace IamService.Domain.AggregatesModel.GroupAggregate; + +/// +/// EN: Group member entity representing a user's membership in a group. +/// VI: Entity thành viên nhóm đại diện cho việc user tham gia nhóm. +/// +public class GroupMember : Entity +{ + // EN: Private fields for encapsulation + // VI: Fields private để đóng gói + private Guid _userId; + private Guid _groupId; + private GroupRole _role = null!; + private DateTime _joinedAt; + private Guid? _addedByUserId; + + /// + /// EN: User ID of the member. + /// VI: ID của user thành viên. + /// + public Guid UserId => _userId; + + /// + /// EN: Group ID this member belongs to. + /// VI: ID nhóm mà thành viên thuộc về. + /// + public Guid GroupId => _groupId; + + /// + /// EN: Role of the member in the group. + /// VI: Vai trò của thành viên trong nhóm. + /// + public GroupRole Role => _role; + + /// + /// EN: Role ID for EF Core mapping. + /// VI: ID vai trò cho EF Core mapping. + /// + public int RoleId { get; private set; } + + /// + /// EN: When the member joined the group. + /// VI: Thời điểm thành viên tham gia nhóm. + /// + public DateTime JoinedAt => _joinedAt; + + /// + /// EN: Who added this member to the group. + /// VI: Ai đã thêm thành viên này vào nhóm. + /// + public Guid? AddedByUserId => _addedByUserId; + + /// + /// EN: Private constructor for EF Core. + /// VI: Constructor private cho EF Core. + /// + protected GroupMember() + { + } + + /// + /// EN: Create a new group member. + /// VI: Tạo thành viên nhóm mới. + /// + public GroupMember(Guid groupId, Guid userId, GroupRole role, Guid? addedByUserId = null) : this() + { + if (groupId == Guid.Empty) + throw new ArgumentException("Group ID cannot be empty", nameof(groupId)); + if (userId == Guid.Empty) + throw new ArgumentException("User ID cannot be empty", nameof(userId)); + + Id = Guid.NewGuid(); + _groupId = groupId; + _userId = userId; + _role = role ?? GroupRole.Member; + RoleId = _role.Id; + _joinedAt = DateTime.UtcNow; + _addedByUserId = addedByUserId; + } + + /// + /// EN: Change the role of this member. + /// VI: Thay đổi vai trò của thành viên này. + /// + public void ChangeRole(GroupRole newRole) + { + _role = newRole ?? throw new ArgumentNullException(nameof(newRole)); + RoleId = _role.Id; + } + + /// + /// EN: Check if this member is an owner. + /// VI: Kiểm tra thành viên này có phải owner không. + /// + public bool IsOwner() => _role == GroupRole.Owner; + + /// + /// EN: Check if this member is an admin or owner. + /// VI: Kiểm tra thành viên này có phải admin hoặc owner không. + /// + public bool IsAdminOrOwner() => _role == GroupRole.Admin || _role == GroupRole.Owner; +} diff --git a/services/iam-service-net/src/IamService.Domain/AggregatesModel/GroupAggregate/GroupPermission.cs b/services/iam-service-net/src/IamService.Domain/AggregatesModel/GroupAggregate/GroupPermission.cs new file mode 100644 index 00000000..133448eb --- /dev/null +++ b/services/iam-service-net/src/IamService.Domain/AggregatesModel/GroupAggregate/GroupPermission.cs @@ -0,0 +1,105 @@ +using IamService.Domain.SeedWork; + +namespace IamService.Domain.AggregatesModel.GroupAggregate; + +/// +/// EN: Group permission entity representing permissions granted to a group. +/// VI: Entity quyền nhóm đại diện cho quyền được cấp cho nhóm. +/// +public class GroupPermission : Entity +{ + // EN: Private fields for encapsulation + // VI: Fields private để đóng gói + private Guid _groupId; + private string _permission = null!; + private string? _resource; + private DateTime _grantedAt; + private Guid? _grantedByUserId; + + /// + /// EN: Group ID this permission belongs to. + /// VI: ID nhóm mà quyền thuộc về. + /// + public Guid GroupId => _groupId; + + /// + /// EN: Permission name (e.g., "read", "write", "delete", "admin"). + /// VI: Tên quyền (ví dụ: "read", "write", "delete", "admin"). + /// + public string Permission => _permission; + + /// + /// EN: Optional resource the permission applies to (e.g., "projects/*", "users/123"). + /// VI: Resource tùy chọn mà quyền áp dụng (ví dụ: "projects/*", "users/123"). + /// + public string? Resource => _resource; + + /// + /// EN: When the permission was granted. + /// VI: Thời điểm quyền được cấp. + /// + public DateTime GrantedAt => _grantedAt; + + /// + /// EN: Who granted this permission. + /// VI: Ai đã cấp quyền này. + /// + public Guid? GrantedByUserId => _grantedByUserId; + + /// + /// EN: Private constructor for EF Core. + /// VI: Constructor private cho EF Core. + /// + protected GroupPermission() + { + } + + /// + /// EN: Create a new group permission. + /// VI: Tạo quyền nhóm mới. + /// + public GroupPermission(Guid groupId, string permission, string? resource = null, Guid? grantedByUserId = null) : this() + { + if (groupId == Guid.Empty) + throw new ArgumentException("Group ID cannot be empty", nameof(groupId)); + if (string.IsNullOrWhiteSpace(permission)) + throw new ArgumentException("Permission cannot be empty", nameof(permission)); + + Id = Guid.NewGuid(); + _groupId = groupId; + _permission = permission.ToLowerInvariant().Trim(); + _resource = resource?.Trim(); + _grantedAt = DateTime.UtcNow; + _grantedByUserId = grantedByUserId; + } + + /// + /// EN: Check if this permission matches a given permission and resource. + /// VI: Kiểm tra quyền này có khớp với quyền và resource cho trước không. + /// + public bool Matches(string permission, string? resource = null) + { + if (!string.Equals(_permission, permission, StringComparison.OrdinalIgnoreCase)) + return false; + + // EN: If no specific resource is required, match any + // VI: Nếu không yêu cầu resource cụ thể, khớp với bất kỳ + if (string.IsNullOrEmpty(resource)) + return true; + + // EN: If this permission has no resource restriction, it applies to all + // VI: Nếu quyền này không có giới hạn resource, nó áp dụng cho tất cả + if (string.IsNullOrEmpty(_resource)) + return true; + + // EN: Check for wildcard match + // VI: Kiểm tra khớp với wildcard + if (_resource.EndsWith("/*")) + { + var prefix = _resource[..^2]; + return resource.StartsWith(prefix, StringComparison.OrdinalIgnoreCase); + } + + return string.Equals(_resource, resource, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/services/iam-service-net/src/IamService.Domain/AggregatesModel/GroupAggregate/GroupRole.cs b/services/iam-service-net/src/IamService.Domain/AggregatesModel/GroupAggregate/GroupRole.cs new file mode 100644 index 00000000..eb30af05 --- /dev/null +++ b/services/iam-service-net/src/IamService.Domain/AggregatesModel/GroupAggregate/GroupRole.cs @@ -0,0 +1,67 @@ +using IamService.Domain.SeedWork; + +namespace IamService.Domain.AggregatesModel.GroupAggregate; + +/// +/// EN: Group role enumeration. +/// VI: Enumeration vai trò trong nhóm. +/// +public class GroupRole : Enumeration +{ + /// + /// EN: Regular member with basic permissions. + /// VI: Thành viên thường với quyền cơ bản. + /// + public static readonly GroupRole Member = new(1, nameof(Member)); + + /// + /// EN: Admin with management permissions. + /// VI: Admin với quyền quản lý. + /// + public static readonly GroupRole Admin = new(2, nameof(Admin)); + + /// + /// EN: Owner with full control. + /// VI: Owner với toàn quyền kiểm soát. + /// + public static readonly GroupRole Owner = new(3, nameof(Owner)); + + public GroupRole(int id, string name) : base(id, name) + { + } + + /// + /// EN: Get all group roles. + /// VI: Lấy tất cả vai trò trong nhóm. + /// + public static IEnumerable GetAll() => + [ + Member, + Admin, + Owner + ]; + + /// + /// EN: Check if this role has higher or equal permissions than another. + /// VI: Kiểm tra vai trò này có quyền cao hơn hoặc bằng vai trò khác không. + /// + public bool HasPermissionOver(GroupRole other) + { + return Id >= other.Id; + } + + /// + /// EN: Get role by ID. + /// VI: Lấy vai trò theo ID. + /// + public static GroupRole? FromId(int id) + { + return id switch + { + 1 => Member, + 2 => Admin, + 3 => Owner, + _ => null + }; + } +} diff --git a/services/iam-service-net/src/IamService.Domain/AggregatesModel/GroupAggregate/IGroupRepository.cs b/services/iam-service-net/src/IamService.Domain/AggregatesModel/GroupAggregate/IGroupRepository.cs new file mode 100644 index 00000000..1a3389cf --- /dev/null +++ b/services/iam-service-net/src/IamService.Domain/AggregatesModel/GroupAggregate/IGroupRepository.cs @@ -0,0 +1,58 @@ +using IamService.Domain.SeedWork; + +namespace IamService.Domain.AggregatesModel.GroupAggregate; + +/// +/// EN: Interface for Group repository. +/// VI: Interface cho repository Group. +/// +public interface IGroupRepository : IRepository +{ + /// + /// EN: Get group by ID with members and permissions. + /// VI: Lấy nhóm theo ID với thành viên và quyền. + /// + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + + /// + /// EN: Get group by ID with members loaded. + /// VI: Lấy nhóm theo ID với thành viên được load. + /// + Task GetByIdWithMembersAsync(Guid id, CancellationToken cancellationToken = default); + + /// + /// EN: Get all groups for an organization. + /// VI: Lấy tất cả nhóm của một tổ chức. + /// + Task> GetByOrganizationIdAsync(Guid organizationId, CancellationToken cancellationToken = default); + + /// + /// EN: Get all groups a user is a member of. + /// VI: Lấy tất cả nhóm mà user là thành viên. + /// + Task> GetByUserIdAsync(Guid userId, CancellationToken cancellationToken = default); + + /// + /// EN: Check if a user is a member of a group. + /// VI: Kiểm tra user có phải là thành viên của nhóm không. + /// + Task IsUserMemberAsync(Guid groupId, Guid userId, CancellationToken cancellationToken = default); + + /// + /// EN: Add a new group. + /// VI: Thêm nhóm mới. + /// + Group Add(Group group); + + /// + /// EN: Update an existing group. + /// VI: Cập nhật nhóm hiện có. + /// + void Update(Group group); + + /// + /// EN: Delete a group. + /// VI: Xóa nhóm. + /// + void Delete(Group group); +} diff --git a/services/iam-service-net/src/IamService.Domain/AggregatesModel/OrganizationAggregate/IOrganizationRepository.cs b/services/iam-service-net/src/IamService.Domain/AggregatesModel/OrganizationAggregate/IOrganizationRepository.cs new file mode 100644 index 00000000..2fe3750e --- /dev/null +++ b/services/iam-service-net/src/IamService.Domain/AggregatesModel/OrganizationAggregate/IOrganizationRepository.cs @@ -0,0 +1,58 @@ +using IamService.Domain.SeedWork; + +namespace IamService.Domain.AggregatesModel.OrganizationAggregate; + +/// +/// EN: Interface for Organization repository. +/// VI: Interface cho repository Organization. +/// +public interface IOrganizationRepository : IRepository +{ + /// + /// EN: Get organization by ID with optional includes. + /// VI: Lấy tổ chức theo ID với optional includes. + /// + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + + /// + /// EN: Get organization by slug. + /// VI: Lấy tổ chức theo slug. + /// + Task GetBySlugAsync(string slug, CancellationToken cancellationToken = default); + + /// + /// EN: Get organization hierarchy (parent and children). + /// VI: Lấy cây phân cấp tổ chức (cha và con). + /// + Task> GetHierarchyAsync(Guid rootId, CancellationToken cancellationToken = default); + + /// + /// EN: Get all child organizations of a parent. + /// VI: Lấy tất cả tổ chức con của một tổ chức cha. + /// + Task> GetChildrenAsync(Guid parentId, CancellationToken cancellationToken = default); + + /// + /// EN: Check if slug is unique. + /// VI: Kiểm tra slug có duy nhất không. + /// + Task IsSlugUniqueAsync(string slug, Guid? excludeId = null, CancellationToken cancellationToken = default); + + /// + /// EN: Add a new organization. + /// VI: Thêm tổ chức mới. + /// + Organization Add(Organization organization); + + /// + /// EN: Update an existing organization. + /// VI: Cập nhật tổ chức hiện có. + /// + void Update(Organization organization); + + /// + /// EN: Delete an organization. + /// VI: Xóa tổ chức. + /// + void Delete(Organization organization); +} diff --git a/services/iam-service-net/src/IamService.Domain/AggregatesModel/OrganizationAggregate/Organization.cs b/services/iam-service-net/src/IamService.Domain/AggregatesModel/OrganizationAggregate/Organization.cs new file mode 100644 index 00000000..11492c89 --- /dev/null +++ b/services/iam-service-net/src/IamService.Domain/AggregatesModel/OrganizationAggregate/Organization.cs @@ -0,0 +1,243 @@ +using IamService.Domain.AggregatesModel.GroupAggregate; +using IamService.Domain.Events; +using IamService.Domain.SeedWork; + +namespace IamService.Domain.AggregatesModel.OrganizationAggregate; + +/// +/// EN: Organization aggregate root for multi-tenant support. +/// VI: Aggregate root tổ chức cho hỗ trợ multi-tenant. +/// +public class Organization : Entity, IAggregateRoot +{ + // EN: Private fields for encapsulation + // VI: Fields private để đóng gói + private string _name = null!; + private string _slug = null!; + private string? _description; + private Guid? _parentOrganizationId; + private OrganizationStatus _status = null!; + private OrganizationSettings _settings = null!; + private DateTime _createdAt; + private DateTime? _updatedAt; + + + /// + /// EN: Organization name. + /// VI: Tên tổ chức. + /// + public string Name => _name; + + /// + /// EN: URL-friendly unique identifier. + /// VI: Định danh duy nhất thân thiện với URL. + /// + public string Slug => _slug; + + /// + /// EN: Organization description. + /// VI: Mô tả tổ chức. + /// + public string? Description => _description; + + /// + /// EN: Parent organization ID for hierarchy. + /// VI: ID tổ chức cha cho phân cấp. + /// + public Guid? ParentOrganizationId => _parentOrganizationId; + + /// + /// EN: Current status of the organization. + /// VI: Trạng thái hiện tại của tổ chức. + /// + public OrganizationStatus Status => _status; + + /// + /// EN: Status ID for EF Core mapping. + /// VI: ID trạng thái cho EF Core mapping. + /// + public int StatusId { get; private set; } + + /// + /// EN: Organization settings. + /// VI: Cấu hình tổ chức. + /// + public OrganizationSettings Settings => _settings; + + /// + /// EN: Creation timestamp. + /// VI: Thời gian tạo. + /// + public DateTime CreatedAt => _createdAt; + + /// + /// EN: Last update timestamp. + /// VI: Thời gian cập nhật cuối. + /// + public DateTime? UpdatedAt => _updatedAt; + + + /// + /// EN: Private constructor for EF Core. + /// VI: Constructor private cho EF Core. + /// + protected Organization() + { + } + + /// + /// EN: Create a new organization. + /// VI: Tạo tổ chức mới. + /// + /// Organization name / Tên tổ chức + /// URL-friendly slug / Slug thân thiện URL + /// Optional description / Mô tả tùy chọn + /// Optional parent ID / ID cha tùy chọn + /// Optional settings / Cấu hình tùy chọn + public Organization( + string name, + string slug, + string? description = null, + Guid? parentOrganizationId = null, + OrganizationSettings? settings = null) : this() + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Organization name cannot be empty", nameof(name)); + if (string.IsNullOrWhiteSpace(slug)) + throw new ArgumentException("Organization slug cannot be empty", nameof(slug)); + if (!IsValidSlug(slug)) + throw new ArgumentException("Slug must contain only lowercase letters, numbers, and hyphens", nameof(slug)); + + Id = Guid.NewGuid(); + _name = name.Trim(); + _slug = slug.ToLowerInvariant().Trim(); + _description = description?.Trim(); + _parentOrganizationId = parentOrganizationId; + _status = OrganizationStatus.Active; + StatusId = OrganizationStatus.Active.Id; + _settings = settings ?? OrganizationSettings.Default; + _createdAt = DateTime.UtcNow; + + AddDomainEvent(new OrganizationCreatedEvent(Id, _name, _slug)); + } + + /// + /// EN: Factory method to create organization. + /// VI: Factory method để tạo tổ chức. + /// + public static Organization Create( + string name, + string slug, + string? description = null, + Guid? parentOrganizationId = null, + OrganizationSettings? settings = null) + { + return new Organization(name, slug, description, parentOrganizationId, settings); + } + + /// + /// EN: Update organization information. + /// VI: Cập nhật thông tin tổ chức. + /// + public void UpdateInfo(string name, string? description) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Organization name cannot be empty", nameof(name)); + + _name = name.Trim(); + _description = description?.Trim(); + _updatedAt = DateTime.UtcNow; + + AddDomainEvent(new OrganizationUpdatedEvent(Id, _name)); + } + + /// + /// EN: Update organization slug. + /// VI: Cập nhật slug tổ chức. + /// + public void UpdateSlug(string slug) + { + if (string.IsNullOrWhiteSpace(slug)) + throw new ArgumentException("Organization slug cannot be empty", nameof(slug)); + if (!IsValidSlug(slug)) + throw new ArgumentException("Slug must contain only lowercase letters, numbers, and hyphens", nameof(slug)); + + _slug = slug.ToLowerInvariant().Trim(); + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Set parent organization for hierarchy. + /// VI: Đặt tổ chức cha cho phân cấp. + /// + public void SetParent(Guid? parentOrganizationId) + { + if (parentOrganizationId == Id) + throw new InvalidOperationException("Organization cannot be its own parent"); + + _parentOrganizationId = parentOrganizationId; + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Update organization settings. + /// VI: Cập nhật cấu hình tổ chức. + /// + public void UpdateSettings(OrganizationSettings settings) + { + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Activate the organization. + /// VI: Kích hoạt tổ chức. + /// + public void Activate() + { + if (_status == OrganizationStatus.Archived) + throw new InvalidOperationException("Cannot activate an archived organization"); + + _status = OrganizationStatus.Active; + StatusId = OrganizationStatus.Active.Id; + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Suspend the organization. + /// VI: Tạm ngưng tổ chức. + /// + public void Suspend() + { + if (_status == OrganizationStatus.Archived) + throw new InvalidOperationException("Cannot suspend an archived organization"); + + _status = OrganizationStatus.Suspended; + StatusId = OrganizationStatus.Suspended.Id; + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Archive the organization (soft delete). + /// VI: Lưu trữ tổ chức (xóa mềm). + /// + public void Archive() + { + _status = OrganizationStatus.Archived; + StatusId = OrganizationStatus.Archived.Id; + _updatedAt = DateTime.UtcNow; + } + + + /// + /// EN: Validate slug format. + /// VI: Kiểm tra định dạng slug. + /// + private static bool IsValidSlug(string slug) + { + return System.Text.RegularExpressions.Regex.IsMatch( + slug, + @"^[a-z0-9]+(?:-[a-z0-9]+)*$" + ); + } +} diff --git a/services/iam-service-net/src/IamService.Domain/AggregatesModel/OrganizationAggregate/OrganizationSettings.cs b/services/iam-service-net/src/IamService.Domain/AggregatesModel/OrganizationAggregate/OrganizationSettings.cs new file mode 100644 index 00000000..75b3a9dc --- /dev/null +++ b/services/iam-service-net/src/IamService.Domain/AggregatesModel/OrganizationAggregate/OrganizationSettings.cs @@ -0,0 +1,115 @@ +using IamService.Domain.SeedWork; + +namespace IamService.Domain.AggregatesModel.OrganizationAggregate; + +/// +/// EN: Organization settings value object containing organization-level configurations. +/// VI: Value object cấu hình tổ chức chứa các thiết lập cấp tổ chức. +/// +public class OrganizationSettings : ValueObject +{ + /// + /// EN: Whether new users can self-register to this organization. + /// VI: Cho phép người dùng mới tự đăng ký vào tổ chức này. + /// + public bool AllowUserRegistration { get; } + + /// + /// EN: Whether email verification is required for new users. + /// VI: Yêu cầu xác thực email cho người dùng mới. + /// + public bool RequireEmailVerification { get; } + + /// + /// EN: Whether 2FA is required for all users. + /// VI: Yêu cầu 2FA cho tất cả người dùng. + /// + public bool Require2FA { get; } + + /// + /// EN: Maximum number of users allowed in this organization. + /// VI: Số lượng người dùng tối đa được phép trong tổ chức. + /// + public int MaxUsersLimit { get; } + + /// + /// EN: Custom domain for the organization (e.g., auth.company.com). + /// VI: Tên miền tùy chỉnh cho tổ chức (ví dụ: auth.company.com). + /// + public string? CustomDomain { get; } + + /// + /// EN: Session timeout in minutes. + /// VI: Thời gian hết hạn phiên tính bằng phút. + /// + public int SessionTimeoutMinutes { get; } + + /// + /// EN: Private constructor for EF Core. + /// VI: Constructor private cho EF Core. + /// + private OrganizationSettings() + { + } + + /// + /// EN: Create organization settings with specified values. + /// VI: Tạo cấu hình tổ chức với các giá trị được chỉ định. + /// + public OrganizationSettings( + bool allowUserRegistration = true, + bool requireEmailVerification = true, + bool require2FA = false, + int maxUsersLimit = 100, + string? customDomain = null, + int sessionTimeoutMinutes = 60) + { + if (maxUsersLimit < 0) + throw new ArgumentException("Max users limit cannot be negative", nameof(maxUsersLimit)); + if (sessionTimeoutMinutes < 1) + throw new ArgumentException("Session timeout must be at least 1 minute", nameof(sessionTimeoutMinutes)); + + AllowUserRegistration = allowUserRegistration; + RequireEmailVerification = requireEmailVerification; + Require2FA = require2FA; + MaxUsersLimit = maxUsersLimit; + CustomDomain = customDomain; + SessionTimeoutMinutes = sessionTimeoutMinutes; + } + + /// + /// EN: Create default organization settings. + /// VI: Tạo cấu hình tổ chức mặc định. + /// + public static OrganizationSettings Default => new( + allowUserRegistration: true, + requireEmailVerification: true, + require2FA: false, + maxUsersLimit: 100, + customDomain: null, + sessionTimeoutMinutes: 60 + ); + + /// + /// EN: Create enterprise organization settings with stricter security. + /// VI: Tạo cấu hình tổ chức enterprise với bảo mật nghiêm ngặt hơn. + /// + public static OrganizationSettings Enterprise => new( + allowUserRegistration: false, + requireEmailVerification: true, + require2FA: true, + maxUsersLimit: 1000, + customDomain: null, + sessionTimeoutMinutes: 30 + ); + + protected override IEnumerable GetEqualityComponents() + { + yield return AllowUserRegistration; + yield return RequireEmailVerification; + yield return Require2FA; + yield return MaxUsersLimit; + yield return CustomDomain; + yield return SessionTimeoutMinutes; + } +} diff --git a/services/iam-service-net/src/IamService.Domain/AggregatesModel/OrganizationAggregate/OrganizationStatus.cs b/services/iam-service-net/src/IamService.Domain/AggregatesModel/OrganizationAggregate/OrganizationStatus.cs new file mode 100644 index 00000000..16aacff7 --- /dev/null +++ b/services/iam-service-net/src/IamService.Domain/AggregatesModel/OrganizationAggregate/OrganizationStatus.cs @@ -0,0 +1,50 @@ +using IamService.Domain.SeedWork; + +namespace IamService.Domain.AggregatesModel.OrganizationAggregate; + +/// +/// EN: Organization status enumeration. +/// VI: Enumeration trạng thái tổ chức. +/// +public class OrganizationStatus : Enumeration +{ + /// + /// EN: Organization is active and operational. + /// VI: Tổ chức đang hoạt động. + /// + public static readonly OrganizationStatus Active = new(1, nameof(Active)); + + /// + /// EN: Organization is suspended (temporarily disabled). + /// VI: Tổ chức bị tạm ngưng. + /// + public static readonly OrganizationStatus Suspended = new(2, nameof(Suspended)); + + /// + /// EN: Organization is pending approval. + /// VI: Tổ chức đang chờ phê duyệt. + /// + public static readonly OrganizationStatus PendingApproval = new(3, nameof(PendingApproval)); + + /// + /// EN: Organization is archived (soft deleted). + /// VI: Tổ chức đã được lưu trữ (xóa mềm). + /// + public static readonly OrganizationStatus Archived = new(4, nameof(Archived)); + + public OrganizationStatus(int id, string name) : base(id, name) + { + } + + /// + /// EN: Get all organization statuses. + /// VI: Lấy tất cả trạng thái tổ chức. + /// + public static IEnumerable GetAll() => + [ + Active, + Suspended, + PendingApproval, + Archived + ]; +} diff --git a/services/iam-service-net/src/IamService.Domain/AggregatesModel/UserAggregate/Address.cs b/services/iam-service-net/src/IamService.Domain/AggregatesModel/UserAggregate/Address.cs new file mode 100644 index 00000000..d17559f7 --- /dev/null +++ b/services/iam-service-net/src/IamService.Domain/AggregatesModel/UserAggregate/Address.cs @@ -0,0 +1,140 @@ +using IamService.Domain.SeedWork; + +namespace IamService.Domain.AggregatesModel.UserAggregate; + +/// +/// EN: Address value object. +/// VI: Value object địa chỉ. +/// +public class Address : ValueObject +{ + /// + /// EN: Street address (line 1). + /// VI: Địa chỉ đường (dòng 1). + /// + public string Street { get; } + + /// + /// EN: Additional address info (line 2). + /// VI: Thông tin địa chỉ bổ sung (dòng 2). + /// + public string? Street2 { get; } + + /// + /// EN: City name. + /// VI: Tên thành phố. + /// + public string City { get; } + + /// + /// EN: State or province. + /// VI: Tỉnh/Thành phố. + /// + public string? State { get; } + + /// + /// EN: Postal or ZIP code. + /// VI: Mã bưu điện. + /// + public string? PostalCode { get; } + + /// + /// EN: Country code (ISO 3166-1 alpha-2). + /// VI: Mã quốc gia (ISO 3166-1 alpha-2). + /// + public string Country { get; } + + /// + /// EN: Private constructor for EF Core. + /// VI: Constructor private cho EF Core. + /// + private Address() + { + Street = string.Empty; + City = string.Empty; + Country = string.Empty; + } + + /// + /// EN: Create a new address. + /// VI: Tạo địa chỉ mới. + /// + public Address( + string street, + string city, + string country, + string? street2 = null, + string? state = null, + string? postalCode = null) + { + if (string.IsNullOrWhiteSpace(street)) + throw new ArgumentException("Street cannot be empty", nameof(street)); + if (string.IsNullOrWhiteSpace(city)) + throw new ArgumentException("City cannot be empty", nameof(city)); + if (string.IsNullOrWhiteSpace(country)) + throw new ArgumentException("Country cannot be empty", nameof(country)); + + Street = street.Trim(); + Street2 = street2?.Trim(); + City = city.Trim(); + State = state?.Trim(); + PostalCode = postalCode?.Trim(); + Country = country.Trim().ToUpperInvariant(); + } + + /// + /// EN: Format address for display. + /// VI: Định dạng địa chỉ để hiển thị. + /// + public string ToDisplayString() + { + var parts = new List { Street }; + + if (!string.IsNullOrEmpty(Street2)) + parts.Add(Street2); + + var cityLine = City; + if (!string.IsNullOrEmpty(State)) + cityLine += $", {State}"; + if (!string.IsNullOrEmpty(PostalCode)) + cityLine += $" {PostalCode}"; + parts.Add(cityLine); + + parts.Add(Country); + + return string.Join("\n", parts); + } + + /// + /// EN: Format address as single line. + /// VI: Định dạng địa chỉ thành một dòng. + /// + public string ToSingleLineString() + { + var parts = new List { Street }; + + if (!string.IsNullOrEmpty(Street2)) + parts.Add(Street2); + + parts.Add(City); + + if (!string.IsNullOrEmpty(State)) + parts.Add(State); + if (!string.IsNullOrEmpty(PostalCode)) + parts.Add(PostalCode); + + parts.Add(Country); + + return string.Join(", ", parts); + } + + protected override IEnumerable GetEqualityComponents() + { + yield return Street; + yield return Street2; + yield return City; + yield return State; + yield return PostalCode; + yield return Country; + } +} diff --git a/services/iam-service-net/src/IamService.Domain/AggregatesModel/UserAggregate/PhoneNumber.cs b/services/iam-service-net/src/IamService.Domain/AggregatesModel/UserAggregate/PhoneNumber.cs new file mode 100644 index 00000000..78e68c29 --- /dev/null +++ b/services/iam-service-net/src/IamService.Domain/AggregatesModel/UserAggregate/PhoneNumber.cs @@ -0,0 +1,113 @@ +using IamService.Domain.SeedWork; + +namespace IamService.Domain.AggregatesModel.UserAggregate; + +/// +/// EN: Phone number value object with validation and formatting. +/// VI: Value object số điện thoại với validation và formatting. +/// +public class PhoneNumber : ValueObject +{ + /// + /// EN: Country code (e.g., +84, +1). + /// VI: Mã quốc gia (ví dụ: +84, +1). + /// + public string CountryCode { get; } + + /// + /// EN: National number without country code. + /// VI: Số điện thoại quốc gia không có mã quốc gia. + /// + public string NationalNumber { get; } + + /// + /// EN: Full phone number with country code. + /// VI: Số điện thoại đầy đủ với mã quốc gia. + /// + public string FullNumber => $"{CountryCode}{NationalNumber}"; + + /// + /// EN: Private constructor for EF Core. + /// VI: Constructor private cho EF Core. + /// + private PhoneNumber() + { + CountryCode = string.Empty; + NationalNumber = string.Empty; + } + + /// + /// EN: Create a new phone number. + /// VI: Tạo số điện thoại mới. + /// + public PhoneNumber(string countryCode, string nationalNumber) + { + if (string.IsNullOrWhiteSpace(countryCode)) + throw new ArgumentException("Country code cannot be empty", nameof(countryCode)); + if (string.IsNullOrWhiteSpace(nationalNumber)) + throw new ArgumentException("National number cannot be empty", nameof(nationalNumber)); + + // EN: Normalize country code + // VI: Chuẩn hóa mã quốc gia + CountryCode = countryCode.StartsWith("+") ? countryCode : $"+{countryCode}"; + + // EN: Remove non-digit characters from national number + // VI: Xóa các ký tự không phải số khỏi số điện thoại + NationalNumber = new string(nationalNumber.Where(char.IsDigit).ToArray()); + + if (NationalNumber.Length < 7 || NationalNumber.Length > 15) + throw new ArgumentException("National number must be between 7 and 15 digits", nameof(nationalNumber)); + } + + /// + /// EN: Parse a full phone number string. + /// VI: Parse chuỗi số điện thoại đầy đủ. + /// + public static PhoneNumber Parse(string fullNumber) + { + if (string.IsNullOrWhiteSpace(fullNumber)) + throw new ArgumentException("Phone number cannot be empty", nameof(fullNumber)); + + // EN: Simple parsing - assumes format +XX NNNNNNNN or +XXXNNNNNNNN + // VI: Parsing đơn giản - giả sử định dạng +XX NNNNNNNN hoặc +XXXNNNNNNNN + var cleanNumber = new string(fullNumber.Where(c => char.IsDigit(c) || c == '+').ToArray()); + + if (!cleanNumber.StartsWith("+")) + throw new ArgumentException("Phone number must start with country code (+XX)", nameof(fullNumber)); + + // EN: Assume first 1-3 digits after + are country code + // VI: Giả sử 1-3 chữ số đầu tiên sau + là mã quốc gia + var countryCodeLength = cleanNumber.Length switch + { + <= 10 => 2, // +1 format (USA) + <= 12 => 3, // +84 format (Vietnam) + _ => 3 + }; + + var countryCode = cleanNumber[..(countryCodeLength + 1)]; // Include + + var nationalNumber = cleanNumber[(countryCodeLength + 1)..]; + + return new PhoneNumber(countryCode, nationalNumber); + } + + /// + /// EN: Format for display. + /// VI: Định dạng để hiển thị. + /// + public string ToDisplayString() + { + // EN: Format as +XX XXX XXX XXXX + // VI: Định dạng thành +XX XXX XXX XXXX + if (NationalNumber.Length >= 10) + { + return $"{CountryCode} {NationalNumber[..3]} {NationalNumber[3..6]} {NationalNumber[6..]}"; + } + return FullNumber; + } + + protected override IEnumerable GetEqualityComponents() + { + yield return CountryCode; + yield return NationalNumber; + } +} diff --git a/services/iam-service-net/src/IamService.Domain/AggregatesModel/UserAggregate/ProfileAttribute.cs b/services/iam-service-net/src/IamService.Domain/AggregatesModel/UserAggregate/ProfileAttribute.cs new file mode 100644 index 00000000..6334daf8 --- /dev/null +++ b/services/iam-service-net/src/IamService.Domain/AggregatesModel/UserAggregate/ProfileAttribute.cs @@ -0,0 +1,253 @@ +using IamService.Domain.SeedWork; + +namespace IamService.Domain.AggregatesModel.UserAggregate; + +/// +/// EN: Profile attribute entity for storing typed key-value pairs. +/// VI: Entity profile attribute để lưu trữ cặp key-value có kiểu dữ liệu. +/// +public class ProfileAttribute : Entity +{ + // EN: Private fields for encapsulation + // VI: Fields private để đóng gói + private Guid _userProfileId; + private string _key = null!; + private string _value = null!; + private ProfileAttributeType _valueType = null!; + private DateTime _createdAt; + private DateTime? _updatedAt; + + /// + /// EN: User profile ID this attribute belongs to. + /// VI: ID profile user mà attribute này thuộc về. + /// + public Guid UserProfileId => _userProfileId; + + /// + /// EN: Attribute key (e.g., "company", "job_title", "preferences.theme"). + /// VI: Key của attribute (ví dụ: "company", "job_title", "preferences.theme"). + /// + public string Key => _key; + + /// + /// EN: Attribute value stored as string (parsed based on ValueType). + /// VI: Giá trị attribute lưu dạng chuỗi (parse dựa trên ValueType). + /// + public string Value => _value; + + /// + /// EN: Type of the value for proper parsing. + /// VI: Loại giá trị để parse đúng cách. + /// + public ProfileAttributeType ValueType => _valueType; + + /// + /// EN: Value type ID for EF Core mapping. + /// VI: ID loại giá trị cho EF Core mapping. + /// + public int ValueTypeId { get; private set; } + + /// + /// EN: Creation timestamp. + /// VI: Thời gian tạo. + /// + public DateTime CreatedAt => _createdAt; + + /// + /// EN: Last update timestamp. + /// VI: Thời gian cập nhật cuối. + /// + public DateTime? UpdatedAt => _updatedAt; + + /// + /// EN: Private constructor for EF Core. + /// VI: Constructor private cho EF Core. + /// + protected ProfileAttribute() + { + } + + /// + /// EN: Create a new profile attribute. + /// VI: Tạo profile attribute mới. + /// + public ProfileAttribute( + Guid userProfileId, + string key, + string value, + ProfileAttributeType valueType) : this() + { + if (userProfileId == Guid.Empty) + throw new ArgumentException("User profile ID cannot be empty", nameof(userProfileId)); + if (string.IsNullOrWhiteSpace(key)) + throw new ArgumentException("Attribute key cannot be empty", nameof(key)); + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException("Attribute value cannot be empty", nameof(value)); + + Id = Guid.NewGuid(); + _userProfileId = userProfileId; + _key = key.Trim().ToLowerInvariant(); + _value = value; + _valueType = valueType ?? ProfileAttributeType.String; + ValueTypeId = _valueType.Id; + _createdAt = DateTime.UtcNow; + + // EN: Validate value matches type + // VI: Kiểm tra giá trị khớp với loại + ValidateValue(); + } + + /// + /// EN: Create a string attribute. + /// VI: Tạo attribute kiểu chuỗi. + /// + public static ProfileAttribute CreateString(Guid userProfileId, string key, string value) + { + return new ProfileAttribute(userProfileId, key, value, ProfileAttributeType.String); + } + + /// + /// EN: Create a number attribute. + /// VI: Tạo attribute kiểu số. + /// + public static ProfileAttribute CreateNumber(Guid userProfileId, string key, decimal value) + { + return new ProfileAttribute(userProfileId, key, value.ToString(), ProfileAttributeType.Number); + } + + /// + /// EN: Create a boolean attribute. + /// VI: Tạo attribute kiểu boolean. + /// + public static ProfileAttribute CreateBoolean(Guid userProfileId, string key, bool value) + { + return new ProfileAttribute(userProfileId, key, value.ToString().ToLowerInvariant(), ProfileAttributeType.Boolean); + } + + /// + /// EN: Create a date attribute. + /// VI: Tạo attribute kiểu ngày. + /// + public static ProfileAttribute CreateDate(Guid userProfileId, string key, DateTime value) + { + return new ProfileAttribute(userProfileId, key, value.ToString("O"), ProfileAttributeType.Date); + } + + /// + /// EN: Create a JSON attribute. + /// VI: Tạo attribute kiểu JSON. + /// + public static ProfileAttribute CreateJson(Guid userProfileId, string key, string jsonValue) + { + return new ProfileAttribute(userProfileId, key, jsonValue, ProfileAttributeType.Json); + } + + /// + /// EN: Update the attribute value. + /// VI: Cập nhật giá trị attribute. + /// + public void UpdateValue(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException("Attribute value cannot be empty", nameof(value)); + + _value = value; + _updatedAt = DateTime.UtcNow; + ValidateValue(); + } + + /// + /// EN: Update both value and type. + /// VI: Cập nhật cả giá trị và loại. + /// + public void Update(string value, ProfileAttributeType valueType) + { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException("Attribute value cannot be empty", nameof(value)); + + _value = value; + _valueType = valueType ?? ProfileAttributeType.String; + ValueTypeId = _valueType.Id; + _updatedAt = DateTime.UtcNow; + ValidateValue(); + } + + /// + /// EN: Get value as string. + /// VI: Lấy giá trị dạng chuỗi. + /// + public string GetAsString() => _value; + + /// + /// EN: Get value as number. + /// VI: Lấy giá trị dạng số. + /// + public decimal? GetAsNumber() + { + if (_valueType != ProfileAttributeType.Number) + return null; + return decimal.TryParse(_value, out var result) ? result : null; + } + + /// + /// EN: Get value as boolean. + /// VI: Lấy giá trị dạng boolean. + /// + public bool? GetAsBoolean() + { + if (_valueType != ProfileAttributeType.Boolean) + return null; + return bool.TryParse(_value, out var result) ? result : null; + } + + /// + /// EN: Get value as DateTime. + /// VI: Lấy giá trị dạng DateTime. + /// + public DateTime? GetAsDate() + { + if (_valueType != ProfileAttributeType.Date) + return null; + return DateTime.TryParse(_value, out var result) ? result : null; + } + + /// + /// EN: Validate value matches the declared type. + /// VI: Kiểm tra giá trị khớp với loại đã khai báo. + /// + private void ValidateValue() + { + var isValid = _valueType.Id switch + { + 2 => decimal.TryParse(_value, out _), // Number + 3 => bool.TryParse(_value, out _), // Boolean + 4 => DateTime.TryParse(_value, out _), // Date + 5 => IsValidJson(_value), // Json + _ => true // String - always valid + }; + + if (!isValid) + { + throw new ArgumentException( + $"Value '{_value}' is not valid for type '{_valueType.Name}'", + nameof(_value)); + } + } + + /// + /// EN: Check if string is valid JSON. + /// VI: Kiểm tra chuỗi có phải JSON hợp lệ không. + /// + private static bool IsValidJson(string value) + { + try + { + System.Text.Json.JsonDocument.Parse(value); + return true; + } + catch + { + return false; + } + } +} diff --git a/services/iam-service-net/src/IamService.Domain/AggregatesModel/UserAggregate/ProfileAttributeType.cs b/services/iam-service-net/src/IamService.Domain/AggregatesModel/UserAggregate/ProfileAttributeType.cs new file mode 100644 index 00000000..2aaf9f7f --- /dev/null +++ b/services/iam-service-net/src/IamService.Domain/AggregatesModel/UserAggregate/ProfileAttributeType.cs @@ -0,0 +1,57 @@ +using IamService.Domain.SeedWork; + +namespace IamService.Domain.AggregatesModel.UserAggregate; + +/// +/// EN: Profile attribute value type enumeration. +/// VI: Enumeration loại giá trị của profile attribute. +/// +public class ProfileAttributeType : Enumeration +{ + /// + /// EN: String value type. + /// VI: Loại giá trị chuỗi. + /// + public static readonly ProfileAttributeType String = new(1, nameof(String)); + + /// + /// EN: Number value type (integer or decimal). + /// VI: Loại giá trị số (số nguyên hoặc thập phân). + /// + public static readonly ProfileAttributeType Number = new(2, nameof(Number)); + + /// + /// EN: Boolean value type (true/false). + /// VI: Loại giá trị boolean (true/false). + /// + public static readonly ProfileAttributeType Boolean = new(3, nameof(Boolean)); + + /// + /// EN: Date/DateTime value type. + /// VI: Loại giá trị ngày/thời gian. + /// + public static readonly ProfileAttributeType Date = new(4, nameof(Date)); + + /// + /// EN: JSON object value type. + /// VI: Loại giá trị đối tượng JSON. + /// + public static readonly ProfileAttributeType Json = new(5, nameof(Json)); + + public ProfileAttributeType(int id, string name) : base(id, name) + { + } + + /// + /// EN: Get all profile attribute types. + /// VI: Lấy tất cả loại profile attribute. + /// + public static IEnumerable GetAll() => + [ + String, + Number, + Boolean, + Date, + Json + ]; +} diff --git a/services/iam-service-net/src/IamService.Domain/AggregatesModel/UserAggregate/UserProfile.cs b/services/iam-service-net/src/IamService.Domain/AggregatesModel/UserAggregate/UserProfile.cs new file mode 100644 index 00000000..f4f148bb --- /dev/null +++ b/services/iam-service-net/src/IamService.Domain/AggregatesModel/UserAggregate/UserProfile.cs @@ -0,0 +1,368 @@ +using IamService.Domain.SeedWork; + +namespace IamService.Domain.AggregatesModel.UserAggregate; + +/// +/// EN: Extended user profile with custom attributes. +/// VI: Profile user mở rộng với custom attributes. +/// +public class UserProfile : Entity +{ + // EN: Private fields for encapsulation + // VI: Fields private để đóng gói + private Guid _userId; + private string? _bio; + private string? _avatarUrl; + private PhoneNumber? _phoneNumber; + private Address? _address; + private string? _timezone; + private string? _locale; + private DateTime? _dateOfBirth; + private DateTime _createdAt; + private DateTime? _updatedAt; + + private readonly List _attributes = []; + + /// + /// EN: User ID this profile belongs to. + /// VI: ID user mà profile này thuộc về. + /// + public Guid UserId => _userId; + + /// + /// EN: User bio/about text. + /// VI: Tiểu sử/giới thiệu user. + /// + public string? Bio => _bio; + + /// + /// EN: Profile picture URL. + /// VI: URL ảnh đại diện. + /// + public string? AvatarUrl => _avatarUrl; + + /// + /// EN: User's phone number. + /// VI: Số điện thoại của user. + /// + public PhoneNumber? PhoneNumber => _phoneNumber; + + /// + /// EN: User's address. + /// VI: Địa chỉ của user. + /// + public Address? Address => _address; + + /// + /// EN: User's preferred timezone (IANA format). + /// VI: Múi giờ ưa thích của user (định dạng IANA). + /// + public string? Timezone => _timezone; + + /// + /// EN: User's preferred locale (e.g., vi-VN, en-US). + /// VI: Locale ưa thích của user (ví dụ: vi-VN, en-US). + /// + public string? Locale => _locale; + + /// + /// EN: User's date of birth. + /// VI: Ngày sinh của user. + /// + public DateTime? DateOfBirth => _dateOfBirth; + + /// + /// EN: Creation timestamp. + /// VI: Thời gian tạo. + /// + public DateTime CreatedAt => _createdAt; + + /// + /// EN: Last update timestamp. + /// VI: Thời gian cập nhật cuối. + /// + public DateTime? UpdatedAt => _updatedAt; + + /// + /// EN: Profile attributes collection. + /// VI: Collection profile attributes. + /// + public IReadOnlyCollection Attributes => _attributes.AsReadOnly(); + + /// + /// EN: Private constructor for EF Core. + /// VI: Constructor private cho EF Core. + /// + protected UserProfile() + { + } + + /// + /// EN: Create a new user profile. + /// VI: Tạo profile user mới. + /// + public UserProfile(Guid userId) : this() + { + if (userId == Guid.Empty) + throw new ArgumentException("User ID cannot be empty", nameof(userId)); + + Id = Guid.NewGuid(); + _userId = userId; + _createdAt = DateTime.UtcNow; + } + + /// + /// EN: Factory method to create profile. + /// VI: Factory method để tạo profile. + /// + public static UserProfile Create(Guid userId) + { + return new UserProfile(userId); + } + + /// + /// EN: Update basic profile information. + /// VI: Cập nhật thông tin profile cơ bản. + /// + public void UpdateBasicInfo(string? bio, string? timezone, string? locale) + { + _bio = bio?.Trim(); + _timezone = timezone?.Trim(); + _locale = locale?.Trim(); + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Set avatar URL. + /// VI: Đặt URL ảnh đại diện. + /// + public void SetAvatar(string? avatarUrl) + { + _avatarUrl = avatarUrl?.Trim(); + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Set phone number. + /// VI: Đặt số điện thoại. + /// + public void SetPhoneNumber(PhoneNumber? phoneNumber) + { + _phoneNumber = phoneNumber; + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Set address. + /// VI: Đặt địa chỉ. + /// + public void SetAddress(Address? address) + { + _address = address; + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Set date of birth. + /// VI: Đặt ngày sinh. + /// + public void SetDateOfBirth(DateTime? dateOfBirth) + { + if (dateOfBirth.HasValue && dateOfBirth.Value > DateTime.UtcNow) + throw new ArgumentException("Date of birth cannot be in the future", nameof(dateOfBirth)); + + _dateOfBirth = dateOfBirth; + _updatedAt = DateTime.UtcNow; + } + + #region Attribute Management + + /// + /// EN: Set a string attribute. + /// VI: Đặt attribute kiểu chuỗi. + /// + public ProfileAttribute SetAttribute(string key, string value) + { + var existing = GetAttributeEntity(key); + if (existing != null) + { + existing.Update(value, ProfileAttributeType.String); + _updatedAt = DateTime.UtcNow; + return existing; + } + + var attribute = ProfileAttribute.CreateString(Id, key, value); + _attributes.Add(attribute); + _updatedAt = DateTime.UtcNow; + return attribute; + } + + /// + /// EN: Set a number attribute. + /// VI: Đặt attribute kiểu số. + /// + public ProfileAttribute SetAttribute(string key, decimal value) + { + var existing = GetAttributeEntity(key); + if (existing != null) + { + existing.Update(value.ToString(), ProfileAttributeType.Number); + _updatedAt = DateTime.UtcNow; + return existing; + } + + var attribute = ProfileAttribute.CreateNumber(Id, key, value); + _attributes.Add(attribute); + _updatedAt = DateTime.UtcNow; + return attribute; + } + + /// + /// EN: Set a boolean attribute. + /// VI: Đặt attribute kiểu boolean. + /// + public ProfileAttribute SetAttribute(string key, bool value) + { + var existing = GetAttributeEntity(key); + if (existing != null) + { + existing.Update(value.ToString().ToLowerInvariant(), ProfileAttributeType.Boolean); + _updatedAt = DateTime.UtcNow; + return existing; + } + + var attribute = ProfileAttribute.CreateBoolean(Id, key, value); + _attributes.Add(attribute); + _updatedAt = DateTime.UtcNow; + return attribute; + } + + /// + /// EN: Set a date attribute. + /// VI: Đặt attribute kiểu ngày. + /// + public ProfileAttribute SetAttribute(string key, DateTime value) + { + var existing = GetAttributeEntity(key); + if (existing != null) + { + existing.Update(value.ToString("O"), ProfileAttributeType.Date); + _updatedAt = DateTime.UtcNow; + return existing; + } + + var attribute = ProfileAttribute.CreateDate(Id, key, value); + _attributes.Add(attribute); + _updatedAt = DateTime.UtcNow; + return attribute; + } + + /// + /// EN: Set a JSON attribute. + /// VI: Đặt attribute kiểu JSON. + /// + public ProfileAttribute SetJsonAttribute(string key, string jsonValue) + { + var existing = GetAttributeEntity(key); + if (existing != null) + { + existing.Update(jsonValue, ProfileAttributeType.Json); + _updatedAt = DateTime.UtcNow; + return existing; + } + + var attribute = ProfileAttribute.CreateJson(Id, key, jsonValue); + _attributes.Add(attribute); + _updatedAt = DateTime.UtcNow; + return attribute; + } + + /// + /// EN: Remove an attribute by key. + /// VI: Xóa attribute theo key. + /// + public bool RemoveAttribute(string key) + { + var attribute = GetAttributeEntity(key); + if (attribute == null) + return false; + + _attributes.Remove(attribute); + _updatedAt = DateTime.UtcNow; + return true; + } + + /// + /// EN: Get attribute value as string. + /// VI: Lấy giá trị attribute dạng chuỗi. + /// + public string? GetAttributeValue(string key) + { + return GetAttributeEntity(key)?.GetAsString(); + } + + /// + /// EN: Get attribute value as number. + /// VI: Lấy giá trị attribute dạng số. + /// + public decimal? GetAttributeAsNumber(string key) + { + return GetAttributeEntity(key)?.GetAsNumber(); + } + + /// + /// EN: Get attribute value as boolean. + /// VI: Lấy giá trị attribute dạng boolean. + /// + public bool? GetAttributeAsBoolean(string key) + { + return GetAttributeEntity(key)?.GetAsBoolean(); + } + + /// + /// EN: Get attribute value as DateTime. + /// VI: Lấy giá trị attribute dạng DateTime. + /// + public DateTime? GetAttributeAsDate(string key) + { + return GetAttributeEntity(key)?.GetAsDate(); + } + + /// + /// EN: Check if attribute exists. + /// VI: Kiểm tra attribute có tồn tại không. + /// + public bool HasAttribute(string key) + { + return GetAttributeEntity(key) != null; + } + + /// + /// EN: Get attribute entity by key. + /// VI: Lấy entity attribute theo key. + /// + private ProfileAttribute? GetAttributeEntity(string key) + { + return _attributes.FirstOrDefault(a => a.Key == key.ToLowerInvariant().Trim()); + } + + #endregion + + /// + /// EN: Calculate user's age. + /// VI: Tính tuổi của user. + /// + public int? GetAge() + { + if (!_dateOfBirth.HasValue) + return null; + + var today = DateTime.UtcNow; + var age = today.Year - _dateOfBirth.Value.Year; + + if (_dateOfBirth.Value.Date > today.AddYears(-age)) + age--; + + return age; + } +} diff --git a/services/iam-service-net/src/IamService.Domain/AggregatesModel/VerificationAggregate/IIdentityVerificationRepository.cs b/services/iam-service-net/src/IamService.Domain/AggregatesModel/VerificationAggregate/IIdentityVerificationRepository.cs new file mode 100644 index 00000000..ff1886f3 --- /dev/null +++ b/services/iam-service-net/src/IamService.Domain/AggregatesModel/VerificationAggregate/IIdentityVerificationRepository.cs @@ -0,0 +1,64 @@ +using IamService.Domain.SeedWork; + +namespace IamService.Domain.AggregatesModel.VerificationAggregate; + +/// +/// EN: Interface for IdentityVerification repository. +/// VI: Interface cho repository IdentityVerification. +/// +public interface IIdentityVerificationRepository : IRepository +{ + /// + /// EN: Get verification by ID. + /// VI: Lấy xác thực theo ID. + /// + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + + /// + /// EN: Get all verifications for a user. + /// VI: Lấy tất cả xác thực của một user. + /// + Task> GetByUserIdAsync(Guid userId, CancellationToken cancellationToken = default); + + /// + /// EN: Get active verification for a user by type. + /// VI: Lấy xác thực đang hoạt động của user theo loại. + /// + Task GetActiveByUserIdAndTypeAsync( + Guid userId, + VerificationType type, + CancellationToken cancellationToken = default); + + /// + /// EN: Get latest verification for a user by type. + /// VI: Lấy xác thực mới nhất của user theo loại. + /// + Task GetLatestByUserIdAndTypeAsync( + Guid userId, + VerificationType type, + CancellationToken cancellationToken = default); + + /// + /// EN: Check if user has verified a specific type. + /// VI: Kiểm tra user đã xác thực loại cụ thể chưa. + /// + Task HasVerifiedAsync(Guid userId, VerificationType type, CancellationToken cancellationToken = default); + + /// + /// EN: Get pending verifications that have expired. + /// VI: Lấy các xác thực đang chờ đã hết hạn. + /// + Task> GetExpiredPendingAsync(CancellationToken cancellationToken = default); + + /// + /// EN: Add a new verification. + /// VI: Thêm xác thực mới. + /// + IdentityVerification Add(IdentityVerification verification); + + /// + /// EN: Update an existing verification. + /// VI: Cập nhật xác thực hiện có. + /// + void Update(IdentityVerification verification); +} diff --git a/services/iam-service-net/src/IamService.Domain/AggregatesModel/VerificationAggregate/IdentityVerification.cs b/services/iam-service-net/src/IamService.Domain/AggregatesModel/VerificationAggregate/IdentityVerification.cs new file mode 100644 index 00000000..fdab75dc --- /dev/null +++ b/services/iam-service-net/src/IamService.Domain/AggregatesModel/VerificationAggregate/IdentityVerification.cs @@ -0,0 +1,357 @@ +using System.Security.Cryptography; +using IamService.Domain.Events; +using IamService.Domain.SeedWork; + +namespace IamService.Domain.AggregatesModel.VerificationAggregate; + +/// +/// EN: Identity verification aggregate root for user verification (email, phone, document). +/// VI: Aggregate root xác thực danh tính cho user (email, phone, tài liệu). +/// +public class IdentityVerification : Entity, IAggregateRoot +{ + // EN: Constants for verification + // VI: Hằng số cho xác thực + private const int MaxAttempts = 5; + private const int OtpLength = 6; + private const int OtpExpirationMinutes = 10; + private const int DocumentExpirationDays = 7; + + // EN: Private fields for encapsulation + // VI: Fields private để đóng gói + private Guid _userId; + private VerificationType _type = null!; + private VerificationStatus _status = null!; + private string? _verificationData; // EN: Encrypted data (phone number, document URL) + private string? _verificationCodeHash; // EN: Hashed OTP for phone verification + private DateTime _requestedAt; + private DateTime? _verifiedAt; + private DateTime _expiresAt; + private int _attemptCount; + private string? _rejectionReason; + private string? _metadata; // EN: JSON metadata + + + + /// + /// EN: User ID this verification belongs to. + /// VI: ID user mà xác thực này thuộc về. + /// + public Guid UserId => _userId; + + /// + /// EN: Type of verification. + /// VI: Loại xác thực. + /// + public VerificationType Type => _type; + + /// + /// EN: Type ID for EF Core mapping. + /// VI: ID loại cho EF Core mapping. + /// + public int TypeId { get; private set; } + + /// + /// EN: Current status of verification. + /// VI: Trạng thái hiện tại của xác thực. + /// + public VerificationStatus Status => _status; + + /// + /// EN: Status ID for EF Core mapping. + /// VI: ID trạng thái cho EF Core mapping. + /// + public int StatusId { get; private set; } + + /// + /// EN: Verification data (encrypted). + /// VI: Dữ liệu xác thực (đã mã hóa). + /// + public string? VerificationData => _verificationData; + + /// + /// EN: Hashed verification code. + /// VI: Mã xác thực đã hash. + /// + public string? VerificationCodeHash => _verificationCodeHash; + + /// + /// EN: When verification was requested. + /// VI: Thời điểm yêu cầu xác thực. + /// + public DateTime RequestedAt => _requestedAt; + + /// + /// EN: When verification was completed. + /// VI: Thời điểm xác thực hoàn thành. + /// + public DateTime? VerifiedAt => _verifiedAt; + + /// + /// EN: When verification expires. + /// VI: Thời điểm xác thực hết hạn. + /// + public DateTime ExpiresAt => _expiresAt; + + /// + /// EN: Number of verification attempts. + /// VI: Số lần thử xác thực. + /// + public int AttemptCount => _attemptCount; + + /// + /// EN: Reason for rejection (if rejected). + /// VI: Lý do từ chối (nếu bị từ chối). + /// + public string? RejectionReason => _rejectionReason; + + /// + /// EN: Additional metadata in JSON format. + /// VI: Metadata bổ sung dạng JSON. + /// + public string? Metadata => _metadata; + + + /// + /// EN: Private constructor for EF Core. + /// VI: Constructor private cho EF Core. + /// + protected IdentityVerification() + { + } + + /// + /// EN: Create a new identity verification. + /// VI: Tạo xác thực danh tính mới. + /// + private IdentityVerification( + Guid userId, + VerificationType type, + string? verificationData, + int expirationMinutes) : this() + { + if (userId == Guid.Empty) + throw new ArgumentException("User ID cannot be empty", nameof(userId)); + + Id = Guid.NewGuid(); + _userId = userId; + _type = type ?? throw new ArgumentNullException(nameof(type)); + TypeId = type.Id; + _status = VerificationStatus.Pending; + StatusId = VerificationStatus.Pending.Id; + _verificationData = verificationData; + _requestedAt = DateTime.UtcNow; + _expiresAt = DateTime.UtcNow.AddMinutes(expirationMinutes); + _attemptCount = 0; + } + + /// + /// EN: Create phone verification with OTP. + /// VI: Tạo xác thực điện thoại với OTP. + /// + /// Tuple of (IdentityVerification, plainTextOtp) + public static (IdentityVerification Verification, string Otp) CreatePhoneVerification( + Guid userId, + string phoneNumber) + { + if (string.IsNullOrWhiteSpace(phoneNumber)) + throw new ArgumentException("Phone number cannot be empty", nameof(phoneNumber)); + + var verification = new IdentityVerification( + userId, + VerificationType.Phone, + phoneNumber, + OtpExpirationMinutes); + + // EN: Generate and hash OTP + // VI: Tạo và hash OTP + var otp = GenerateOtp(); + verification._verificationCodeHash = HashOtp(otp); + + verification.AddDomainEvent(new VerificationRequestedEvent( + verification.Id, + userId, + VerificationType.Phone.Name)); + + return (verification, otp); + } + + /// + /// EN: Create document verification for KYC. + /// VI: Tạo xác thực tài liệu cho KYC. + /// + public static IdentityVerification CreateDocumentVerification( + Guid userId, + string documentUrl, + string? documentType = null) + { + if (string.IsNullOrWhiteSpace(documentUrl)) + throw new ArgumentException("Document URL cannot be empty", nameof(documentUrl)); + + var verification = new IdentityVerification( + userId, + VerificationType.Document, + documentUrl, + DocumentExpirationDays * 24 * 60); + + verification._status = VerificationStatus.InProgress; + verification.StatusId = VerificationStatus.InProgress.Id; + + if (!string.IsNullOrEmpty(documentType)) + { + verification._metadata = System.Text.Json.JsonSerializer.Serialize(new + { + DocumentType = documentType, + SubmittedAt = DateTime.UtcNow + }); + } + + verification.AddDomainEvent(new VerificationRequestedEvent( + verification.Id, + userId, + VerificationType.Document.Name)); + + return verification; + } + + /// + /// EN: Verify OTP code. + /// VI: Xác minh mã OTP. + /// + public bool VerifyCode(string code) + { + if (_status.IsFinal) + throw new InvalidOperationException($"Verification is already in final status: {_status.Name}"); + + if (DateTime.UtcNow > _expiresAt) + { + MarkAsExpired(); + return false; + } + + _attemptCount++; + + if (_attemptCount > MaxAttempts) + { + _status = VerificationStatus.Rejected; + StatusId = VerificationStatus.Rejected.Id; + _rejectionReason = "Maximum attempts exceeded"; + return false; + } + + var isValid = VerifyOtpHash(code, _verificationCodeHash); + + if (isValid) + { + MarkAsVerified(); + } + + return isValid; + } + + /// + /// EN: Mark verification as verified. + /// VI: Đánh dấu xác thực hoàn thành. + /// + public void MarkAsVerified() + { + if (_status.IsFinal) + throw new InvalidOperationException($"Verification is already in final status: {_status.Name}"); + + _status = VerificationStatus.Verified; + StatusId = VerificationStatus.Verified.Id; + _verifiedAt = DateTime.UtcNow; + + AddDomainEvent(new VerificationCompletedEvent(Id, _userId, _type.Name, true)); + } + + /// + /// EN: Mark verification as rejected. + /// VI: Đánh dấu xác thực bị từ chối. + /// + public void MarkAsRejected(string reason) + { + if (_status.IsFinal) + throw new InvalidOperationException($"Verification is already in final status: {_status.Name}"); + + _status = VerificationStatus.Rejected; + StatusId = VerificationStatus.Rejected.Id; + _rejectionReason = reason; + + AddDomainEvent(new VerificationCompletedEvent(Id, _userId, _type.Name, false, reason)); + } + + /// + /// EN: Mark verification as expired. + /// VI: Đánh dấu xác thực hết hạn. + /// + public void MarkAsExpired() + { + if (_status.IsFinal) + return; + + _status = VerificationStatus.Expired; + StatusId = VerificationStatus.Expired.Id; + } + + /// + /// EN: Cancel the verification. + /// VI: Hủy xác thực. + /// + public void Cancel() + { + if (_status.IsFinal) + throw new InvalidOperationException($"Verification is already in final status: {_status.Name}"); + + _status = VerificationStatus.Cancelled; + StatusId = VerificationStatus.Cancelled.Id; + } + + /// + /// EN: Check if verification is expired. + /// VI: Kiểm tra xác thực đã hết hạn chưa. + /// + public bool IsExpired => DateTime.UtcNow > _expiresAt; + + /// + /// EN: Check if can retry verification. + /// VI: Kiểm tra có thể thử lại xác thực không. + /// + public bool CanRetry => _attemptCount < MaxAttempts && !_status.IsFinal && !IsExpired; + + + /// + /// EN: Generate a random OTP. + /// VI: Tạo OTP ngẫu nhiên. + /// + private static string GenerateOtp() + { + var bytes = RandomNumberGenerator.GetBytes(4); + var number = BitConverter.ToUInt32(bytes, 0) % (uint)Math.Pow(10, OtpLength); + return number.ToString().PadLeft(OtpLength, '0'); + } + + /// + /// EN: Hash OTP for storage. + /// VI: Hash OTP để lưu trữ. + /// + private static string HashOtp(string otp) + { + var bytes = System.Text.Encoding.UTF8.GetBytes(otp); + var hash = SHA256.HashData(bytes); + return Convert.ToBase64String(hash); + } + + /// + /// EN: Verify OTP against hash. + /// VI: Xác minh OTP với hash. + /// + private static bool VerifyOtpHash(string otp, string? hash) + { + if (string.IsNullOrEmpty(hash)) + return false; + + var computedHash = HashOtp(otp); + return computedHash == hash; + } +} diff --git a/services/iam-service-net/src/IamService.Domain/AggregatesModel/VerificationAggregate/VerificationStatus.cs b/services/iam-service-net/src/IamService.Domain/AggregatesModel/VerificationAggregate/VerificationStatus.cs new file mode 100644 index 00000000..26504cb2 --- /dev/null +++ b/services/iam-service-net/src/IamService.Domain/AggregatesModel/VerificationAggregate/VerificationStatus.cs @@ -0,0 +1,70 @@ +using IamService.Domain.SeedWork; + +namespace IamService.Domain.AggregatesModel.VerificationAggregate; + +/// +/// EN: Verification status enumeration. +/// VI: Enumeration trạng thái xác thực. +/// +public class VerificationStatus : Enumeration +{ + /// + /// EN: Verification is pending (awaiting user action). + /// VI: Xác thực đang chờ (đợi user hành động). + /// + public static readonly VerificationStatus Pending = new(1, nameof(Pending)); + + /// + /// EN: Verification is in progress (e.g., document under review). + /// VI: Xác thực đang xử lý (ví dụ: tài liệu đang được xem xét). + /// + public static readonly VerificationStatus InProgress = new(2, nameof(InProgress)); + + /// + /// EN: Verification completed successfully. + /// VI: Xác thực hoàn thành thành công. + /// + public static readonly VerificationStatus Verified = new(3, nameof(Verified)); + + /// + /// EN: Verification was rejected. + /// VI: Xác thực bị từ chối. + /// + public static readonly VerificationStatus Rejected = new(4, nameof(Rejected)); + + /// + /// EN: Verification expired. + /// VI: Xác thực đã hết hạn. + /// + public static readonly VerificationStatus Expired = new(5, nameof(Expired)); + + /// + /// EN: Verification was cancelled. + /// VI: Xác thực đã bị hủy. + /// + public static readonly VerificationStatus Cancelled = new(6, nameof(Cancelled)); + + public VerificationStatus(int id, string name) : base(id, name) + { + } + + /// + /// EN: Get all verification statuses. + /// VI: Lấy tất cả trạng thái xác thực. + /// + public static IEnumerable GetAll() => + [ + Pending, + InProgress, + Verified, + Rejected, + Expired, + Cancelled + ]; + + /// + /// EN: Check if this status is a final status. + /// VI: Kiểm tra trạng thái này có phải là trạng thái cuối cùng không. + /// + public bool IsFinal => this == Verified || this == Rejected || this == Expired || this == Cancelled; +} diff --git a/services/iam-service-net/src/IamService.Domain/AggregatesModel/VerificationAggregate/VerificationType.cs b/services/iam-service-net/src/IamService.Domain/AggregatesModel/VerificationAggregate/VerificationType.cs new file mode 100644 index 00000000..bbd95ecd --- /dev/null +++ b/services/iam-service-net/src/IamService.Domain/AggregatesModel/VerificationAggregate/VerificationType.cs @@ -0,0 +1,50 @@ +using IamService.Domain.SeedWork; + +namespace IamService.Domain.AggregatesModel.VerificationAggregate; + +/// +/// EN: Verification type enumeration. +/// VI: Enumeration loại xác thực. +/// +public class VerificationType : Enumeration +{ + /// + /// EN: Email verification. + /// VI: Xác thực email. + /// + public static readonly VerificationType Email = new(1, nameof(Email)); + + /// + /// EN: Phone/SMS verification. + /// VI: Xác thực điện thoại/SMS. + /// + public static readonly VerificationType Phone = new(2, nameof(Phone)); + + /// + /// EN: Document verification (KYC). + /// VI: Xác thực tài liệu (KYC). + /// + public static readonly VerificationType Document = new(3, nameof(Document)); + + /// + /// EN: Identity verification. + /// VI: Xác thực danh tính. + /// + public static readonly VerificationType Identity = new(4, nameof(Identity)); + + public VerificationType(int id, string name) : base(id, name) + { + } + + /// + /// EN: Get all verification types. + /// VI: Lấy tất cả loại xác thực. + /// + public static IEnumerable GetAll() => + [ + Email, + Phone, + Document, + Identity + ]; +} diff --git a/services/iam-service-net/src/IamService.Domain/Events/GroupEvents.cs b/services/iam-service-net/src/IamService.Domain/Events/GroupEvents.cs new file mode 100644 index 00000000..f40cffc8 --- /dev/null +++ b/services/iam-service-net/src/IamService.Domain/Events/GroupEvents.cs @@ -0,0 +1,61 @@ +using IamService.Domain.SeedWork; + +namespace IamService.Domain.Events; + +/// +/// EN: Domain event raised when a new group is created. +/// VI: Domain event được raise khi nhóm mới được tạo. +/// +public class GroupCreatedEvent : IDomainEvent +{ + public Guid GroupId { get; } + public Guid OrganizationId { get; } + public string Name { get; } + public DateTime OccurredOn { get; } + + public GroupCreatedEvent(Guid groupId, Guid organizationId, string name) + { + GroupId = groupId; + OrganizationId = organizationId; + Name = name; + OccurredOn = DateTime.UtcNow; + } +} + +/// +/// EN: Domain event raised when a member is added to a group. +/// VI: Domain event được raise khi thành viên được thêm vào nhóm. +/// +public class MemberAddedToGroupEvent : IDomainEvent +{ + public Guid GroupId { get; } + public Guid UserId { get; } + public string Role { get; } + public DateTime OccurredOn { get; } + + public MemberAddedToGroupEvent(Guid groupId, Guid userId, string role) + { + GroupId = groupId; + UserId = userId; + Role = role; + OccurredOn = DateTime.UtcNow; + } +} + +/// +/// EN: Domain event raised when a member is removed from a group. +/// VI: Domain event được raise khi thành viên bị xóa khỏi nhóm. +/// +public class MemberRemovedFromGroupEvent : IDomainEvent +{ + public Guid GroupId { get; } + public Guid UserId { get; } + public DateTime OccurredOn { get; } + + public MemberRemovedFromGroupEvent(Guid groupId, Guid userId) + { + GroupId = groupId; + UserId = userId; + OccurredOn = DateTime.UtcNow; + } +} diff --git a/services/iam-service-net/src/IamService.Domain/Events/OrganizationCreatedEvent.cs b/services/iam-service-net/src/IamService.Domain/Events/OrganizationCreatedEvent.cs new file mode 100644 index 00000000..85480723 --- /dev/null +++ b/services/iam-service-net/src/IamService.Domain/Events/OrganizationCreatedEvent.cs @@ -0,0 +1,42 @@ +using IamService.Domain.SeedWork; + +namespace IamService.Domain.Events; + +/// +/// EN: Domain event raised when a new organization is created. +/// VI: Domain event được raise khi tổ chức mới được tạo. +/// +public class OrganizationCreatedEvent : IDomainEvent +{ + /// + /// EN: The organization ID. + /// VI: ID tổ chức. + /// + public Guid OrganizationId { get; } + + /// + /// EN: The organization name. + /// VI: Tên tổ chức. + /// + public string Name { get; } + + /// + /// EN: The organization slug. + /// VI: Slug tổ chức. + /// + public string Slug { get; } + + /// + /// EN: When the event occurred. + /// VI: Thời điểm sự kiện xảy ra. + /// + public DateTime OccurredOn { get; } + + public OrganizationCreatedEvent(Guid organizationId, string name, string slug) + { + OrganizationId = organizationId; + Name = name; + Slug = slug; + OccurredOn = DateTime.UtcNow; + } +} diff --git a/services/iam-service-net/src/IamService.Domain/Events/OrganizationUpdatedEvent.cs b/services/iam-service-net/src/IamService.Domain/Events/OrganizationUpdatedEvent.cs new file mode 100644 index 00000000..8244f303 --- /dev/null +++ b/services/iam-service-net/src/IamService.Domain/Events/OrganizationUpdatedEvent.cs @@ -0,0 +1,35 @@ +using IamService.Domain.SeedWork; + +namespace IamService.Domain.Events; + +/// +/// EN: Domain event raised when an organization is updated. +/// VI: Domain event được raise khi tổ chức được cập nhật. +/// +public class OrganizationUpdatedEvent : IDomainEvent +{ + /// + /// EN: The organization ID. + /// VI: ID tổ chức. + /// + public Guid OrganizationId { get; } + + /// + /// EN: The organization name. + /// VI: Tên tổ chức. + /// + public string Name { get; } + + /// + /// EN: When the event occurred. + /// VI: Thời điểm sự kiện xảy ra. + /// + public DateTime OccurredOn { get; } + + public OrganizationUpdatedEvent(Guid organizationId, string name) + { + OrganizationId = organizationId; + Name = name; + OccurredOn = DateTime.UtcNow; + } +} diff --git a/services/iam-service-net/src/IamService.Domain/Events/VerificationEvents.cs b/services/iam-service-net/src/IamService.Domain/Events/VerificationEvents.cs new file mode 100644 index 00000000..d9e796c5 --- /dev/null +++ b/services/iam-service-net/src/IamService.Domain/Events/VerificationEvents.cs @@ -0,0 +1,52 @@ +using IamService.Domain.SeedWork; + +namespace IamService.Domain.Events; + +/// +/// EN: Domain event raised when verification is requested. +/// VI: Domain event được raise khi yêu cầu xác thực. +/// +public class VerificationRequestedEvent : IDomainEvent +{ + public Guid VerificationId { get; } + public Guid UserId { get; } + public string VerificationType { get; } + public DateTime OccurredOn { get; } + + public VerificationRequestedEvent(Guid verificationId, Guid userId, string verificationType) + { + VerificationId = verificationId; + UserId = userId; + VerificationType = verificationType; + OccurredOn = DateTime.UtcNow; + } +} + +/// +/// EN: Domain event raised when verification is completed. +/// VI: Domain event được raise khi xác thực hoàn thành. +/// +public class VerificationCompletedEvent : IDomainEvent +{ + public Guid VerificationId { get; } + public Guid UserId { get; } + public string VerificationType { get; } + public bool IsSuccess { get; } + public string? FailureReason { get; } + public DateTime OccurredOn { get; } + + public VerificationCompletedEvent( + Guid verificationId, + Guid userId, + string verificationType, + bool isSuccess, + string? failureReason = null) + { + VerificationId = verificationId; + UserId = userId; + VerificationType = verificationType; + IsSuccess = isSuccess; + FailureReason = failureReason; + OccurredOn = DateTime.UtcNow; + } +} diff --git a/services/iam-service-net/src/IamService.Infrastructure/DependencyInjection.cs b/services/iam-service-net/src/IamService.Infrastructure/DependencyInjection.cs index 2e33949f..4338dbaa 100644 --- a/services/iam-service-net/src/IamService.Infrastructure/DependencyInjection.cs +++ b/services/iam-service-net/src/IamService.Infrastructure/DependencyInjection.cs @@ -7,6 +7,9 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using IamService.Domain.AggregatesModel.UserAggregate; using IamService.Domain.AggregatesModel.RoleAggregate; +using IamService.Domain.AggregatesModel.OrganizationAggregate; +using IamService.Domain.AggregatesModel.GroupAggregate; +using IamService.Domain.AggregatesModel.VerificationAggregate; using IamService.Domain.SeedWork; using IamService.Infrastructure.Email; using IamService.Infrastructure.IdentityServer; @@ -147,6 +150,9 @@ public static class DependencyInjection // VI: Đăng ký repositories services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(sp => sp.GetRequiredService()); // EN: Configure Redis caching (skip in Testing environment) diff --git a/services/iam-service-net/src/IamService.Infrastructure/EntityConfigurations/GroupEntityConfiguration.cs b/services/iam-service-net/src/IamService.Infrastructure/EntityConfigurations/GroupEntityConfiguration.cs new file mode 100644 index 00000000..1cdedcca --- /dev/null +++ b/services/iam-service-net/src/IamService.Infrastructure/EntityConfigurations/GroupEntityConfiguration.cs @@ -0,0 +1,213 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using IamService.Domain.AggregatesModel.GroupAggregate; + +namespace IamService.Infrastructure.EntityConfigurations; + +/// +/// EN: Entity configuration for Group. +/// VI: Cấu hình entity cho Group. +/// +public class GroupEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("groups"); + + builder.HasKey(g => g.Id); + + builder.Property(g => g.Id) + .HasColumnName("id") + .ValueGeneratedNever(); + + builder.Property("_name") + .HasColumnName("name") + .HasMaxLength(200) + .IsRequired(); + + builder.Property("_description") + .HasColumnName("description") + .HasMaxLength(1000); + + builder.Property("_organizationId") + .HasColumnName("organization_id") + .IsRequired(); + + builder.Property("_createdAt") + .HasColumnName("created_at") + .IsRequired(); + + builder.Property("_updatedAt") + .HasColumnName("updated_at"); + + builder.Property("_isDeleted") + .HasColumnName("is_deleted") + .HasDefaultValue(false); + + // EN: Index for organization lookup + // VI: Index để tìm kiếm theo organization + builder.HasIndex("_organizationId") + .HasDatabaseName("ix_groups_organization_id"); + + // EN: Query filter for soft delete + // VI: Query filter cho soft delete + builder.HasQueryFilter(g => !g.IsDeleted); + + // EN: Configure navigation to members + // VI: Cấu hình navigation đến members + builder.HasMany(g => g.Members) + .WithOne() + .HasForeignKey("_groupId") + .OnDelete(DeleteBehavior.Cascade); + + // EN: Configure navigation to permissions + // VI: Cấu hình navigation đến permissions + builder.HasMany(g => g.Permissions) + .WithOne() + .HasForeignKey("_groupId") + .OnDelete(DeleteBehavior.Cascade); + + // EN: Ignore domain events + // VI: Bỏ qua domain events + builder.Ignore(g => g.DomainEvents); + } +} + +/// +/// EN: Entity configuration for GroupMember. +/// VI: Cấu hình entity cho GroupMember. +/// +public class GroupMemberEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("group_members"); + + builder.HasKey(m => m.Id); + + builder.Property(m => m.Id) + .HasColumnName("id") + .ValueGeneratedNever(); + + builder.Property("_userId") + .HasColumnName("user_id") + .IsRequired(); + + builder.Property("_groupId") + .HasColumnName("group_id") + .IsRequired(); + + builder.Property(m => m.RoleId) + .HasColumnName("role_id") + .IsRequired(); + + builder.Property("_joinedAt") + .HasColumnName("joined_at") + .IsRequired(); + + builder.Property("_addedByUserId") + .HasColumnName("added_by_user_id"); + + // EN: Unique index to prevent duplicate memberships + // VI: Unique index để ngăn thành viên trùng lặp + builder.HasIndex("_groupId", "_userId") + .IsUnique() + .HasDatabaseName("ix_group_members_group_user"); + + // EN: Index for user lookup + // VI: Index để tìm kiếm theo user + builder.HasIndex("_userId") + .HasDatabaseName("ix_group_members_user_id"); + + // EN: Ignore expression-bodied properties (use backing fields instead) + // VI: Bỏ qua properties expression-bodied (sử dụng backing fields thay thế) + builder.Ignore(m => m.UserId); + builder.Ignore(m => m.GroupId); + builder.Ignore(m => m.JoinedAt); + builder.Ignore(m => m.AddedByUserId); + builder.Ignore(m => m.Role); + } +} + +/// +/// EN: Entity configuration for GroupPermission. +/// VI: Cấu hình entity cho GroupPermission. +/// +public class GroupPermissionEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("group_permissions"); + + builder.HasKey(p => p.Id); + + builder.Property(p => p.Id) + .HasColumnName("id") + .ValueGeneratedNever(); + + builder.Property("_groupId") + .HasColumnName("group_id") + .IsRequired(); + + builder.Property("_permission") + .HasColumnName("permission") + .HasMaxLength(100) + .IsRequired(); + + builder.Property("_resource") + .HasColumnName("resource") + .HasMaxLength(500); + + builder.Property("_grantedAt") + .HasColumnName("granted_at") + .IsRequired(); + + builder.Property("_grantedByUserId") + .HasColumnName("granted_by_user_id"); + + // EN: Index for permission lookup + // VI: Index để tìm kiếm permission + builder.HasIndex("_groupId", "_permission", "_resource") + .IsUnique() + .HasDatabaseName("ix_group_permissions_unique"); + + // EN: Ignore expression-bodied properties (use backing fields instead) + // VI: Bỏ qua properties expression-bodied (sử dụng backing fields thay thế) + builder.Ignore(p => p.GroupId); + builder.Ignore(p => p.Permission); + builder.Ignore(p => p.Resource); + builder.Ignore(p => p.GrantedAt); + builder.Ignore(p => p.GrantedByUserId); + } +} + +/// +/// EN: Entity configuration for GroupRole. +/// VI: Cấu hình entity cho GroupRole. +/// +public class GroupRoleEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("group_roles"); + + builder.HasKey(r => r.Id); + + builder.Property(r => r.Id) + .HasColumnName("id") + .ValueGeneratedNever(); + + builder.Property(r => r.Name) + .HasColumnName("name") + .HasMaxLength(50) + .IsRequired(); + + // EN: Seed data + // VI: Seed data + builder.HasData( + GroupRole.Member, + GroupRole.Admin, + GroupRole.Owner + ); + } +} diff --git a/services/iam-service-net/src/IamService.Infrastructure/EntityConfigurations/IdentityVerificationEntityConfiguration.cs b/services/iam-service-net/src/IamService.Infrastructure/EntityConfigurations/IdentityVerificationEntityConfiguration.cs new file mode 100644 index 00000000..cbf38be6 --- /dev/null +++ b/services/iam-service-net/src/IamService.Infrastructure/EntityConfigurations/IdentityVerificationEntityConfiguration.cs @@ -0,0 +1,146 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using IamService.Domain.AggregatesModel.VerificationAggregate; + +namespace IamService.Infrastructure.EntityConfigurations; + +/// +/// EN: Entity configuration for IdentityVerification. +/// VI: Cấu hình entity cho IdentityVerification. +/// +public class IdentityVerificationEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("identity_verifications"); + + builder.HasKey(v => v.Id); + + builder.Property(v => v.Id) + .HasColumnName("id") + .ValueGeneratedNever(); + + builder.Property("_userId") + .HasColumnName("user_id") + .IsRequired(); + + builder.Property(v => v.TypeId) + .HasColumnName("type_id") + .IsRequired(); + + builder.Property(v => v.StatusId) + .HasColumnName("status_id") + .IsRequired(); + + builder.Property("_verificationData") + .HasColumnName("verification_data") + .HasMaxLength(1000); + + builder.Property("_verificationCodeHash") + .HasColumnName("verification_code_hash") + .HasMaxLength(100); + + builder.Property("_requestedAt") + .HasColumnName("requested_at") + .IsRequired(); + + builder.Property("_verifiedAt") + .HasColumnName("verified_at"); + + builder.Property("_expiresAt") + .HasColumnName("expires_at") + .IsRequired(); + + builder.Property("_attemptCount") + .HasColumnName("attempt_count") + .HasDefaultValue(0); + + builder.Property("_rejectionReason") + .HasColumnName("rejection_reason") + .HasMaxLength(500); + + builder.Property("_metadata") + .HasColumnName("metadata") + .HasColumnType("jsonb"); + + // EN: Index for user verifications lookup + // VI: Index để tìm kiếm verifications theo user + builder.HasIndex("_userId") + .HasDatabaseName("ix_identity_verifications_user_id"); + + // EN: Composite index for checking active verification + // VI: Composite index để kiểm tra verification đang hoạt động + builder.HasIndex("_userId", "TypeId", "StatusId") + .HasDatabaseName("ix_identity_verifications_user_type_status"); + + // EN: Ignore domain events + // VI: Bỏ qua domain events + builder.Ignore(v => v.DomainEvents); + } +} + +/// +/// EN: Entity configuration for VerificationType. +/// VI: Cấu hình entity cho VerificationType. +/// +public class VerificationTypeEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("verification_types"); + + builder.HasKey(t => t.Id); + + builder.Property(t => t.Id) + .HasColumnName("id") + .ValueGeneratedNever(); + + builder.Property(t => t.Name) + .HasColumnName("name") + .HasMaxLength(50) + .IsRequired(); + + // EN: Seed data + // VI: Seed data + builder.HasData( + VerificationType.Email, + VerificationType.Phone, + VerificationType.Document, + VerificationType.Identity + ); + } +} + +/// +/// EN: Entity configuration for VerificationStatus. +/// VI: Cấu hình entity cho VerificationStatus. +/// +public class VerificationStatusEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("verification_statuses"); + + builder.HasKey(s => s.Id); + + builder.Property(s => s.Id) + .HasColumnName("id") + .ValueGeneratedNever(); + + builder.Property(s => s.Name) + .HasColumnName("name") + .HasMaxLength(50) + .IsRequired(); + + // EN: Seed data + // VI: Seed data + builder.HasData( + VerificationStatus.Pending, + VerificationStatus.InProgress, + VerificationStatus.Verified, + VerificationStatus.Rejected, + VerificationStatus.Expired, + VerificationStatus.Cancelled + ); + } +} diff --git a/services/iam-service-net/src/IamService.Infrastructure/EntityConfigurations/OrganizationEntityConfiguration.cs b/services/iam-service-net/src/IamService.Infrastructure/EntityConfigurations/OrganizationEntityConfiguration.cs new file mode 100644 index 00000000..a83d9ceb --- /dev/null +++ b/services/iam-service-net/src/IamService.Infrastructure/EntityConfigurations/OrganizationEntityConfiguration.cs @@ -0,0 +1,128 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using IamService.Domain.AggregatesModel.OrganizationAggregate; + +namespace IamService.Infrastructure.EntityConfigurations; + +/// +/// EN: Entity configuration for Organization. +/// VI: Cấu hình entity cho Organization. +/// +public class OrganizationEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("organizations"); + + builder.HasKey(o => o.Id); + + builder.Property(o => o.Id) + .HasColumnName("id") + .ValueGeneratedNever(); + + builder.Property("_name") + .HasColumnName("name") + .HasMaxLength(200) + .IsRequired(); + + builder.Property("_slug") + .HasColumnName("slug") + .HasMaxLength(100) + .IsRequired(); + + builder.HasIndex("_slug") + .IsUnique() + .HasDatabaseName("ix_organizations_slug"); + + builder.Property("_description") + .HasColumnName("description") + .HasMaxLength(1000); + + builder.Property("_parentOrganizationId") + .HasColumnName("parent_organization_id"); + + builder.Property(o => o.StatusId) + .HasColumnName("status_id") + .IsRequired(); + + builder.Property("_createdAt") + .HasColumnName("created_at") + .IsRequired(); + + builder.Property("_updatedAt") + .HasColumnName("updated_at"); + + // EN: Configure owned entity for Settings + // VI: Cấu hình owned entity cho Settings + builder.OwnsOne("_settings", settings => + { + settings.Property(s => s.AllowUserRegistration) + .HasColumnName("settings_allow_user_registration") + .HasDefaultValue(true); + + settings.Property(s => s.RequireEmailVerification) + .HasColumnName("settings_require_email_verification") + .HasDefaultValue(true); + + settings.Property(s => s.Require2FA) + .HasColumnName("settings_require_2fa") + .HasDefaultValue(false); + + settings.Property(s => s.MaxUsersLimit) + .HasColumnName("settings_max_users_limit") + .HasDefaultValue(100); + + settings.Property(s => s.CustomDomain) + .HasColumnName("settings_custom_domain") + .HasMaxLength(255); + + settings.Property(s => s.SessionTimeoutMinutes) + .HasColumnName("settings_session_timeout_minutes") + .HasDefaultValue(60); + }); + + // EN: Self-referencing relationship for hierarchy + // VI: Quan hệ tự tham chiếu cho phân cấp + builder.HasOne() + .WithMany() + .HasForeignKey("_parentOrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_organizations_parent"); + + // EN: Ignore domain events + // VI: Bỏ qua domain events + builder.Ignore(o => o.DomainEvents); + } +} + +/// +/// EN: Entity configuration for OrganizationStatus. +/// VI: Cấu hình entity cho OrganizationStatus. +/// +public class OrganizationStatusEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("organization_statuses"); + + builder.HasKey(s => s.Id); + + builder.Property(s => s.Id) + .HasColumnName("id") + .ValueGeneratedNever(); + + builder.Property(s => s.Name) + .HasColumnName("name") + .HasMaxLength(50) + .IsRequired(); + + // EN: Seed data + // VI: Seed data + builder.HasData( + OrganizationStatus.Active, + OrganizationStatus.Suspended, + OrganizationStatus.PendingApproval, + OrganizationStatus.Archived + ); + } +} diff --git a/services/iam-service-net/src/IamService.Infrastructure/EntityConfigurations/UserProfileEntityConfiguration.cs b/services/iam-service-net/src/IamService.Infrastructure/EntityConfigurations/UserProfileEntityConfiguration.cs new file mode 100644 index 00000000..59992b30 --- /dev/null +++ b/services/iam-service-net/src/IamService.Infrastructure/EntityConfigurations/UserProfileEntityConfiguration.cs @@ -0,0 +1,199 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using IamService.Domain.AggregatesModel.UserAggregate; + +namespace IamService.Infrastructure.EntityConfigurations; + +/// +/// EN: Entity configuration for UserProfile. +/// VI: Cấu hình entity cho UserProfile. +/// +public class UserProfileEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("user_profiles"); + + builder.HasKey(p => p.Id); + + builder.Property(p => p.Id) + .HasColumnName("id") + .ValueGeneratedNever(); + + builder.Property("_userId") + .HasColumnName("user_id") + .IsRequired(); + + // EN: Unique index - one profile per user + // VI: Unique index - mỗi user một profile + builder.HasIndex("_userId") + .IsUnique() + .HasDatabaseName("ix_user_profiles_user_id"); + + builder.Property("_bio") + .HasColumnName("bio") + .HasMaxLength(2000); + + builder.Property("_avatarUrl") + .HasColumnName("avatar_url") + .HasMaxLength(500); + + builder.Property("_timezone") + .HasColumnName("timezone") + .HasMaxLength(50); + + builder.Property("_locale") + .HasColumnName("locale") + .HasMaxLength(10); + + builder.Property("_dateOfBirth") + .HasColumnName("date_of_birth"); + + builder.Property("_createdAt") + .HasColumnName("created_at") + .IsRequired(); + + builder.Property("_updatedAt") + .HasColumnName("updated_at"); + + // EN: Configure owned entity for PhoneNumber + // VI: Cấu hình owned entity cho PhoneNumber + builder.OwnsOne("_phoneNumber", phone => + { + phone.Property(p => p.CountryCode) + .HasColumnName("phone_country_code") + .HasMaxLength(5); + + phone.Property(p => p.NationalNumber) + .HasColumnName("phone_national_number") + .HasMaxLength(15); + }); + + // EN: Configure owned entity for Address + // VI: Cấu hình owned entity cho Address + builder.OwnsOne
("_address", address => + { + address.Property(a => a.Street) + .HasColumnName("address_street") + .HasMaxLength(200); + + address.Property(a => a.Street2) + .HasColumnName("address_street2") + .HasMaxLength(200); + + address.Property(a => a.City) + .HasColumnName("address_city") + .HasMaxLength(100); + + address.Property(a => a.State) + .HasColumnName("address_state") + .HasMaxLength(100); + + address.Property(a => a.PostalCode) + .HasColumnName("address_postal_code") + .HasMaxLength(20); + + address.Property(a => a.Country) + .HasColumnName("address_country") + .HasMaxLength(2); // ISO 3166-1 alpha-2 + }); + + // EN: Configure navigation to attributes + // VI: Cấu hình navigation đến attributes + builder.HasMany(p => p.Attributes) + .WithOne() + .HasForeignKey("_userProfileId") + .OnDelete(DeleteBehavior.Cascade); + } +} + +/// +/// EN: Entity configuration for ProfileAttribute. +/// VI: Cấu hình entity cho ProfileAttribute. +/// +public class ProfileAttributeEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("profile_attributes"); + + builder.HasKey(a => a.Id); + + builder.Property(a => a.Id) + .HasColumnName("id") + .ValueGeneratedNever(); + + builder.Property("_userProfileId") + .HasColumnName("user_profile_id") + .IsRequired(); + + builder.Property("_key") + .HasColumnName("key") + .HasMaxLength(100) + .IsRequired(); + + builder.Property("_value") + .HasColumnName("value") + .HasMaxLength(4000) + .IsRequired(); + + builder.Property(a => a.ValueTypeId) + .HasColumnName("value_type_id") + .IsRequired(); + + builder.Property("_createdAt") + .HasColumnName("created_at") + .IsRequired(); + + builder.Property("_updatedAt") + .HasColumnName("updated_at"); + + // EN: Unique index - one key per profile + // VI: Unique index - mỗi key cho mỗi profile + builder.HasIndex("_userProfileId", "_key") + .IsUnique() + .HasDatabaseName("ix_profile_attributes_profile_key"); + + // EN: Ignore expression-bodied properties + // VI: Bỏ qua properties expression-bodied + builder.Ignore(a => a.UserProfileId); + builder.Ignore(a => a.Key); + builder.Ignore(a => a.Value); + builder.Ignore(a => a.ValueType); + builder.Ignore(a => a.CreatedAt); + builder.Ignore(a => a.UpdatedAt); + } +} + +/// +/// EN: Entity configuration for ProfileAttributeType. +/// VI: Cấu hình entity cho ProfileAttributeType. +/// +public class ProfileAttributeTypeEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("profile_attribute_types"); + + builder.HasKey(t => t.Id); + + builder.Property(t => t.Id) + .HasColumnName("id") + .ValueGeneratedNever(); + + builder.Property(t => t.Name) + .HasColumnName("name") + .HasMaxLength(50) + .IsRequired(); + + // EN: Seed data + // VI: Seed data + builder.HasData( + ProfileAttributeType.String, + ProfileAttributeType.Number, + ProfileAttributeType.Boolean, + ProfileAttributeType.Date, + ProfileAttributeType.Json + ); + } +} diff --git a/services/iam-service-net/src/IamService.Infrastructure/IamServiceContext.cs b/services/iam-service-net/src/IamService.Infrastructure/IamServiceContext.cs index f0a544d0..89c27ee6 100644 --- a/services/iam-service-net/src/IamService.Infrastructure/IamServiceContext.cs +++ b/services/iam-service-net/src/IamService.Infrastructure/IamServiceContext.cs @@ -5,10 +5,14 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; using IamService.Domain.AggregatesModel.UserAggregate; using IamService.Domain.AggregatesModel.RoleAggregate; +using IamService.Domain.AggregatesModel.OrganizationAggregate; +using IamService.Domain.AggregatesModel.GroupAggregate; +using IamService.Domain.AggregatesModel.VerificationAggregate; using IamService.Domain.SeedWork; namespace IamService.Infrastructure; + /// /// EN: Database context for IAM Service with Identity support. /// VI: Database context cho IAM Service với Identity support. @@ -36,6 +40,78 @@ public class IamServiceContext : IdentityDbContext public DbSet UserStatuses { get; set; } = null!; + /// + /// EN: User profiles table. + /// VI: Bảng profile user. + /// + public DbSet UserProfiles { get; set; } = null!; + + /// + /// EN: Organizations table. + /// VI: Bảng tổ chức. + /// + public DbSet Organizations { get; set; } = null!; + + /// + /// EN: Organization statuses table. + /// VI: Bảng trạng thái tổ chức. + /// + public DbSet OrganizationStatuses { get; set; } = null!; + + /// + /// EN: Groups table. + /// VI: Bảng nhóm. + /// + public DbSet Groups { get; set; } = null!; + + /// + /// EN: Group members table. + /// VI: Bảng thành viên nhóm. + /// + public DbSet GroupMembers { get; set; } = null!; + + /// + /// EN: Group permissions table. + /// VI: Bảng quyền nhóm. + /// + public DbSet GroupPermissions { get; set; } = null!; + + /// + /// EN: Group roles table. + /// VI: Bảng vai trò nhóm. + /// + public DbSet GroupRoles { get; set; } = null!; + + /// + /// EN: Identity verifications table. + /// VI: Bảng xác thực danh tính. + /// + public DbSet IdentityVerifications { get; set; } = null!; + + /// + /// EN: Verification types table. + /// VI: Bảng loại xác thực. + /// + public DbSet VerificationTypes { get; set; } = null!; + + /// + /// EN: Verification statuses table. + /// VI: Bảng trạng thái xác thực. + /// + public DbSet VerificationStatuses { get; set; } = null!; + + /// + /// EN: Profile attributes table. + /// VI: Bảng profile attributes. + /// + public DbSet ProfileAttributes { get; set; } = null!; + + /// + /// EN: Profile attribute types table. + /// VI: Bảng loại profile attribute. + /// + public DbSet ProfileAttributeTypes { get; set; } = null!; + /// /// EN: Check if there's an active transaction. /// VI: Kiểm tra xem có transaction đang hoạt động không. @@ -155,18 +231,37 @@ public class IamServiceContext : IdentityDbContext() .Where(x => x.Entity.DomainEvents.Any()) .ToList(); - var domainEvents = domainEntities + var userEvents = userEntities .SelectMany(x => x.Entity.DomainEvents) .ToList(); - domainEntities.ForEach(entity => entity.Entity.ClearDomainEvents()); + userEntities.ForEach(entity => entity.Entity.ClearDomainEvents()); - foreach (var domainEvent in domainEvents) + // EN: Get domain events from Entity-based aggregates (Organization, Group, IdentityVerification, etc.) + // VI: Lấy domain events từ các aggregates kế thừa Entity (Organization, Group, IdentityVerification, v.v.) + var entityEntries = ChangeTracker + .Entries() + .Where(x => x.Entity.DomainEvents.Any()) + .ToList(); + + var entityEvents = entityEntries + .SelectMany(x => x.Entity.DomainEvents) + .ToList(); + + entityEntries.ForEach(entity => entity.Entity.ClearDomainEvents()); + + // EN: Combine all domain events and dispatch + // VI: Gộp tất cả domain events và dispatch + var allDomainEvents = userEvents.Concat(entityEvents).ToList(); + + foreach (var domainEvent in allDomainEvents) { await _mediator.Publish(domainEvent, cancellationToken); } diff --git a/services/iam-service-net/src/IamService.Infrastructure/Repositories/GroupRepository.cs b/services/iam-service-net/src/IamService.Infrastructure/Repositories/GroupRepository.cs new file mode 100644 index 00000000..77291156 --- /dev/null +++ b/services/iam-service-net/src/IamService.Infrastructure/Repositories/GroupRepository.cs @@ -0,0 +1,86 @@ +using Microsoft.EntityFrameworkCore; +using IamService.Domain.AggregatesModel.GroupAggregate; +using IamService.Domain.SeedWork; + +namespace IamService.Infrastructure.Repositories; + +/// +/// EN: Repository implementation for Group aggregate. +/// VI: Repository implementation cho Group aggregate. +/// +public class GroupRepository : IGroupRepository +{ + private readonly IamServiceContext _context; + + public IUnitOfWork UnitOfWork => _context; + + public GroupRepository(IamServiceContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + /// + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _context.Groups + .Include(g => g.Members) + .Include(g => g.Permissions) + .FirstOrDefaultAsync(g => g.Id == id, cancellationToken); + } + + /// + public async Task GetByIdWithMembersAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _context.Groups + .Include(g => g.Members) + .FirstOrDefaultAsync(g => g.Id == id, cancellationToken); + } + + /// + public async Task> GetByOrganizationIdAsync(Guid organizationId, CancellationToken cancellationToken = default) + { + return await _context.Groups + .Where(g => EF.Property(g, "_organizationId") == organizationId) + .ToListAsync(cancellationToken); + } + + /// + public async Task> GetByUserIdAsync(Guid userId, CancellationToken cancellationToken = default) + { + return await _context.Groups + .Include(g => g.Members) + .Where(g => g.Members.Any(m => EF.Property(m, "_userId") == userId)) + .ToListAsync(cancellationToken); + } + + /// + public async Task IsUserMemberAsync(Guid groupId, Guid userId, CancellationToken cancellationToken = default) + { + return await _context.GroupMembers + .AnyAsync(m => + EF.Property(m, "_groupId") == groupId && + EF.Property(m, "_userId") == userId, + cancellationToken); + } + + /// + public Group Add(Group group) + { + return _context.Groups.Add(group).Entity; + } + + /// + public void Update(Group group) + { + _context.Entry(group).State = EntityState.Modified; + } + + /// + public void Delete(Group group) + { + // EN: Soft delete - just mark as deleted + // VI: Soft delete - chỉ đánh dấu là đã xóa + group.Delete(); + Update(group); + } +} diff --git a/services/iam-service-net/src/IamService.Infrastructure/Repositories/IdentityVerificationRepository.cs b/services/iam-service-net/src/IamService.Infrastructure/Repositories/IdentityVerificationRepository.cs new file mode 100644 index 00000000..861ba29c --- /dev/null +++ b/services/iam-service-net/src/IamService.Infrastructure/Repositories/IdentityVerificationRepository.cs @@ -0,0 +1,99 @@ +using Microsoft.EntityFrameworkCore; +using IamService.Domain.AggregatesModel.VerificationAggregate; +using IamService.Domain.SeedWork; + +namespace IamService.Infrastructure.Repositories; + +/// +/// EN: Repository implementation for IdentityVerification aggregate. +/// VI: Repository implementation cho IdentityVerification aggregate. +/// +public class IdentityVerificationRepository : IIdentityVerificationRepository +{ + private readonly IamServiceContext _context; + + public IUnitOfWork UnitOfWork => _context; + + public IdentityVerificationRepository(IamServiceContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + /// + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _context.IdentityVerifications + .FirstOrDefaultAsync(v => v.Id == id, cancellationToken); + } + + /// + public async Task> GetByUserIdAsync(Guid userId, CancellationToken cancellationToken = default) + { + return await _context.IdentityVerifications + .Where(v => EF.Property(v, "_userId") == userId) + .OrderByDescending(v => EF.Property(v, "_requestedAt")) + .ToListAsync(cancellationToken); + } + + /// + public async Task GetActiveByUserIdAndTypeAsync( + Guid userId, + VerificationType type, + CancellationToken cancellationToken = default) + { + return await _context.IdentityVerifications + .Where(v => + EF.Property(v, "_userId") == userId && + v.TypeId == type.Id && + (v.StatusId == VerificationStatus.Pending.Id || v.StatusId == VerificationStatus.InProgress.Id) && + EF.Property(v, "_expiresAt") > DateTime.UtcNow) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public async Task GetLatestByUserIdAndTypeAsync( + Guid userId, + VerificationType type, + CancellationToken cancellationToken = default) + { + return await _context.IdentityVerifications + .Where(v => + EF.Property(v, "_userId") == userId && + v.TypeId == type.Id) + .OrderByDescending(v => EF.Property(v, "_requestedAt")) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public async Task HasVerifiedAsync(Guid userId, VerificationType type, CancellationToken cancellationToken = default) + { + return await _context.IdentityVerifications + .AnyAsync(v => + EF.Property(v, "_userId") == userId && + v.TypeId == type.Id && + v.StatusId == VerificationStatus.Verified.Id, + cancellationToken); + } + + /// + public async Task> GetExpiredPendingAsync(CancellationToken cancellationToken = default) + { + return await _context.IdentityVerifications + .Where(v => + (v.StatusId == VerificationStatus.Pending.Id || v.StatusId == VerificationStatus.InProgress.Id) && + EF.Property(v, "_expiresAt") < DateTime.UtcNow) + .ToListAsync(cancellationToken); + } + + /// + public IdentityVerification Add(IdentityVerification verification) + { + return _context.IdentityVerifications.Add(verification).Entity; + } + + /// + public void Update(IdentityVerification verification) + { + _context.Entry(verification).State = EntityState.Modified; + } +} diff --git a/services/iam-service-net/src/IamService.Infrastructure/Repositories/OrganizationRepository.cs b/services/iam-service-net/src/IamService.Infrastructure/Repositories/OrganizationRepository.cs new file mode 100644 index 00000000..194f4485 --- /dev/null +++ b/services/iam-service-net/src/IamService.Infrastructure/Repositories/OrganizationRepository.cs @@ -0,0 +1,106 @@ +using Microsoft.EntityFrameworkCore; +using IamService.Domain.AggregatesModel.OrganizationAggregate; +using IamService.Domain.SeedWork; + +namespace IamService.Infrastructure.Repositories; + +/// +/// EN: Repository implementation for Organization aggregate. +/// VI: Repository implementation cho Organization aggregate. +/// +public class OrganizationRepository : IOrganizationRepository +{ + private readonly IamServiceContext _context; + + public IUnitOfWork UnitOfWork => _context; + + public OrganizationRepository(IamServiceContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + /// + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _context.Organizations + .FirstOrDefaultAsync(o => o.Id == id, cancellationToken); + } + + /// + public async Task GetBySlugAsync(string slug, CancellationToken cancellationToken = default) + { + return await _context.Organizations + .FirstOrDefaultAsync(o => EF.Property(o, "_slug") == slug.ToLowerInvariant(), cancellationToken); + } + + /// + public async Task> GetHierarchyAsync(Guid rootId, CancellationToken cancellationToken = default) + { + // EN: Get the root organization and all its descendants using recursive CTE + // VI: Lấy tổ chức gốc và tất cả con cháu sử dụng recursive CTE + var results = new List(); + + var root = await GetByIdAsync(rootId, cancellationToken); + if (root != null) + { + results.Add(root); + await GetChildrenRecursiveAsync(rootId, results, cancellationToken); + } + + return results; + } + + /// + public async Task> GetChildrenAsync(Guid parentId, CancellationToken cancellationToken = default) + { + return await _context.Organizations + .Where(o => EF.Property(o, "_parentOrganizationId") == parentId) + .ToListAsync(cancellationToken); + } + + /// + public async Task IsSlugUniqueAsync(string slug, Guid? excludeId = null, CancellationToken cancellationToken = default) + { + var query = _context.Organizations + .Where(o => EF.Property(o, "_slug") == slug.ToLowerInvariant()); + + if (excludeId.HasValue) + { + query = query.Where(o => o.Id != excludeId.Value); + } + + return !await query.AnyAsync(cancellationToken); + } + + /// + public Organization Add(Organization organization) + { + return _context.Organizations.Add(organization).Entity; + } + + /// + public void Update(Organization organization) + { + _context.Entry(organization).State = EntityState.Modified; + } + + /// + public void Delete(Organization organization) + { + _context.Organizations.Remove(organization); + } + + private async Task GetChildrenRecursiveAsync( + Guid parentId, + List results, + CancellationToken cancellationToken) + { + var children = await GetChildrenAsync(parentId, cancellationToken); + + foreach (var child in children) + { + results.Add(child); + await GetChildrenRecursiveAsync(child.Id, results, cancellationToken); + } + } +}