From 4a1a0ef79cc99a2943b074ba91fdb49031a71b70 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Tue, 13 Jan 2026 00:28:41 +0700 Subject: [PATCH] feat(storage-service): Add Social Service to Docker Compose and enhance IAM service integration - Introduced a new social-service in the Docker Compose configuration for local development, including build context, environment variables, and health checks. - Updated architecture documentation to reflect the new storage service structure and its components, including user storage quotas and file management. - Enhanced README files to provide clearer instructions on service setup, configuration, and API endpoints for file storage management. - Implemented caching mechanisms in the IAM service client for improved performance and reduced latency in user information retrieval. - Updated appsettings for development to include caching settings for IAM service interactions. --- deployments/local/docker-compose.yml | 35 ++ services/chat-service-net/.env.example | 40 ++ services/chat-service-net/.gitignore | 75 ++++ services/chat-service-net/ChatService.slnx | 11 + .../chat-service-net/Directory.Build.props | 22 + services/chat-service-net/Dockerfile | 66 +++ services/chat-service-net/docker-compose.yml | 73 ++++ .../chat-service-net/docs/en/ARCHITECTURE.md | 271 ++++++++++++ services/chat-service-net/docs/en/README.md | 265 ++++++++++++ .../chat-service-net/docs/vi/ARCHITECTURE.md | 271 ++++++++++++ services/chat-service-net/docs/vi/README.md | 265 ++++++++++++ services/chat-service-net/global.json | 7 + .../Application/Behaviors/LoggingBehavior.cs | 58 +++ .../Behaviors/TransactionBehavior.cs | 84 ++++ .../Behaviors/ValidatorBehavior.cs | 63 +++ .../CreateConversationCommand.cs | 30 ++ .../CreateConversationCommandHandler.cs | 100 +++++ .../Commands/Keys/RegisterUserKeysCommand.cs | 24 ++ .../Keys/RegisterUserKeysCommandHandler.cs | 84 ++++ .../Commands/Keys/RotatePreKeyCommand.cs | 17 + .../Keys/RotatePreKeyCommandHandler.cs | 43 ++ .../Commands/Keys/UploadOneTimeKeysCommand.cs | 17 + .../Keys/UploadOneTimeKeysCommandHandler.cs | 50 +++ .../Messages/MarkMessagesReadCommand.cs | 19 + .../MarkMessagesReadCommandHandler.cs | 103 +++++ .../Commands/Messages/SendMessageCommand.cs | 30 ++ .../Messages/SendMessageCommandHandler.cs | 67 +++ .../Conversations/GetConversationsQuery.cs | 48 +++ .../GetConversationsQueryHandler.cs | 102 +++++ .../Queries/Keys/GetMyKeyBundleQuery.cs | 23 + .../Keys/GetMyKeyBundleQueryHandler.cs | 45 ++ .../Queries/Keys/GetUserKeyBundleQuery.cs | 29 ++ .../Keys/GetUserKeyBundleQueryHandler.cs | 58 +++ .../Queries/Messages/GetMessagesQuery.cs | 40 ++ .../Messages/GetMessagesQueryHandler.cs | 81 ++++ .../Validations/Keys/KeyCommandValidators.cs | 158 +++++++ .../Messaging/MessagingCommandValidators.cs | 108 +++++ .../ChatService.API/ChatService.API.csproj | 46 ++ .../Controllers/ConversationsController.cs | 102 +++++ .../Controllers/KeysController.cs | 187 +++++++++ .../Controllers/MessagesController.cs | 132 ++++++ .../src/ChatService.API/Program.cs | 144 +++++++ .../Properties/launchSettings.json | 15 + .../appsettings.Development.json | 19 + .../src/ChatService.API/appsettings.json | 46 ++ .../ConversationAggregate/Conversation.cs | 300 +++++++++++++ .../ConversationParticipant.cs | 117 ++++++ .../ConversationAggregate/ConversationType.cs | 54 +++ .../IConversationRepository.cs | 112 +++++ .../ConversationAggregate/Message.cs | 193 +++++++++ .../ConversationAggregate/MessageStatus.cs | 66 +++ .../ConversationAggregate/MessageType.cs | 72 ++++ .../AggregatesModel/UserAggregate/ChatUser.cs | 226 ++++++++++ .../UserAggregate/IChatUserRepository.cs | 65 +++ .../UserAggregate/OneTimePreKey.cs | 79 ++++ .../UserAggregate/UserKeyBundle.cs | 96 +++++ .../UserAggregate/UserStatus.cs | 47 +++ .../ChatService.Domain.csproj | 14 + .../Events/ConversationDomainEvents.cs | 74 ++++ .../Events/UserDomainEvents.cs | 32 ++ .../Exceptions/ChatDomainException.cs | 22 + .../Exceptions/DomainException.cs | 21 + .../src/ChatService.Domain/SeedWork/Entity.cs | 102 +++++ .../SeedWork/Enumeration.cs | 95 +++++ .../SeedWork/IAggregateRoot.cs | 15 + .../SeedWork/IRepository.cs | 15 + .../SeedWork/IUnitOfWork.cs | 30 ++ .../SeedWork/ValueObject.cs | 53 +++ .../ChatService.Infrastructure.csproj | 36 ++ .../ChatServiceContext.cs | 200 +++++++++ .../DependencyInjection.cs | 59 +++ .../ConversationEntityTypeConfigurations.cs | 302 ++++++++++++++ .../UserEntityTypeConfigurations.cs | 170 ++++++++ .../Idempotency/ClientRequest.cs | 26 ++ .../Idempotency/IRequestManager.cs | 24 ++ .../Idempotency/RequestManager.cs | 45 ++ .../Repositories/ChatUserRepository.cs | 102 +++++ .../Repositories/ConversationRepository.cs | 229 ++++++++++ .../ChatService.FunctionalTests.csproj | 38 ++ .../Controllers/SamplesControllerTests.cs | 80 ++++ .../CustomWebApplicationFactory.cs | 56 +++ .../ChatService.UnitTests.csproj | 35 ++ services/membership-service-net/.env.example | 40 ++ services/membership-service-net/.gitignore | 75 ++++ .../Directory.Build.props | 22 + services/membership-service-net/Dockerfile | 66 +++ .../MembershipService.slnx | 11 + .../membership-service-net/docker-compose.yml | 72 ++++ .../docs/en/ARCHITECTURE.md | 378 +++++++++++++++++ .../membership-service-net/docs/en/README.md | 322 ++++++++++++++ .../docs/vi/ARCHITECTURE.md | 378 +++++++++++++++++ .../membership-service-net/docs/vi/README.md | 322 ++++++++++++++ services/membership-service-net/global.json | 7 + .../Application/Behaviors/LoggingBehavior.cs | 58 +++ .../Behaviors/TransactionBehavior.cs | 84 ++++ .../Behaviors/ValidatorBehavior.cs | 63 +++ .../Commands/ChangeMembershipLevelCommand.cs | 34 ++ .../ChangeMembershipLevelCommandHandler.cs | 54 +++ .../Commands/CreateMemberCommand.cs | 46 ++ .../Commands/CreateMemberCommandHandler.cs | 57 +++ .../Commands/UpdateMemberProfileCommand.cs | 51 +++ .../UpdateMemberProfileCommandHandler.cs | 45 ++ .../Application/Queries/GetMemberByIdQuery.cs | 45 ++ .../Queries/GetMemberByIdQueryHandler.cs | 50 +++ .../Application/Queries/GetMembersQuery.cs | 27 ++ .../Queries/GetMembersQueryHandler.cs | 53 +++ .../CreateMemberCommandValidator.cs | 42 ++ .../UpdateMemberProfileCommandValidator.cs | 50 +++ .../Controllers/MembersController.cs | 187 +++++++++ .../MembershipService.API.csproj | 47 +++ .../src/MembershipService.API/Program.cs | 160 +++++++ .../Properties/launchSettings.json | 15 + .../appsettings.Development.json | 19 + .../MembershipService.API/appsettings.json | 46 ++ .../MemberAggregate/IMemberRepository.cs | 56 +++ .../AggregatesModel/MemberAggregate/Member.cs | 278 +++++++++++++ .../MemberAggregate/MembershipLevel.cs | 38 ++ .../Events/MemberCreatedDomainEvent.cs | 18 + .../Events/MemberUpdatedDomainEvent.cs | 18 + .../MembershipLevelChangedDomainEvent.cs | 25 ++ .../Exceptions/DomainException.cs | 21 + .../Exceptions/MemberDomainException.cs | 17 + .../MembershipService.Domain.csproj | 14 + .../SeedWork/Entity.cs | 102 +++++ .../SeedWork/Enumeration.cs | 95 +++++ .../SeedWork/IAggregateRoot.cs | 15 + .../SeedWork/IRepository.cs | 15 + .../SeedWork/IUnitOfWork.cs | 30 ++ .../SeedWork/ValueObject.cs | 53 +++ .../DependencyInjection.cs | 57 +++ .../MemberEntityTypeConfiguration.cs | 125 ++++++ .../MembershipLevelEntityTypeConfiguration.cs | 28 ++ .../Idempotency/ClientRequest.cs | 26 ++ .../Idempotency/IRequestManager.cs | 24 ++ .../Idempotency/RequestManager.cs | 45 ++ .../MembershipService.Infrastructure.csproj | 39 ++ .../MembershipServiceContext.cs | 166 ++++++++ .../Repositories/MemberRepository.cs | 115 +++++ .../Controllers/MembersControllerTests.cs | 59 +++ .../CustomWebApplicationFactory.cs | 43 ++ .../MembershipService.FunctionalTests.csproj | 38 ++ .../Domain/MemberAggregateTests.cs | 107 +++++ .../Domain/MembershipLevelTests.cs | 50 +++ .../MembershipService.UnitTests.csproj | 35 ++ .../organization-service-net/.env.example | 40 ++ services/organization-service-net/.gitignore | 75 ++++ .../Directory.Build.props | 22 + services/organization-service-net/Dockerfile | 66 +++ .../OrganizationService.slnx | 11 + .../docker-compose.yml | 72 ++++ .../docs/en/ARCHITECTURE.md | 271 ++++++++++++ .../docs/en/README.md | 265 ++++++++++++ .../docs/vi/ARCHITECTURE.md | 271 ++++++++++++ .../docs/vi/README.md | 265 ++++++++++++ services/organization-service-net/global.json | 7 + .../Application/Behaviors/LoggingBehavior.cs | 58 +++ .../Behaviors/TransactionBehavior.cs | 84 ++++ .../Behaviors/ValidatorBehavior.cs | 63 +++ .../Commands/CreateOrganizationCommand.cs | 28 ++ .../CreateOrganizationCommandHandler.cs | 56 +++ .../Queries/OrganizationQueries.cs | 42 ++ .../Queries/OrganizationQueryHandlers.cs | 111 +++++ .../CreateOrganizationCommandValidator.cs | 45 ++ .../Controllers/OrganizationsController.cs | 84 ++++ .../OrganizationService.API.csproj | 43 ++ .../src/OrganizationService.API/Program.cs | 144 +++++++ .../Properties/launchSettings.json | 15 + .../appsettings.Development.json | 19 + .../OrganizationService.API/appsettings.json | 46 ++ .../MemberAggregate/MemberRole.cs | 38 ++ .../MemberAggregate/MemberStatus.cs | 30 ++ .../MemberAggregate/OrganizationMember.cs | 168 ++++++++ .../IOrganizationRepository.cs | 46 ++ .../OrganizationAggregate/Organization.cs | 313 ++++++++++++++ .../OrganizationStatus.cs | 18 + .../OrganizationAggregate/OrganizationType.cs | 19 + .../ValueObjects/Address.cs | 61 +++ .../ValueObjects/ContactInfo.cs | 57 +++ .../VerificationDocument.cs | 61 +++ .../VerificationRequest.cs | 153 +++++++ .../VerificationStatus.cs | 30 ++ .../VerificationAggregate/VerificationType.cs | 18 + .../Events/OrganizationDomainEvents.cs | 107 +++++ .../Exceptions/DomainException.cs | 21 + .../Exceptions/OrganizationDomainException.cs | 15 + .../OrganizationService.Domain.csproj | 14 + .../SeedWork/Entity.cs | 102 +++++ .../SeedWork/Enumeration.cs | 95 +++++ .../SeedWork/IAggregateRoot.cs | 15 + .../SeedWork/IRepository.cs | 15 + .../SeedWork/IUnitOfWork.cs | 30 ++ .../SeedWork/ValueObject.cs | 53 +++ .../DependencyInjection.cs | 58 +++ .../MemberRoleEntityTypeConfiguration.cs | 39 ++ .../MemberStatusEntityTypeConfiguration.cs | 39 ++ .../OrganizationEntityTypeConfiguration.cs | 144 +++++++ ...ganizationMemberEntityTypeConfiguration.cs | 72 ++++ ...ganizationStatusEntityTypeConfiguration.cs | 39 ++ ...OrganizationTypeEntityTypeConfiguration.cs | 40 ++ ...ificationRequestEntityTypeConfiguration.cs | 87 ++++ ...rificationStatusEntityTypeConfiguration.cs | 39 ++ ...VerificationTypeEntityTypeConfiguration.cs | 39 ++ .../Idempotency/ClientRequest.cs | 26 ++ .../Idempotency/IRequestManager.cs | 24 ++ .../Idempotency/RequestManager.cs | 45 ++ .../OrganizationService.Infrastructure.csproj | 36 ++ .../OrganizationServiceContext.cs | 181 ++++++++ .../Repositories/OrganizationRepository.cs | 76 ++++ .../OrganizationsControllerTests.cs | 111 +++++ .../CustomWebApplicationFactory.cs | 81 ++++ ...OrganizationService.FunctionalTests.csproj | 38 ++ .../CreateOrganizationCommandHandlerTests.cs | 84 ++++ .../Domain/OrganizationAggregateTests.cs | 115 +++++ .../OrganizationService.UnitTests.csproj | 35 ++ services/social-service-net/.env.example | 40 ++ services/social-service-net/.gitignore | 75 ++++ .../social-service-net/Directory.Build.props | 22 + services/social-service-net/Dockerfile | 66 +++ .../social-service-net/SocialService.slnx | 11 + .../social-service-net/docker-compose.yml | 72 ++++ .../docs/en/ARCHITECTURE.md | 271 ++++++++++++ services/social-service-net/docs/en/README.md | 265 ++++++++++++ .../docs/vi/ARCHITECTURE.md | 271 ++++++++++++ services/social-service-net/docs/vi/README.md | 265 ++++++++++++ services/social-service-net/global.json | 7 + .../Application/Behaviors/LoggingBehavior.cs | 58 +++ .../Behaviors/TransactionBehavior.cs | 84 ++++ .../Behaviors/ValidatorBehavior.cs | 63 +++ .../Application/Commands/BlockUserCommand.cs | 18 + .../Commands/BlockUserCommandHandler.cs | 100 +++++ .../Application/Commands/FollowUserCommand.cs | 17 + .../Commands/FollowUserCommandHandler.cs | 76 ++++ .../Commands/RespondToFriendRequestCommand.cs | 12 + .../RespondToFriendRequestCommandHandler.cs | 58 +++ .../Commands/SendFriendRequestCommand.cs | 17 + .../SendFriendRequestCommandHandler.cs | 93 +++++ .../Commands/UnblockUserCommand.cs | 11 + .../Commands/UnblockUserCommandHandler.cs | 41 ++ .../Commands/UnfollowUserCommand.cs | 11 + .../Commands/UnfollowUserCommandHandler.cs | 45 ++ .../Queries/GetBlockedUsersQueryHandler.cs | 52 +++ .../Queries/GetFriendSuggestionsQuery.cs | 24 ++ .../GetFriendSuggestionsQueryHandler.cs | 48 +++ .../Application/Queries/GetFriendsQuery.cs | 24 ++ .../Queries/GetFriendsQueryHandler.cs | 55 +++ .../Queries/GetMutualFriendsQuery.cs | 17 + .../Queries/GetMutualFriendsQueryHandler.cs | 47 +++ .../Controllers/BlocksController.cs | 79 ++++ .../Controllers/RelationshipsController.cs | 137 ++++++ .../src/SocialService.API/Program.cs | 144 +++++++ .../Properties/launchSettings.json | 15 + .../SocialService.API.csproj | 47 +++ .../appsettings.Development.json | 19 + .../src/SocialService.API/appsettings.json | 46 ++ .../IRelationshipRepository.cs | 94 +++++ .../RelationshipAggregate/Relationship.cs | 189 +++++++++ .../RelationshipStatus.cs | 36 ++ .../RelationshipAggregate/RelationshipType.cs | 24 ++ .../IUserBlockRepository.cs | 58 +++ .../UserBlockAggregate/UserBlock.cs | 74 ++++ .../IUserProfileRepository.cs | 52 +++ .../UserProfileAggregate/UserProfile.cs | 104 +++++ .../Events/RelationshipDomainEvents.cs | 91 ++++ .../Events/UserBlockDomainEvents.cs | 38 ++ .../Exceptions/DomainException.cs | 21 + .../Exceptions/SampleDomainException.cs | 21 + .../Exceptions/SocialDomainException.cs | 21 + .../SocialService.Domain/SeedWork/Entity.cs | 102 +++++ .../SeedWork/Enumeration.cs | 95 +++++ .../SeedWork/IAggregateRoot.cs | 15 + .../SeedWork/IRepository.cs | 15 + .../SeedWork/IUnitOfWork.cs | 30 ++ .../SeedWork/ValueObject.cs | 53 +++ .../Services/IGraphQueryService.cs | 47 +++ .../SocialService.Domain.csproj | 14 + .../DependencyInjection.cs | 66 +++ .../EnumerationEntityTypeConfigurations.cs | 67 +++ .../RelationshipEntityTypeConfiguration.cs | 79 ++++ .../UserBlockEntityTypeConfiguration.cs | 57 +++ .../UserProfileEntityTypeConfiguration.cs | 63 +++ .../Idempotency/ClientRequest.cs | 26 ++ .../Idempotency/IRequestManager.cs | 24 ++ .../Idempotency/RequestManager.cs | 45 ++ .../Repositories/RelationshipRepository.cs | 172 ++++++++ .../Repositories/UserBlockRepository.cs | 86 ++++ .../Repositories/UserProfileRepository.cs | 66 +++ .../Services/GraphQueryService.cs | 196 +++++++++ .../SocialService.Infrastructure.csproj | 36 ++ .../SocialServiceContext.cs | 189 +++++++++ .../Controllers/SamplesControllerTests.cs | 80 ++++ .../CustomWebApplicationFactory.cs | 56 +++ .../SocialService.FunctionalTests.csproj | 38 ++ .../SocialService.UnitTests.csproj | 35 ++ .../docs/en/ARCHITECTURE.md | 377 +++++++++-------- .../storage-service-net/docs/en/README.md | 332 ++++++--------- .../docs/vi/ARCHITECTURE.md | 393 ++++++++++-------- .../storage-service-net/docs/vi/README.md | 358 ++++++---------- .../appsettings.Development.json | 4 +- .../ExternalServices/HttpIamServiceClient.cs | 373 ++++++++++++++++- .../ExternalServices/IamServiceClient.cs | 98 ++++- services/wallet-service-net/.env.example | 53 +++ services/wallet-service-net/.gitignore | 75 ++++ .../wallet-service-net/Directory.Build.props | 22 + services/wallet-service-net/Dockerfile | 66 +++ .../wallet-service-net/WalletService.slnx | 11 + .../wallet-service-net/docker-compose.yml | 72 ++++ .../docs/en/ARCHITECTURE.md | 271 ++++++++++++ services/wallet-service-net/docs/en/README.md | 265 ++++++++++++ .../docs/vi/ARCHITECTURE.md | 271 ++++++++++++ services/wallet-service-net/docs/vi/README.md | 265 ++++++++++++ services/wallet-service-net/global.json | 7 + .../Application/Behaviors/LoggingBehavior.cs | 58 +++ .../Behaviors/TransactionBehavior.cs | 84 ++++ .../Behaviors/ValidatorBehavior.cs | 63 +++ .../Commands/CreatePointAccountCommand.cs | 19 + .../CreatePointAccountCommandHandler.cs | 55 +++ .../Commands/CreateWalletCommand.cs | 21 + .../Commands/CreateWalletCommandHandler.cs | 56 +++ .../Application/Commands/DepositCommand.cs | 24 ++ .../Commands/DepositCommandHandler.cs | 62 +++ .../Application/Commands/EarnPointsCommand.cs | 25 ++ .../Commands/EarnPointsCommandHandler.cs | 60 +++ .../Commands/SpendPointsCommand.cs | 14 + .../Commands/SpendPointsCommandHandler.cs | 53 +++ .../Application/Commands/WithdrawCommand.cs | 14 + .../Commands/WithdrawCommandHandler.cs | 56 +++ .../Queries/GetPointAccountQuery.cs | 18 + .../Queries/GetPointAccountQueryHandler.cs | 37 ++ .../Queries/GetPointTransactionsQuery.cs | 32 ++ .../GetPointTransactionsQueryHandler.cs | 50 +++ .../Application/Queries/GetWalletQuery.cs | 19 + .../Queries/GetWalletQueryHandler.cs | 36 ++ .../Queries/GetWalletTransactionsQuery.cs | 32 ++ .../GetWalletTransactionsQueryHandler.cs | 50 +++ .../Controllers/HealthController.cs | 43 ++ .../Controllers/PointsController.cs | 132 ++++++ .../Controllers/WalletsController.cs | 133 ++++++ .../src/WalletService.API/Program.cs | 193 +++++++++ .../Properties/launchSettings.json | 15 + .../WalletService.API.csproj | 45 ++ .../appsettings.Development.json | 19 + .../src/WalletService.API/appsettings.json | 46 ++ .../IPointAccountRepository.cs | 61 +++ .../PointAccountAggregate/PointAccount.cs | 207 +++++++++ .../PointAccountAggregate/PointTransaction.cs | 87 ++++ .../PointTransactionType.cs | 42 ++ .../WalletAggregate/IWalletRepository.cs | 55 +++ .../AggregatesModel/WalletAggregate/Money.cs | 93 +++++ .../WalletAggregate/TransactionType.cs | 42 ++ .../AggregatesModel/WalletAggregate/Wallet.cs | 260 ++++++++++++ .../WalletAggregate/WalletStatus.cs | 30 ++ .../WalletAggregate/WalletTransaction.cs | 79 ++++ .../Events/PointsEarnedDomainEvent.cs | 32 ++ .../Events/PointsSpentDomainEvent.cs | 32 ++ .../Events/WalletBalanceChangedDomainEvent.cs | 32 ++ .../Events/WalletCreatedDomainEvent.cs | 21 + .../InsufficientBalanceException.cs | 18 + .../Exceptions/InsufficientPointsException.cs | 18 + .../Exceptions/PointsDomainException.cs | 15 + .../Exceptions/WalletDomainException.cs | 15 + .../WalletService.Domain/SeedWork/Entity.cs | 102 +++++ .../SeedWork/Enumeration.cs | 95 +++++ .../SeedWork/IAggregateRoot.cs | 15 + .../SeedWork/IRepository.cs | 15 + .../SeedWork/IUnitOfWork.cs | 30 ++ .../SeedWork/ValueObject.cs | 53 +++ .../WalletService.Domain.csproj | 14 + .../DependencyInjection.cs | 44 ++ .../PointAccountEntityTypeConfiguration.cs | 56 +++ ...PointTransactionEntityTypeConfiguration.cs | 69 +++ .../WalletEntityTypeConfiguration.cs | 69 +++ ...alletTransactionEntityTypeConfiguration.cs | 74 ++++ .../Idempotency/ClientRequest.cs | 26 ++ .../Idempotency/IRequestManager.cs | 24 ++ .../Idempotency/RequestManager.cs | 45 ++ .../Repositories/PointAccountRepository.cs | 82 ++++ .../Repositories/WalletRepository.cs | 69 +++ .../WalletService.Infrastructure.csproj | 36 ++ .../WalletServiceContext.cs | 135 ++++++ .../Controllers/HealthControllerTests.cs | 53 +++ .../CustomWebApplicationFactory.cs | 110 +++++ .../WalletService.FunctionalTests.csproj | 38 ++ .../Domain/PointAccountTests.cs | 153 +++++++ .../Domain/WalletTests.cs | 141 +++++++ .../WalletService.UnitTests.csproj | 35 ++ 385 files changed, 28872 insertions(+), 808 deletions(-) create mode 100644 services/chat-service-net/.env.example create mode 100644 services/chat-service-net/.gitignore create mode 100644 services/chat-service-net/ChatService.slnx create mode 100644 services/chat-service-net/Directory.Build.props create mode 100644 services/chat-service-net/Dockerfile create mode 100644 services/chat-service-net/docker-compose.yml create mode 100644 services/chat-service-net/docs/en/ARCHITECTURE.md create mode 100644 services/chat-service-net/docs/en/README.md create mode 100644 services/chat-service-net/docs/vi/ARCHITECTURE.md create mode 100644 services/chat-service-net/docs/vi/README.md create mode 100644 services/chat-service-net/global.json create mode 100644 services/chat-service-net/src/ChatService.API/Application/Behaviors/LoggingBehavior.cs create mode 100644 services/chat-service-net/src/ChatService.API/Application/Behaviors/TransactionBehavior.cs create mode 100644 services/chat-service-net/src/ChatService.API/Application/Behaviors/ValidatorBehavior.cs create mode 100644 services/chat-service-net/src/ChatService.API/Application/Commands/Conversations/CreateConversationCommand.cs create mode 100644 services/chat-service-net/src/ChatService.API/Application/Commands/Conversations/CreateConversationCommandHandler.cs create mode 100644 services/chat-service-net/src/ChatService.API/Application/Commands/Keys/RegisterUserKeysCommand.cs create mode 100644 services/chat-service-net/src/ChatService.API/Application/Commands/Keys/RegisterUserKeysCommandHandler.cs create mode 100644 services/chat-service-net/src/ChatService.API/Application/Commands/Keys/RotatePreKeyCommand.cs create mode 100644 services/chat-service-net/src/ChatService.API/Application/Commands/Keys/RotatePreKeyCommandHandler.cs create mode 100644 services/chat-service-net/src/ChatService.API/Application/Commands/Keys/UploadOneTimeKeysCommand.cs create mode 100644 services/chat-service-net/src/ChatService.API/Application/Commands/Keys/UploadOneTimeKeysCommandHandler.cs create mode 100644 services/chat-service-net/src/ChatService.API/Application/Commands/Messages/MarkMessagesReadCommand.cs create mode 100644 services/chat-service-net/src/ChatService.API/Application/Commands/Messages/MarkMessagesReadCommandHandler.cs create mode 100644 services/chat-service-net/src/ChatService.API/Application/Commands/Messages/SendMessageCommand.cs create mode 100644 services/chat-service-net/src/ChatService.API/Application/Commands/Messages/SendMessageCommandHandler.cs create mode 100644 services/chat-service-net/src/ChatService.API/Application/Queries/Conversations/GetConversationsQuery.cs create mode 100644 services/chat-service-net/src/ChatService.API/Application/Queries/Conversations/GetConversationsQueryHandler.cs create mode 100644 services/chat-service-net/src/ChatService.API/Application/Queries/Keys/GetMyKeyBundleQuery.cs create mode 100644 services/chat-service-net/src/ChatService.API/Application/Queries/Keys/GetMyKeyBundleQueryHandler.cs create mode 100644 services/chat-service-net/src/ChatService.API/Application/Queries/Keys/GetUserKeyBundleQuery.cs create mode 100644 services/chat-service-net/src/ChatService.API/Application/Queries/Keys/GetUserKeyBundleQueryHandler.cs create mode 100644 services/chat-service-net/src/ChatService.API/Application/Queries/Messages/GetMessagesQuery.cs create mode 100644 services/chat-service-net/src/ChatService.API/Application/Queries/Messages/GetMessagesQueryHandler.cs create mode 100644 services/chat-service-net/src/ChatService.API/Application/Validations/Keys/KeyCommandValidators.cs create mode 100644 services/chat-service-net/src/ChatService.API/Application/Validations/Messaging/MessagingCommandValidators.cs create mode 100644 services/chat-service-net/src/ChatService.API/ChatService.API.csproj create mode 100644 services/chat-service-net/src/ChatService.API/Controllers/ConversationsController.cs create mode 100644 services/chat-service-net/src/ChatService.API/Controllers/KeysController.cs create mode 100644 services/chat-service-net/src/ChatService.API/Controllers/MessagesController.cs create mode 100644 services/chat-service-net/src/ChatService.API/Program.cs create mode 100644 services/chat-service-net/src/ChatService.API/Properties/launchSettings.json create mode 100644 services/chat-service-net/src/ChatService.API/appsettings.Development.json create mode 100644 services/chat-service-net/src/ChatService.API/appsettings.json create mode 100644 services/chat-service-net/src/ChatService.Domain/AggregatesModel/ConversationAggregate/Conversation.cs create mode 100644 services/chat-service-net/src/ChatService.Domain/AggregatesModel/ConversationAggregate/ConversationParticipant.cs create mode 100644 services/chat-service-net/src/ChatService.Domain/AggregatesModel/ConversationAggregate/ConversationType.cs create mode 100644 services/chat-service-net/src/ChatService.Domain/AggregatesModel/ConversationAggregate/IConversationRepository.cs create mode 100644 services/chat-service-net/src/ChatService.Domain/AggregatesModel/ConversationAggregate/Message.cs create mode 100644 services/chat-service-net/src/ChatService.Domain/AggregatesModel/ConversationAggregate/MessageStatus.cs create mode 100644 services/chat-service-net/src/ChatService.Domain/AggregatesModel/ConversationAggregate/MessageType.cs create mode 100644 services/chat-service-net/src/ChatService.Domain/AggregatesModel/UserAggregate/ChatUser.cs create mode 100644 services/chat-service-net/src/ChatService.Domain/AggregatesModel/UserAggregate/IChatUserRepository.cs create mode 100644 services/chat-service-net/src/ChatService.Domain/AggregatesModel/UserAggregate/OneTimePreKey.cs create mode 100644 services/chat-service-net/src/ChatService.Domain/AggregatesModel/UserAggregate/UserKeyBundle.cs create mode 100644 services/chat-service-net/src/ChatService.Domain/AggregatesModel/UserAggregate/UserStatus.cs create mode 100644 services/chat-service-net/src/ChatService.Domain/ChatService.Domain.csproj create mode 100644 services/chat-service-net/src/ChatService.Domain/Events/ConversationDomainEvents.cs create mode 100644 services/chat-service-net/src/ChatService.Domain/Events/UserDomainEvents.cs create mode 100644 services/chat-service-net/src/ChatService.Domain/Exceptions/ChatDomainException.cs create mode 100644 services/chat-service-net/src/ChatService.Domain/Exceptions/DomainException.cs create mode 100644 services/chat-service-net/src/ChatService.Domain/SeedWork/Entity.cs create mode 100644 services/chat-service-net/src/ChatService.Domain/SeedWork/Enumeration.cs create mode 100644 services/chat-service-net/src/ChatService.Domain/SeedWork/IAggregateRoot.cs create mode 100644 services/chat-service-net/src/ChatService.Domain/SeedWork/IRepository.cs create mode 100644 services/chat-service-net/src/ChatService.Domain/SeedWork/IUnitOfWork.cs create mode 100644 services/chat-service-net/src/ChatService.Domain/SeedWork/ValueObject.cs create mode 100644 services/chat-service-net/src/ChatService.Infrastructure/ChatService.Infrastructure.csproj create mode 100644 services/chat-service-net/src/ChatService.Infrastructure/ChatServiceContext.cs create mode 100644 services/chat-service-net/src/ChatService.Infrastructure/DependencyInjection.cs create mode 100644 services/chat-service-net/src/ChatService.Infrastructure/EntityConfigurations/ConversationEntityTypeConfigurations.cs create mode 100644 services/chat-service-net/src/ChatService.Infrastructure/EntityConfigurations/UserEntityTypeConfigurations.cs create mode 100644 services/chat-service-net/src/ChatService.Infrastructure/Idempotency/ClientRequest.cs create mode 100644 services/chat-service-net/src/ChatService.Infrastructure/Idempotency/IRequestManager.cs create mode 100644 services/chat-service-net/src/ChatService.Infrastructure/Idempotency/RequestManager.cs create mode 100644 services/chat-service-net/src/ChatService.Infrastructure/Repositories/ChatUserRepository.cs create mode 100644 services/chat-service-net/src/ChatService.Infrastructure/Repositories/ConversationRepository.cs create mode 100644 services/chat-service-net/tests/ChatService.FunctionalTests/ChatService.FunctionalTests.csproj create mode 100644 services/chat-service-net/tests/ChatService.FunctionalTests/Controllers/SamplesControllerTests.cs create mode 100644 services/chat-service-net/tests/ChatService.FunctionalTests/CustomWebApplicationFactory.cs create mode 100644 services/chat-service-net/tests/ChatService.UnitTests/ChatService.UnitTests.csproj create mode 100644 services/membership-service-net/.env.example create mode 100644 services/membership-service-net/.gitignore create mode 100644 services/membership-service-net/Directory.Build.props create mode 100644 services/membership-service-net/Dockerfile create mode 100644 services/membership-service-net/MembershipService.slnx create mode 100644 services/membership-service-net/docker-compose.yml create mode 100644 services/membership-service-net/docs/en/ARCHITECTURE.md create mode 100644 services/membership-service-net/docs/en/README.md create mode 100644 services/membership-service-net/docs/vi/ARCHITECTURE.md create mode 100644 services/membership-service-net/docs/vi/README.md create mode 100644 services/membership-service-net/global.json create mode 100644 services/membership-service-net/src/MembershipService.API/Application/Behaviors/LoggingBehavior.cs create mode 100644 services/membership-service-net/src/MembershipService.API/Application/Behaviors/TransactionBehavior.cs create mode 100644 services/membership-service-net/src/MembershipService.API/Application/Behaviors/ValidatorBehavior.cs create mode 100644 services/membership-service-net/src/MembershipService.API/Application/Commands/ChangeMembershipLevelCommand.cs create mode 100644 services/membership-service-net/src/MembershipService.API/Application/Commands/ChangeMembershipLevelCommandHandler.cs create mode 100644 services/membership-service-net/src/MembershipService.API/Application/Commands/CreateMemberCommand.cs create mode 100644 services/membership-service-net/src/MembershipService.API/Application/Commands/CreateMemberCommandHandler.cs create mode 100644 services/membership-service-net/src/MembershipService.API/Application/Commands/UpdateMemberProfileCommand.cs create mode 100644 services/membership-service-net/src/MembershipService.API/Application/Commands/UpdateMemberProfileCommandHandler.cs create mode 100644 services/membership-service-net/src/MembershipService.API/Application/Queries/GetMemberByIdQuery.cs create mode 100644 services/membership-service-net/src/MembershipService.API/Application/Queries/GetMemberByIdQueryHandler.cs create mode 100644 services/membership-service-net/src/MembershipService.API/Application/Queries/GetMembersQuery.cs create mode 100644 services/membership-service-net/src/MembershipService.API/Application/Queries/GetMembersQueryHandler.cs create mode 100644 services/membership-service-net/src/MembershipService.API/Application/Validations/CreateMemberCommandValidator.cs create mode 100644 services/membership-service-net/src/MembershipService.API/Application/Validations/UpdateMemberProfileCommandValidator.cs create mode 100644 services/membership-service-net/src/MembershipService.API/Controllers/MembersController.cs create mode 100644 services/membership-service-net/src/MembershipService.API/MembershipService.API.csproj create mode 100644 services/membership-service-net/src/MembershipService.API/Program.cs create mode 100644 services/membership-service-net/src/MembershipService.API/Properties/launchSettings.json create mode 100644 services/membership-service-net/src/MembershipService.API/appsettings.Development.json create mode 100644 services/membership-service-net/src/MembershipService.API/appsettings.json create mode 100644 services/membership-service-net/src/MembershipService.Domain/AggregatesModel/MemberAggregate/IMemberRepository.cs create mode 100644 services/membership-service-net/src/MembershipService.Domain/AggregatesModel/MemberAggregate/Member.cs create mode 100644 services/membership-service-net/src/MembershipService.Domain/AggregatesModel/MemberAggregate/MembershipLevel.cs create mode 100644 services/membership-service-net/src/MembershipService.Domain/Events/MemberCreatedDomainEvent.cs create mode 100644 services/membership-service-net/src/MembershipService.Domain/Events/MemberUpdatedDomainEvent.cs create mode 100644 services/membership-service-net/src/MembershipService.Domain/Events/MembershipLevelChangedDomainEvent.cs create mode 100644 services/membership-service-net/src/MembershipService.Domain/Exceptions/DomainException.cs create mode 100644 services/membership-service-net/src/MembershipService.Domain/Exceptions/MemberDomainException.cs create mode 100644 services/membership-service-net/src/MembershipService.Domain/MembershipService.Domain.csproj create mode 100644 services/membership-service-net/src/MembershipService.Domain/SeedWork/Entity.cs create mode 100644 services/membership-service-net/src/MembershipService.Domain/SeedWork/Enumeration.cs create mode 100644 services/membership-service-net/src/MembershipService.Domain/SeedWork/IAggregateRoot.cs create mode 100644 services/membership-service-net/src/MembershipService.Domain/SeedWork/IRepository.cs create mode 100644 services/membership-service-net/src/MembershipService.Domain/SeedWork/IUnitOfWork.cs create mode 100644 services/membership-service-net/src/MembershipService.Domain/SeedWork/ValueObject.cs create mode 100644 services/membership-service-net/src/MembershipService.Infrastructure/DependencyInjection.cs create mode 100644 services/membership-service-net/src/MembershipService.Infrastructure/EntityConfigurations/MemberEntityTypeConfiguration.cs create mode 100644 services/membership-service-net/src/MembershipService.Infrastructure/EntityConfigurations/MembershipLevelEntityTypeConfiguration.cs create mode 100644 services/membership-service-net/src/MembershipService.Infrastructure/Idempotency/ClientRequest.cs create mode 100644 services/membership-service-net/src/MembershipService.Infrastructure/Idempotency/IRequestManager.cs create mode 100644 services/membership-service-net/src/MembershipService.Infrastructure/Idempotency/RequestManager.cs create mode 100644 services/membership-service-net/src/MembershipService.Infrastructure/MembershipService.Infrastructure.csproj create mode 100644 services/membership-service-net/src/MembershipService.Infrastructure/MembershipServiceContext.cs create mode 100644 services/membership-service-net/src/MembershipService.Infrastructure/Repositories/MemberRepository.cs create mode 100644 services/membership-service-net/tests/MembershipService.FunctionalTests/Controllers/MembersControllerTests.cs create mode 100644 services/membership-service-net/tests/MembershipService.FunctionalTests/CustomWebApplicationFactory.cs create mode 100644 services/membership-service-net/tests/MembershipService.FunctionalTests/MembershipService.FunctionalTests.csproj create mode 100644 services/membership-service-net/tests/MembershipService.UnitTests/Domain/MemberAggregateTests.cs create mode 100644 services/membership-service-net/tests/MembershipService.UnitTests/Domain/MembershipLevelTests.cs create mode 100644 services/membership-service-net/tests/MembershipService.UnitTests/MembershipService.UnitTests.csproj create mode 100644 services/organization-service-net/.env.example create mode 100644 services/organization-service-net/.gitignore create mode 100644 services/organization-service-net/Directory.Build.props create mode 100644 services/organization-service-net/Dockerfile create mode 100644 services/organization-service-net/OrganizationService.slnx create mode 100644 services/organization-service-net/docker-compose.yml create mode 100644 services/organization-service-net/docs/en/ARCHITECTURE.md create mode 100644 services/organization-service-net/docs/en/README.md create mode 100644 services/organization-service-net/docs/vi/ARCHITECTURE.md create mode 100644 services/organization-service-net/docs/vi/README.md create mode 100644 services/organization-service-net/global.json create mode 100644 services/organization-service-net/src/OrganizationService.API/Application/Behaviors/LoggingBehavior.cs create mode 100644 services/organization-service-net/src/OrganizationService.API/Application/Behaviors/TransactionBehavior.cs create mode 100644 services/organization-service-net/src/OrganizationService.API/Application/Behaviors/ValidatorBehavior.cs create mode 100644 services/organization-service-net/src/OrganizationService.API/Application/Commands/CreateOrganizationCommand.cs create mode 100644 services/organization-service-net/src/OrganizationService.API/Application/Commands/CreateOrganizationCommandHandler.cs create mode 100644 services/organization-service-net/src/OrganizationService.API/Application/Queries/OrganizationQueries.cs create mode 100644 services/organization-service-net/src/OrganizationService.API/Application/Queries/OrganizationQueryHandlers.cs create mode 100644 services/organization-service-net/src/OrganizationService.API/Application/Validations/CreateOrganizationCommandValidator.cs create mode 100644 services/organization-service-net/src/OrganizationService.API/Controllers/OrganizationsController.cs create mode 100644 services/organization-service-net/src/OrganizationService.API/OrganizationService.API.csproj create mode 100644 services/organization-service-net/src/OrganizationService.API/Program.cs create mode 100644 services/organization-service-net/src/OrganizationService.API/Properties/launchSettings.json create mode 100644 services/organization-service-net/src/OrganizationService.API/appsettings.Development.json create mode 100644 services/organization-service-net/src/OrganizationService.API/appsettings.json create mode 100644 services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/MemberAggregate/MemberRole.cs create mode 100644 services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/MemberAggregate/MemberStatus.cs create mode 100644 services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/MemberAggregate/OrganizationMember.cs create mode 100644 services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/OrganizationAggregate/IOrganizationRepository.cs create mode 100644 services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/OrganizationAggregate/Organization.cs create mode 100644 services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/OrganizationAggregate/OrganizationStatus.cs create mode 100644 services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/OrganizationAggregate/OrganizationType.cs create mode 100644 services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/OrganizationAggregate/ValueObjects/Address.cs create mode 100644 services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/OrganizationAggregate/ValueObjects/ContactInfo.cs create mode 100644 services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/VerificationAggregate/VerificationDocument.cs create mode 100644 services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/VerificationAggregate/VerificationRequest.cs create mode 100644 services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/VerificationAggregate/VerificationStatus.cs create mode 100644 services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/VerificationAggregate/VerificationType.cs create mode 100644 services/organization-service-net/src/OrganizationService.Domain/Events/OrganizationDomainEvents.cs create mode 100644 services/organization-service-net/src/OrganizationService.Domain/Exceptions/DomainException.cs create mode 100644 services/organization-service-net/src/OrganizationService.Domain/Exceptions/OrganizationDomainException.cs create mode 100644 services/organization-service-net/src/OrganizationService.Domain/OrganizationService.Domain.csproj create mode 100644 services/organization-service-net/src/OrganizationService.Domain/SeedWork/Entity.cs create mode 100644 services/organization-service-net/src/OrganizationService.Domain/SeedWork/Enumeration.cs create mode 100644 services/organization-service-net/src/OrganizationService.Domain/SeedWork/IAggregateRoot.cs create mode 100644 services/organization-service-net/src/OrganizationService.Domain/SeedWork/IRepository.cs create mode 100644 services/organization-service-net/src/OrganizationService.Domain/SeedWork/IUnitOfWork.cs create mode 100644 services/organization-service-net/src/OrganizationService.Domain/SeedWork/ValueObject.cs create mode 100644 services/organization-service-net/src/OrganizationService.Infrastructure/DependencyInjection.cs create mode 100644 services/organization-service-net/src/OrganizationService.Infrastructure/EntityConfigurations/MemberRoleEntityTypeConfiguration.cs create mode 100644 services/organization-service-net/src/OrganizationService.Infrastructure/EntityConfigurations/MemberStatusEntityTypeConfiguration.cs create mode 100644 services/organization-service-net/src/OrganizationService.Infrastructure/EntityConfigurations/OrganizationEntityTypeConfiguration.cs create mode 100644 services/organization-service-net/src/OrganizationService.Infrastructure/EntityConfigurations/OrganizationMemberEntityTypeConfiguration.cs create mode 100644 services/organization-service-net/src/OrganizationService.Infrastructure/EntityConfigurations/OrganizationStatusEntityTypeConfiguration.cs create mode 100644 services/organization-service-net/src/OrganizationService.Infrastructure/EntityConfigurations/OrganizationTypeEntityTypeConfiguration.cs create mode 100644 services/organization-service-net/src/OrganizationService.Infrastructure/EntityConfigurations/VerificationRequestEntityTypeConfiguration.cs create mode 100644 services/organization-service-net/src/OrganizationService.Infrastructure/EntityConfigurations/VerificationStatusEntityTypeConfiguration.cs create mode 100644 services/organization-service-net/src/OrganizationService.Infrastructure/EntityConfigurations/VerificationTypeEntityTypeConfiguration.cs create mode 100644 services/organization-service-net/src/OrganizationService.Infrastructure/Idempotency/ClientRequest.cs create mode 100644 services/organization-service-net/src/OrganizationService.Infrastructure/Idempotency/IRequestManager.cs create mode 100644 services/organization-service-net/src/OrganizationService.Infrastructure/Idempotency/RequestManager.cs create mode 100644 services/organization-service-net/src/OrganizationService.Infrastructure/OrganizationService.Infrastructure.csproj create mode 100644 services/organization-service-net/src/OrganizationService.Infrastructure/OrganizationServiceContext.cs create mode 100644 services/organization-service-net/src/OrganizationService.Infrastructure/Repositories/OrganizationRepository.cs create mode 100644 services/organization-service-net/tests/OrganizationService.FunctionalTests/Controllers/OrganizationsControllerTests.cs create mode 100644 services/organization-service-net/tests/OrganizationService.FunctionalTests/CustomWebApplicationFactory.cs create mode 100644 services/organization-service-net/tests/OrganizationService.FunctionalTests/OrganizationService.FunctionalTests.csproj create mode 100644 services/organization-service-net/tests/OrganizationService.UnitTests/Application/CreateOrganizationCommandHandlerTests.cs create mode 100644 services/organization-service-net/tests/OrganizationService.UnitTests/Domain/OrganizationAggregateTests.cs create mode 100644 services/organization-service-net/tests/OrganizationService.UnitTests/OrganizationService.UnitTests.csproj create mode 100644 services/social-service-net/.env.example create mode 100644 services/social-service-net/.gitignore create mode 100644 services/social-service-net/Directory.Build.props create mode 100644 services/social-service-net/Dockerfile create mode 100644 services/social-service-net/SocialService.slnx create mode 100644 services/social-service-net/docker-compose.yml create mode 100644 services/social-service-net/docs/en/ARCHITECTURE.md create mode 100644 services/social-service-net/docs/en/README.md create mode 100644 services/social-service-net/docs/vi/ARCHITECTURE.md create mode 100644 services/social-service-net/docs/vi/README.md create mode 100644 services/social-service-net/global.json create mode 100644 services/social-service-net/src/SocialService.API/Application/Behaviors/LoggingBehavior.cs create mode 100644 services/social-service-net/src/SocialService.API/Application/Behaviors/TransactionBehavior.cs create mode 100644 services/social-service-net/src/SocialService.API/Application/Behaviors/ValidatorBehavior.cs create mode 100644 services/social-service-net/src/SocialService.API/Application/Commands/BlockUserCommand.cs create mode 100644 services/social-service-net/src/SocialService.API/Application/Commands/BlockUserCommandHandler.cs create mode 100644 services/social-service-net/src/SocialService.API/Application/Commands/FollowUserCommand.cs create mode 100644 services/social-service-net/src/SocialService.API/Application/Commands/FollowUserCommandHandler.cs create mode 100644 services/social-service-net/src/SocialService.API/Application/Commands/RespondToFriendRequestCommand.cs create mode 100644 services/social-service-net/src/SocialService.API/Application/Commands/RespondToFriendRequestCommandHandler.cs create mode 100644 services/social-service-net/src/SocialService.API/Application/Commands/SendFriendRequestCommand.cs create mode 100644 services/social-service-net/src/SocialService.API/Application/Commands/SendFriendRequestCommandHandler.cs create mode 100644 services/social-service-net/src/SocialService.API/Application/Commands/UnblockUserCommand.cs create mode 100644 services/social-service-net/src/SocialService.API/Application/Commands/UnblockUserCommandHandler.cs create mode 100644 services/social-service-net/src/SocialService.API/Application/Commands/UnfollowUserCommand.cs create mode 100644 services/social-service-net/src/SocialService.API/Application/Commands/UnfollowUserCommandHandler.cs create mode 100644 services/social-service-net/src/SocialService.API/Application/Queries/GetBlockedUsersQueryHandler.cs create mode 100644 services/social-service-net/src/SocialService.API/Application/Queries/GetFriendSuggestionsQuery.cs create mode 100644 services/social-service-net/src/SocialService.API/Application/Queries/GetFriendSuggestionsQueryHandler.cs create mode 100644 services/social-service-net/src/SocialService.API/Application/Queries/GetFriendsQuery.cs create mode 100644 services/social-service-net/src/SocialService.API/Application/Queries/GetFriendsQueryHandler.cs create mode 100644 services/social-service-net/src/SocialService.API/Application/Queries/GetMutualFriendsQuery.cs create mode 100644 services/social-service-net/src/SocialService.API/Application/Queries/GetMutualFriendsQueryHandler.cs create mode 100644 services/social-service-net/src/SocialService.API/Controllers/BlocksController.cs create mode 100644 services/social-service-net/src/SocialService.API/Controllers/RelationshipsController.cs create mode 100644 services/social-service-net/src/SocialService.API/Program.cs create mode 100644 services/social-service-net/src/SocialService.API/Properties/launchSettings.json create mode 100644 services/social-service-net/src/SocialService.API/SocialService.API.csproj create mode 100644 services/social-service-net/src/SocialService.API/appsettings.Development.json create mode 100644 services/social-service-net/src/SocialService.API/appsettings.json create mode 100644 services/social-service-net/src/SocialService.Domain/AggregatesModel/RelationshipAggregate/IRelationshipRepository.cs create mode 100644 services/social-service-net/src/SocialService.Domain/AggregatesModel/RelationshipAggregate/Relationship.cs create mode 100644 services/social-service-net/src/SocialService.Domain/AggregatesModel/RelationshipAggregate/RelationshipStatus.cs create mode 100644 services/social-service-net/src/SocialService.Domain/AggregatesModel/RelationshipAggregate/RelationshipType.cs create mode 100644 services/social-service-net/src/SocialService.Domain/AggregatesModel/UserBlockAggregate/IUserBlockRepository.cs create mode 100644 services/social-service-net/src/SocialService.Domain/AggregatesModel/UserBlockAggregate/UserBlock.cs create mode 100644 services/social-service-net/src/SocialService.Domain/AggregatesModel/UserProfileAggregate/IUserProfileRepository.cs create mode 100644 services/social-service-net/src/SocialService.Domain/AggregatesModel/UserProfileAggregate/UserProfile.cs create mode 100644 services/social-service-net/src/SocialService.Domain/Events/RelationshipDomainEvents.cs create mode 100644 services/social-service-net/src/SocialService.Domain/Events/UserBlockDomainEvents.cs create mode 100644 services/social-service-net/src/SocialService.Domain/Exceptions/DomainException.cs create mode 100644 services/social-service-net/src/SocialService.Domain/Exceptions/SampleDomainException.cs create mode 100644 services/social-service-net/src/SocialService.Domain/Exceptions/SocialDomainException.cs create mode 100644 services/social-service-net/src/SocialService.Domain/SeedWork/Entity.cs create mode 100644 services/social-service-net/src/SocialService.Domain/SeedWork/Enumeration.cs create mode 100644 services/social-service-net/src/SocialService.Domain/SeedWork/IAggregateRoot.cs create mode 100644 services/social-service-net/src/SocialService.Domain/SeedWork/IRepository.cs create mode 100644 services/social-service-net/src/SocialService.Domain/SeedWork/IUnitOfWork.cs create mode 100644 services/social-service-net/src/SocialService.Domain/SeedWork/ValueObject.cs create mode 100644 services/social-service-net/src/SocialService.Domain/Services/IGraphQueryService.cs create mode 100644 services/social-service-net/src/SocialService.Domain/SocialService.Domain.csproj create mode 100644 services/social-service-net/src/SocialService.Infrastructure/DependencyInjection.cs create mode 100644 services/social-service-net/src/SocialService.Infrastructure/EntityConfigurations/EnumerationEntityTypeConfigurations.cs create mode 100644 services/social-service-net/src/SocialService.Infrastructure/EntityConfigurations/RelationshipEntityTypeConfiguration.cs create mode 100644 services/social-service-net/src/SocialService.Infrastructure/EntityConfigurations/UserBlockEntityTypeConfiguration.cs create mode 100644 services/social-service-net/src/SocialService.Infrastructure/EntityConfigurations/UserProfileEntityTypeConfiguration.cs create mode 100644 services/social-service-net/src/SocialService.Infrastructure/Idempotency/ClientRequest.cs create mode 100644 services/social-service-net/src/SocialService.Infrastructure/Idempotency/IRequestManager.cs create mode 100644 services/social-service-net/src/SocialService.Infrastructure/Idempotency/RequestManager.cs create mode 100644 services/social-service-net/src/SocialService.Infrastructure/Repositories/RelationshipRepository.cs create mode 100644 services/social-service-net/src/SocialService.Infrastructure/Repositories/UserBlockRepository.cs create mode 100644 services/social-service-net/src/SocialService.Infrastructure/Repositories/UserProfileRepository.cs create mode 100644 services/social-service-net/src/SocialService.Infrastructure/Services/GraphQueryService.cs create mode 100644 services/social-service-net/src/SocialService.Infrastructure/SocialService.Infrastructure.csproj create mode 100644 services/social-service-net/src/SocialService.Infrastructure/SocialServiceContext.cs create mode 100644 services/social-service-net/tests/SocialService.FunctionalTests/Controllers/SamplesControllerTests.cs create mode 100644 services/social-service-net/tests/SocialService.FunctionalTests/CustomWebApplicationFactory.cs create mode 100644 services/social-service-net/tests/SocialService.FunctionalTests/SocialService.FunctionalTests.csproj create mode 100644 services/social-service-net/tests/SocialService.UnitTests/SocialService.UnitTests.csproj create mode 100644 services/wallet-service-net/.env.example create mode 100644 services/wallet-service-net/.gitignore create mode 100644 services/wallet-service-net/Directory.Build.props create mode 100644 services/wallet-service-net/Dockerfile create mode 100644 services/wallet-service-net/WalletService.slnx create mode 100644 services/wallet-service-net/docker-compose.yml create mode 100644 services/wallet-service-net/docs/en/ARCHITECTURE.md create mode 100644 services/wallet-service-net/docs/en/README.md create mode 100644 services/wallet-service-net/docs/vi/ARCHITECTURE.md create mode 100644 services/wallet-service-net/docs/vi/README.md create mode 100644 services/wallet-service-net/global.json create mode 100644 services/wallet-service-net/src/WalletService.API/Application/Behaviors/LoggingBehavior.cs create mode 100644 services/wallet-service-net/src/WalletService.API/Application/Behaviors/TransactionBehavior.cs create mode 100644 services/wallet-service-net/src/WalletService.API/Application/Behaviors/ValidatorBehavior.cs create mode 100644 services/wallet-service-net/src/WalletService.API/Application/Commands/CreatePointAccountCommand.cs create mode 100644 services/wallet-service-net/src/WalletService.API/Application/Commands/CreatePointAccountCommandHandler.cs create mode 100644 services/wallet-service-net/src/WalletService.API/Application/Commands/CreateWalletCommand.cs create mode 100644 services/wallet-service-net/src/WalletService.API/Application/Commands/CreateWalletCommandHandler.cs create mode 100644 services/wallet-service-net/src/WalletService.API/Application/Commands/DepositCommand.cs create mode 100644 services/wallet-service-net/src/WalletService.API/Application/Commands/DepositCommandHandler.cs create mode 100644 services/wallet-service-net/src/WalletService.API/Application/Commands/EarnPointsCommand.cs create mode 100644 services/wallet-service-net/src/WalletService.API/Application/Commands/EarnPointsCommandHandler.cs create mode 100644 services/wallet-service-net/src/WalletService.API/Application/Commands/SpendPointsCommand.cs create mode 100644 services/wallet-service-net/src/WalletService.API/Application/Commands/SpendPointsCommandHandler.cs create mode 100644 services/wallet-service-net/src/WalletService.API/Application/Commands/WithdrawCommand.cs create mode 100644 services/wallet-service-net/src/WalletService.API/Application/Commands/WithdrawCommandHandler.cs create mode 100644 services/wallet-service-net/src/WalletService.API/Application/Queries/GetPointAccountQuery.cs create mode 100644 services/wallet-service-net/src/WalletService.API/Application/Queries/GetPointAccountQueryHandler.cs create mode 100644 services/wallet-service-net/src/WalletService.API/Application/Queries/GetPointTransactionsQuery.cs create mode 100644 services/wallet-service-net/src/WalletService.API/Application/Queries/GetPointTransactionsQueryHandler.cs create mode 100644 services/wallet-service-net/src/WalletService.API/Application/Queries/GetWalletQuery.cs create mode 100644 services/wallet-service-net/src/WalletService.API/Application/Queries/GetWalletQueryHandler.cs create mode 100644 services/wallet-service-net/src/WalletService.API/Application/Queries/GetWalletTransactionsQuery.cs create mode 100644 services/wallet-service-net/src/WalletService.API/Application/Queries/GetWalletTransactionsQueryHandler.cs create mode 100644 services/wallet-service-net/src/WalletService.API/Controllers/HealthController.cs create mode 100644 services/wallet-service-net/src/WalletService.API/Controllers/PointsController.cs create mode 100644 services/wallet-service-net/src/WalletService.API/Controllers/WalletsController.cs create mode 100644 services/wallet-service-net/src/WalletService.API/Program.cs create mode 100644 services/wallet-service-net/src/WalletService.API/Properties/launchSettings.json create mode 100644 services/wallet-service-net/src/WalletService.API/WalletService.API.csproj create mode 100644 services/wallet-service-net/src/WalletService.API/appsettings.Development.json create mode 100644 services/wallet-service-net/src/WalletService.API/appsettings.json create mode 100644 services/wallet-service-net/src/WalletService.Domain/AggregatesModel/PointAccountAggregate/IPointAccountRepository.cs create mode 100644 services/wallet-service-net/src/WalletService.Domain/AggregatesModel/PointAccountAggregate/PointAccount.cs create mode 100644 services/wallet-service-net/src/WalletService.Domain/AggregatesModel/PointAccountAggregate/PointTransaction.cs create mode 100644 services/wallet-service-net/src/WalletService.Domain/AggregatesModel/PointAccountAggregate/PointTransactionType.cs create mode 100644 services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/IWalletRepository.cs create mode 100644 services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/Money.cs create mode 100644 services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/TransactionType.cs create mode 100644 services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/Wallet.cs create mode 100644 services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/WalletStatus.cs create mode 100644 services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/WalletTransaction.cs create mode 100644 services/wallet-service-net/src/WalletService.Domain/Events/PointsEarnedDomainEvent.cs create mode 100644 services/wallet-service-net/src/WalletService.Domain/Events/PointsSpentDomainEvent.cs create mode 100644 services/wallet-service-net/src/WalletService.Domain/Events/WalletBalanceChangedDomainEvent.cs create mode 100644 services/wallet-service-net/src/WalletService.Domain/Events/WalletCreatedDomainEvent.cs create mode 100644 services/wallet-service-net/src/WalletService.Domain/Exceptions/InsufficientBalanceException.cs create mode 100644 services/wallet-service-net/src/WalletService.Domain/Exceptions/InsufficientPointsException.cs create mode 100644 services/wallet-service-net/src/WalletService.Domain/Exceptions/PointsDomainException.cs create mode 100644 services/wallet-service-net/src/WalletService.Domain/Exceptions/WalletDomainException.cs create mode 100644 services/wallet-service-net/src/WalletService.Domain/SeedWork/Entity.cs create mode 100644 services/wallet-service-net/src/WalletService.Domain/SeedWork/Enumeration.cs create mode 100644 services/wallet-service-net/src/WalletService.Domain/SeedWork/IAggregateRoot.cs create mode 100644 services/wallet-service-net/src/WalletService.Domain/SeedWork/IRepository.cs create mode 100644 services/wallet-service-net/src/WalletService.Domain/SeedWork/IUnitOfWork.cs create mode 100644 services/wallet-service-net/src/WalletService.Domain/SeedWork/ValueObject.cs create mode 100644 services/wallet-service-net/src/WalletService.Domain/WalletService.Domain.csproj create mode 100644 services/wallet-service-net/src/WalletService.Infrastructure/DependencyInjection.cs create mode 100644 services/wallet-service-net/src/WalletService.Infrastructure/EntityConfigurations/PointAccountEntityTypeConfiguration.cs create mode 100644 services/wallet-service-net/src/WalletService.Infrastructure/EntityConfigurations/PointTransactionEntityTypeConfiguration.cs create mode 100644 services/wallet-service-net/src/WalletService.Infrastructure/EntityConfigurations/WalletEntityTypeConfiguration.cs create mode 100644 services/wallet-service-net/src/WalletService.Infrastructure/EntityConfigurations/WalletTransactionEntityTypeConfiguration.cs create mode 100644 services/wallet-service-net/src/WalletService.Infrastructure/Idempotency/ClientRequest.cs create mode 100644 services/wallet-service-net/src/WalletService.Infrastructure/Idempotency/IRequestManager.cs create mode 100644 services/wallet-service-net/src/WalletService.Infrastructure/Idempotency/RequestManager.cs create mode 100644 services/wallet-service-net/src/WalletService.Infrastructure/Repositories/PointAccountRepository.cs create mode 100644 services/wallet-service-net/src/WalletService.Infrastructure/Repositories/WalletRepository.cs create mode 100644 services/wallet-service-net/src/WalletService.Infrastructure/WalletService.Infrastructure.csproj create mode 100644 services/wallet-service-net/src/WalletService.Infrastructure/WalletServiceContext.cs create mode 100644 services/wallet-service-net/tests/WalletService.FunctionalTests/Controllers/HealthControllerTests.cs create mode 100644 services/wallet-service-net/tests/WalletService.FunctionalTests/CustomWebApplicationFactory.cs create mode 100644 services/wallet-service-net/tests/WalletService.FunctionalTests/WalletService.FunctionalTests.csproj create mode 100644 services/wallet-service-net/tests/WalletService.UnitTests/Domain/PointAccountTests.cs create mode 100644 services/wallet-service-net/tests/WalletService.UnitTests/Domain/WalletTests.cs create mode 100644 services/wallet-service-net/tests/WalletService.UnitTests/WalletService.UnitTests.csproj diff --git a/deployments/local/docker-compose.yml b/deployments/local/docker-compose.yml index 88f5225d..128be02f 100644 --- a/deployments/local/docker-compose.yml +++ b/deployments/local/docker-compose.yml @@ -199,6 +199,41 @@ services: timeout: 5s retries: 5 + # Social Service .NET - Social Graph Management + social-service: + build: + context: ../.. + dockerfile: services/social-service-net/Dockerfile + container_name: social-service-local + environment: + - ASPNETCORE_ENVIRONMENT=Development + - ConnectionStrings__DefaultConnection=${SOCIAL_DATABASE_URL:-Host=localhost;Port=5432;Database=social_db;Username=postgres;Password=postgres} + - IamService__BaseUrl=http://iam-service:5001 + - IamService__ServiceName=social-service + ports: + - "5003:8080" + depends_on: + redis: + condition: service_healthy + traefik: + condition: service_started + networks: + - microservices-network + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health/live"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + labels: + - "traefik.enable=true" + - "traefik.http.routers.social-service.rule=PathPrefix(`/api/v1/relationships`) || PathPrefix(`/api/v1/blocks`)" + - "traefik.http.routers.social-service.entrypoints=web" + - "traefik.http.services.social-service.loadbalancer.server.port=8080" + - "traefik.http.services.social-service.loadbalancer.healthcheck.path=/health/live" + - "traefik.http.services.social-service.loadbalancer.healthcheck.interval=10s" + # =========================================================================== # FRONTEND APPLICATIONS (Temporarily disabled) # =========================================================================== diff --git a/services/chat-service-net/.env.example b/services/chat-service-net/.env.example new file mode 100644 index 00000000..f9053bc3 --- /dev/null +++ b/services/chat-service-net/.env.example @@ -0,0 +1,40 @@ +# Environment / Môi Trường +ASPNETCORE_ENVIRONMENT=Development + +# Database / Cơ Sở Dữ Liệu +# PostgreSQL connection string (Neon or local) +DATABASE_URL=Host=localhost;Port=5432;Database=myservice_db;Username=postgres;Password=postgres + +# Redis Cache +REDIS_URL=localhost:6379 +REDIS_PASSWORD= + +# JWT Authentication / Xác Thực JWT +JWT_SECRET=your-secret-key-min-32-characters-long-here +JWT_ISSUER=goodgo-platform +JWT_AUDIENCE=goodgo-services +JWT_ACCESS_TOKEN_EXPIRY_MINUTES=15 +JWT_REFRESH_TOKEN_EXPIRY_DAYS=7 + +# API Configuration / Cấu Hình API +API_PORT=5000 +API_BASE_PATH=/api/v1/myservice + +# Observability / Quan Sát +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 +OTEL_SERVICE_NAME=myservice + +# Logging +LOG_LEVEL=Information +SEQ_URL=http://localhost:5341 + +# Feature Flags +FEATURE_SWAGGER_ENABLED=true +FEATURE_DETAILED_ERRORS=true + +# Rate Limiting +RATE_LIMIT_PERMITS_PER_MINUTE=100 +RATE_LIMIT_QUEUE_LIMIT=10 + +# Health Checks +HEALTHCHECK_TIMEOUT_SECONDS=5 diff --git a/services/chat-service-net/.gitignore b/services/chat-service-net/.gitignore new file mode 100644 index 00000000..84b02a53 --- /dev/null +++ b/services/chat-service-net/.gitignore @@ -0,0 +1,75 @@ +# Build results +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio +.vs/ +*.user +*.userosscache +*.suo +*.userprefs +*.sln.docstates + +# Rider +.idea/ +*.sln.iml + +# Visual Studio Code +.vscode/ + +# NuGet +*.nupkg +*.snupkg +.nuget/ +packages/ +project.lock.json +project.fragment.lock.json + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# Coverage +TestResults/ +*.coverage +*.coveragexml +coverage*.json +coverage*.xml + +# Publish output +publish/ +out/ + +# Environment files +.env +.env.local +.env.*.local +*.env + +# Secrets +appsettings.*.json +!appsettings.json +!appsettings.Development.json + +# macOS +.DS_Store + +# Windows +Thumbs.db +ehthumbs.db + +# JetBrains +*.resharper + +# dotnet tools +.config/dotnet-tools.json + +# Migration scripts (only keep structure) +Migrations/ + +# Temp files +*.tmp +*.temp +~$* diff --git a/services/chat-service-net/ChatService.slnx b/services/chat-service-net/ChatService.slnx new file mode 100644 index 00000000..15f268f6 --- /dev/null +++ b/services/chat-service-net/ChatService.slnx @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/services/chat-service-net/Directory.Build.props b/services/chat-service-net/Directory.Build.props new file mode 100644 index 00000000..c3b74373 --- /dev/null +++ b/services/chat-service-net/Directory.Build.props @@ -0,0 +1,22 @@ + + + net10.0 + 14.0 + enable + enable + true + true + $(NoWarn);1591;CA2017 + + + + GoodGo Team + GoodGo + © 2026 GoodGo. All rights reserved. + git + + + + + + diff --git a/services/chat-service-net/Dockerfile b/services/chat-service-net/Dockerfile new file mode 100644 index 00000000..6f9b52d3 --- /dev/null +++ b/services/chat-service-net/Dockerfile @@ -0,0 +1,66 @@ +# Build stage / Giai đoạn build +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +# EN: Copy project files for layer caching +# VI: Sao chép các file project để tận dụng layer caching +COPY ["src/ChatService.API/ChatService.API.csproj", "src/ChatService.API/"] +COPY ["src/ChatService.Domain/ChatService.Domain.csproj", "src/ChatService.Domain/"] +COPY ["src/ChatService.Infrastructure/ChatService.Infrastructure.csproj", "src/ChatService.Infrastructure/"] +COPY ["Directory.Build.props", "./"] + +# EN: Restore dependencies +# VI: Khôi phục dependencies +RUN dotnet restore "src/ChatService.API/ChatService.API.csproj" + +# EN: Copy all source code +# VI: Sao chép toàn bộ source code +COPY src/ ./src/ + +# EN: Build the application +# VI: Build ứng dụng +WORKDIR "/src/src/ChatService.API" +RUN dotnet build "ChatService.API.csproj" -c Release -o /app/build --no-restore + +# Publish stage / Giai đoạn publish +FROM build AS publish +RUN dotnet publish "ChatService.API.csproj" -c Release -o /app/publish /p:UseAppHost=false --no-restore + +# Runtime stage / Giai đoạn runtime +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final +WORKDIR /app + +# EN: Create non-root user for security +# VI: Tạo user non-root cho bảo mật +RUN groupadd -g 1001 dotnetuser && \ + useradd -u 1001 -g dotnetuser -s /bin/sh dotnetuser + +# EN: Copy published application +# VI: Sao chép ứng dụng đã publish +COPY --from=publish /app/publish . + +# EN: Change ownership to non-root user +# VI: Thay đổi quyền sở hữu sang user non-root +RUN chown -R dotnetuser:dotnetuser /app + +# EN: Switch to non-root user +# VI: Chuyển sang user non-root +USER dotnetuser + +# EN: Expose port +# VI: Mở cổng +EXPOSE 8080 + +# EN: Set environment variables +# VI: Thiết lập biến môi trường +ENV ASPNETCORE_URLS=http://+:8080 +ENV ASPNETCORE_ENVIRONMENT=Production + +# EN: Health check +# VI: Kiểm tra health +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD curl -f http://localhost:8080/health/live || exit 1 + +# EN: Start the application +# VI: Khởi động ứng dụng +ENTRYPOINT ["dotnet", "ChatService.API.dll"] diff --git a/services/chat-service-net/docker-compose.yml b/services/chat-service-net/docker-compose.yml new file mode 100644 index 00000000..8c46bc72 --- /dev/null +++ b/services/chat-service-net/docker-compose.yml @@ -0,0 +1,73 @@ +version: '3.8' + +# EN: Docker Compose for Chat Service local development +# VI: Docker Compose cho Chat Service phát triển local + +services: + chatservice-api: + build: + context: . + dockerfile: Dockerfile + container_name: chatservice-api + ports: + - "5003:8080" + environment: + - ASPNETCORE_ENVIRONMENT=Development + - DATABASE_URL=Host=postgres;Port=5432;Database=chatservice_db;Username=postgres;Password=postgres + - REDIS_HOST=redis + - REDIS_PORT=6379 + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + networks: + - chatservice-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health/live"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + + postgres: + image: postgres:16-alpine + container_name: chatservice-postgres + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: chatservice_db + ports: + - "5435:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - chatservice-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + container_name: chatservice-redis + ports: + - "6382:6379" + volumes: + - redis_data:/data + networks: + - chatservice-network + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + postgres_data: + redis_data: + +networks: + chatservice-network: + driver: bridge diff --git a/services/chat-service-net/docs/en/ARCHITECTURE.md b/services/chat-service-net/docs/en/ARCHITECTURE.md new file mode 100644 index 00000000..03992515 --- /dev/null +++ b/services/chat-service-net/docs/en/ARCHITECTURE.md @@ -0,0 +1,271 @@ +# Architecture Documentation + +> Detailed architecture documentation for the .NET 10 Microservice Template. + +## Architecture Overview + +```mermaid +graph TB + subgraph "API Layer" + C[Controllers] + CMD[Commands] + Q[Queries] + B[Behaviors] + V[Validations] + end + + subgraph "Domain Layer" + AR[Aggregate Roots] + E[Entities] + VO[Value Objects] + DE[Domain Events] + DX[Domain Exceptions] + end + + subgraph "Infrastructure Layer" + DB[(PostgreSQL)] + R[Repositories] + CTX[DbContext] + ID[Idempotency] + end + + C --> CMD + C --> Q + CMD --> B --> V + CMD --> AR + Q --> R + R --> CTX --> DB + AR --> DE + R --> AR + + style C fill:#4a90d9,stroke:#2d5986,color:#fff + style AR fill:#50c878,stroke:#2d8659,color:#fff + style DB fill:#ff6b6b,stroke:#c0392b,color:#fff +``` + +## Layer Responsibilities + +### 1. Domain Layer (ChatService.Domain) + +The heart of the application containing pure business logic. This layer: +- Has **ZERO** external dependencies (except MediatR.Contracts for events) +- Contains only POCO classes +- Implements DDD tactical patterns + +#### Components + +| Component | Purpose | +|-----------|---------| +| **SeedWork** | Base classes: Entity, ValueObject, Enumeration, IAggregateRoot | +| **AggregatesModel** | Aggregate roots with their entities and value objects | +| **Events** | Domain events for cross-aggregate communication | +| **Exceptions** | Domain-specific exceptions for business rule violations | + +### 2. Infrastructure Layer (ChatService.Infrastructure) + +Technical implementations and external concerns: +- Database access (EF Core) +- Repository implementations +- External service integrations + +### 3. API Layer (ChatService.API) + +Application entry point and CQRS implementation: +- Controllers for HTTP handling +- Commands for write operations +- Queries for read operations +- MediatR behaviors for cross-cutting concerns + +## CQRS Flow + +```mermaid +sequenceDiagram + participant Client + participant Controller + participant MediatR + participant LoggingBehavior + participant ValidatorBehavior + participant TransactionBehavior + participant CommandHandler + participant Repository + participant DbContext + + Client->>Controller: HTTP Request + Controller->>MediatR: Send(Command) + MediatR->>LoggingBehavior: Handle + LoggingBehavior->>ValidatorBehavior: Next() + ValidatorBehavior->>TransactionBehavior: Next() + TransactionBehavior->>CommandHandler: Next() + CommandHandler->>Repository: Add/Update/Delete + Repository->>DbContext: SaveEntitiesAsync() + DbContext-->>Repository: Success + Repository-->>CommandHandler: Result + CommandHandler-->>Controller: Response + Controller-->>Client: HTTP Response +``` + +## Domain Events + +```mermaid +graph LR + AR[Aggregate Root] -->|Raises| DE[Domain Event] + DE -->|Dispatched by| CTX[DbContext] + CTX -->|Publishes to| M[MediatR] + M -->|Handled by| H1[Handler 1] + M -->|Handled by| H2[Handler 2] + + style AR fill:#50c878,stroke:#2d8659,color:#fff + style DE fill:#f39c12,stroke:#d68910,color:#fff + style M fill:#9b59b6,stroke:#7d3c98,color:#fff +``` + +## Database Schema + +### Sample Aggregate + +```mermaid +erDiagram + samples { + uuid id PK + varchar(200) name + varchar(1000) description + int status_id FK + timestamp created_at + timestamp updated_at + } + + sample_statuses { + int id PK + varchar(50) name + } + + samples ||--o{ sample_statuses : has +``` + +## MediatR Pipeline + +``` +Request → LoggingBehavior → ValidatorBehavior → TransactionBehavior → Handler → Response + │ │ │ + ▼ ▼ ▼ + Log start/end Validate Begin/Commit + + timing with Transaction + FluentValidation +``` + +### Behavior Order + +1. **LoggingBehavior** - Logs request handling with timing +2. **ValidatorBehavior** - Validates request using FluentValidation +3. **TransactionBehavior** - Wraps command handlers in database transactions + +## Error Handling + +### Exception Hierarchy + +``` +Exception +└── DomainException + └── SampleDomainException +``` + +### Problem Details (RFC 7807) + +All errors are returned in Problem Details format: + +```json +{ + "type": "https://tools.ietf.org/html/rfc7807", + "title": "Validation Error", + "status": 400, + "detail": "One or more validation errors occurred.", + "errors": { + "Name": ["Name is required"] + } +} +``` + +## Health Checks + +```mermaid +graph TD + HC[Health Check Endpoint] + HC --> |/health/live| L[Liveness] + HC --> |/health/ready| R[Readiness] + HC --> |/health| F[Full Status] + + R --> PG[(PostgreSQL)] + R --> RD[(Redis)] + + style HC fill:#3498db,stroke:#2980b9,color:#fff + style L fill:#2ecc71,stroke:#27ae60,color:#fff + style R fill:#f39c12,stroke:#d68910,color:#fff +``` + +## Deployment Architecture + +### Docker Compose (Local) + +```yaml +services: + myservice-api: + build: . + ports: ["5000:8080"] + depends_on: + - postgres + - redis + + postgres: + image: postgres:16-alpine + + redis: + image: redis:7-alpine +``` + +### Kubernetes (Production) + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: myservice-api +spec: + replicas: 3 + template: + spec: + containers: + - name: api + image: myservice:latest + ports: + - containerPort: 8080 + livenessProbe: + httpGet: + path: /health/live + port: 8080 + readinessProbe: + httpGet: + path: /health/ready + port: 8080 +``` + +## Security Considerations + +1. **Authentication**: JWT Bearer token (configure in production) +2. **Authorization**: Role-based access control +3. **Input Validation**: FluentValidation on all requests +4. **SQL Injection**: EF Core parameterized queries +5. **Secrets**: Environment variables, never in code + +## Performance Optimization + +1. **Connection Pooling**: EF Core with Npgsql connection resilience +2. **Async/Await**: All I/O operations are async +3. **Response Caching**: Add caching headers for queries +4. **Database Indexes**: Configure in EntityConfigurations + +## References + +- [eShopOnContainers](https://github.com/dotnet-architecture/eShopOnContainers) +- [.NET Microservices Architecture Guide](https://docs.microsoft.com/en-us/dotnet/architecture/microservices/) +- [Domain-Driven Design](https://martinfowler.com/bliki/DomainDrivenDesign.html) +- [CQRS Pattern](https://docs.microsoft.com/en-us/azure/architecture/patterns/cqrs) diff --git a/services/chat-service-net/docs/en/README.md b/services/chat-service-net/docs/en/README.md new file mode 100644 index 00000000..a4bc6cd1 --- /dev/null +++ b/services/chat-service-net/docs/en/README.md @@ -0,0 +1,265 @@ +# .NET 10 Microservice Template + +> Enterprise-grade .NET 10 microservice template following DDD, CQRS, and Clean Architecture patterns. + +## Overview + +This template provides a production-ready structure for .NET microservices based on the eShopOnContainers reference architecture with: + +- **Domain-Driven Design (DDD)** - Aggregates, Entities, Value Objects, Domain Events +- **CQRS Pattern** - Separate Commands (write) and Queries (read) with MediatR +- **Clean Architecture** - Domain, Infrastructure, API layered separation +- **EF Core 10** - PostgreSQL with connection resilience +- **FluentValidation** - Request validation +- **API Versioning** - URL segment versioning +- **Health Checks** - Kubernetes-ready probes +- **Structured Logging** - Serilog with console and Seq + +## Prerequisites + +| Requirement | Version | +|-------------|---------| +| .NET SDK | 10.0.101+ | +| Docker | 24.0+ | +| PostgreSQL | 15+ (or use Docker) | + +```bash +# Check .NET version +dotnet --version +# Should output: 10.0.xxx +``` + +## Quick Start + +### 1. Create New Service + +```bash +# Copy template to new service +cp -r services/_template_dot_net services/your-service-name + +# Navigate to service directory +cd services/your-service-name + +# Rename all occurrences of "ChatService" to "YourService" +find . -type f -name "*.cs" -exec sed -i '' 's/ChatService/YourService/g' {} + +find . -type f -name "*.csproj" -exec sed -i '' 's/ChatService/YourService/g' {} + +``` + +### 2. Configure Environment + +```bash +# Copy environment template +cp .env.example .env + +# Edit with your configuration +nano .env +``` + +### 3. Run with Docker + +```bash +# Start all services (API + PostgreSQL + Redis) +docker-compose up -d + +# View logs +docker-compose logs -f myservice-api +``` + +### 4. Run Locally + +```bash +# Restore dependencies +dotnet restore + +# Build all projects +dotnet build + +# Run the API +dotnet run --project src/ChatService.API +``` + +## Project Structure + +``` +_template_dot_net/ +├── src/ +│ ├── ChatService.API/ # Presentation Layer (Controllers, CQRS) +│ │ ├── Controllers/ # API endpoints +│ │ ├── Application/ # CQRS Implementation +│ │ │ ├── Commands/ # Write operations (MediatR) +│ │ │ ├── Queries/ # Read operations +│ │ │ ├── Behaviors/ # MediatR pipeline behaviors +│ │ │ └── Validations/ # FluentValidation validators +│ │ ├── Middleware/ # Custom middleware +│ │ └── Program.cs # Application entry point +│ │ +│ ├── ChatService.Domain/ # Domain Layer (Pure business logic) +│ │ ├── AggregatesModel/ # Aggregate roots and entities +│ │ ├── Events/ # Domain events +│ │ ├── Exceptions/ # Domain exceptions +│ │ └── SeedWork/ # Base classes (Entity, ValueObject, etc.) +│ │ +│ └── ChatService.Infrastructure/ # Infrastructure Layer (Data access) +│ ├── EntityConfigurations/ # EF Core Fluent API configurations +│ ├── Repositories/ # Repository implementations +│ ├── Idempotency/ # Request idempotency handling +│ └── ChatServiceContext.cs # DbContext with Unit of Work +│ +├── tests/ +│ ├── ChatService.UnitTests/ # Unit tests (Domain, Application) +│ └── ChatService.FunctionalTests/ # Integration tests (API endpoints) +│ +├── Dockerfile # Multi-stage Docker build +├── docker-compose.yml # Local development setup +├── global.json # .NET SDK version pinning +└── Directory.Build.props # Common MSBuild properties +``` + +## API Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/api/v1/samples` | Get all samples | +| `GET` | `/api/v1/samples/{id}` | Get sample by ID | +| `POST` | `/api/v1/samples` | Create new sample | +| `PUT` | `/api/v1/samples/{id}` | Update sample | +| `DELETE` | `/api/v1/samples/{id}` | Delete sample | +| `PATCH` | `/api/v1/samples/{id}/status` | Change status | + +### Health Endpoints + +| Endpoint | Purpose | +|----------|---------| +| `/health` | Full health status | +| `/health/live` | Liveness probe | +| `/health/ready` | Readiness probe | + +## CQRS Pattern + +### Commands (Write Operations) + +```csharp +// Define command +public record CreateSampleCommand(string Name, string? Description) + : IRequest; + +// Handle command +public class CreateSampleCommandHandler : IRequestHandler +{ + public async Task Handle(CreateSampleCommand request, CancellationToken ct) + { + var sample = new Sample(request.Name, request.Description); + _repository.Add(sample); + await _repository.UnitOfWork.SaveEntitiesAsync(ct); + return new CreateSampleCommandResult(sample.Id); + } +} +``` + +### Queries (Read Operations) + +```csharp +// Define query +public record GetSampleQuery(Guid SampleId) : IRequest; +``` + +## Domain Model + +### Aggregate Root + +```csharp +public class Sample : Entity, IAggregateRoot +{ + public string Name => _name; + public SampleStatus Status => _status; + + public Sample(string name, string? description) { + // Business logic validation + if (string.IsNullOrWhiteSpace(name)) + throw new SampleDomainException("Sample name cannot be empty"); + + // Domain event + AddDomainEvent(new SampleCreatedDomainEvent(this)); + } + + public void Activate() { + if (_status != SampleStatus.Draft) + throw new SampleDomainException("Only draft samples can be activated"); + // State transition + } +} +``` + +## Testing + +```bash +# Run all tests +dotnet test + +# Run with coverage +dotnet test /p:CollectCoverage=true /p:CoverageReportFormat=cobertura + +# Run specific test project +dotnet test tests/ChatService.UnitTests +``` + +## Configuration + +### Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `ASPNETCORE_ENVIRONMENT` | Environment name | `Development` | +| `DATABASE_URL` | PostgreSQL connection string | - | +| `REDIS_URL` | Redis connection string | - | +| `JWT_SECRET` | JWT signing secret (min 32 chars) | - | + +### appsettings.json + +```json +{ + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Database=myservice;Username=postgres;Password=postgres" + }, + "Serilog": { + "MinimumLevel": "Information" + } +} +``` + +## Deployment + +### Docker Build + +```bash +# Build Docker image +docker build -t myservice:latest . + +# Run container +docker run -p 5000:8080 --env-file .env myservice:latest +``` + +### Kubernetes + +See [ARCHITECTURE.md](./ARCHITECTURE.md) for Kubernetes deployment manifests. + +## What's New in .NET 10 + +- **C# 14** language features +- Improved **Native AOT** support +- Better **async/await** performance +- Enhanced **JSON serialization** +- Performance improvements across the board +- 3-year **LTS** support (until November 2028) + +## Resources + +- [eShopOnContainers](https://github.com/dotnet-architecture/eShopOnContainers) - Reference architecture +- [.NET 10 Documentation](https://docs.microsoft.com/en-us/dotnet/core/whats-new/dotnet-10) +- [DDD with .NET](https://docs.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/) +- [MediatR](https://github.com/jbogard/MediatR) - CQRS library +- [FluentValidation](https://docs.fluentvalidation.net/) - Validation library + +## License + +Proprietary - GoodGo Platform diff --git a/services/chat-service-net/docs/vi/ARCHITECTURE.md b/services/chat-service-net/docs/vi/ARCHITECTURE.md new file mode 100644 index 00000000..69b9dabe --- /dev/null +++ b/services/chat-service-net/docs/vi/ARCHITECTURE.md @@ -0,0 +1,271 @@ +# Tài Liệu Kiến Trúc + +> Tài liệu kiến trúc chi tiết cho Template Microservice .NET 10. + +## Tổng Quan Kiến Trúc + +```mermaid +graph TB + subgraph "Lớp API" + C[Controllers] + CMD[Commands] + Q[Queries] + B[Behaviors] + V[Validations] + end + + subgraph "Lớp Domain" + AR[Aggregate Roots] + E[Entities] + VO[Value Objects] + DE[Domain Events] + DX[Domain Exceptions] + end + + subgraph "Lớp Infrastructure" + DB[(PostgreSQL)] + R[Repositories] + CTX[DbContext] + ID[Idempotency] + end + + C --> CMD + C --> Q + CMD --> B --> V + CMD --> AR + Q --> R + R --> CTX --> DB + AR --> DE + R --> AR + + style C fill:#4a90d9,stroke:#2d5986,color:#fff + style AR fill:#50c878,stroke:#2d8659,color:#fff + style DB fill:#ff6b6b,stroke:#c0392b,color:#fff +``` + +## Trách Nhiệm Các Lớp + +### 1. Lớp Domain (ChatService.Domain) + +Trái tim của ứng dụng chứa business logic thuần túy. Lớp này: +- Có **ZERO** phụ thuộc bên ngoài (ngoại trừ MediatR.Contracts cho events) +- Chỉ chứa các class POCO +- Triển khai các tactical patterns của DDD + +#### Thành Phần + +| Thành phần | Mục Đích | +|------------|----------| +| **SeedWork** | Base classes: Entity, ValueObject, Enumeration, IAggregateRoot | +| **AggregatesModel** | Aggregate roots với entities và value objects | +| **Events** | Domain events cho giao tiếp cross-aggregate | +| **Exceptions** | Domain exceptions cho vi phạm business rules | + +### 2. Lớp Infrastructure (ChatService.Infrastructure) + +Triển khai kỹ thuật và các mối quan tâm bên ngoài: +- Truy cập database (EF Core) +- Triển khai repositories +- Tích hợp external services + +### 3. Lớp API (ChatService.API) + +Điểm vào ứng dụng và triển khai CQRS: +- Controllers để xử lý HTTP +- Commands cho các thao tác ghi +- Queries cho các thao tác đọc +- MediatR behaviors cho cross-cutting concerns + +## Luồng CQRS + +```mermaid +sequenceDiagram + participant Client + participant Controller + participant MediatR + participant LoggingBehavior + participant ValidatorBehavior + participant TransactionBehavior + participant CommandHandler + participant Repository + participant DbContext + + Client->>Controller: HTTP Request + Controller->>MediatR: Send(Command) + MediatR->>LoggingBehavior: Handle + LoggingBehavior->>ValidatorBehavior: Next() + ValidatorBehavior->>TransactionBehavior: Next() + TransactionBehavior->>CommandHandler: Next() + CommandHandler->>Repository: Add/Update/Delete + Repository->>DbContext: SaveEntitiesAsync() + DbContext-->>Repository: Success + Repository-->>CommandHandler: Result + CommandHandler-->>Controller: Response + Controller-->>Client: HTTP Response +``` + +## Domain Events + +```mermaid +graph LR + AR[Aggregate Root] -->|Phát sinh| DE[Domain Event] + DE -->|Dispatch bởi| CTX[DbContext] + CTX -->|Publish tới| M[MediatR] + M -->|Xử lý bởi| H1[Handler 1] + M -->|Xử lý bởi| H2[Handler 2] + + style AR fill:#50c878,stroke:#2d8659,color:#fff + style DE fill:#f39c12,stroke:#d68910,color:#fff + style M fill:#9b59b6,stroke:#7d3c98,color:#fff +``` + +## Schema Database + +### Sample Aggregate + +```mermaid +erDiagram + samples { + uuid id PK + varchar(200) name + varchar(1000) description + int status_id FK + timestamp created_at + timestamp updated_at + } + + sample_statuses { + int id PK + varchar(50) name + } + + samples ||--o{ sample_statuses : has +``` + +## Pipeline MediatR + +``` +Request → LoggingBehavior → ValidatorBehavior → TransactionBehavior → Handler → Response + │ │ │ + ▼ ▼ ▼ + Log start/end Validate Begin/Commit + + timing với Transaction + FluentValidation +``` + +### Thứ Tự Behaviors + +1. **LoggingBehavior** - Ghi log xử lý request với timing +2. **ValidatorBehavior** - Validate request sử dụng FluentValidation +3. **TransactionBehavior** - Bao bọc command handlers trong database transactions + +## Xử Lý Lỗi + +### Phân Cấp Exceptions + +``` +Exception +└── DomainException + └── SampleDomainException +``` + +### Problem Details (RFC 7807) + +Tất cả lỗi được trả về theo định dạng Problem Details: + +```json +{ + "type": "https://tools.ietf.org/html/rfc7807", + "title": "Lỗi Validation", + "status": 400, + "detail": "Một hoặc nhiều lỗi validation đã xảy ra.", + "errors": { + "Name": ["Tên là bắt buộc"] + } +} +``` + +## Health Checks + +```mermaid +graph TD + HC[Health Check Endpoint] + HC --> |/health/live| L[Liveness] + HC --> |/health/ready| R[Readiness] + HC --> |/health| F[Full Status] + + R --> PG[(PostgreSQL)] + R --> RD[(Redis)] + + style HC fill:#3498db,stroke:#2980b9,color:#fff + style L fill:#2ecc71,stroke:#27ae60,color:#fff + style R fill:#f39c12,stroke:#d68910,color:#fff +``` + +## Kiến Trúc Deployment + +### Docker Compose (Local) + +```yaml +services: + myservice-api: + build: . + ports: ["5000:8080"] + depends_on: + - postgres + - redis + + postgres: + image: postgres:16-alpine + + redis: + image: redis:7-alpine +``` + +### Kubernetes (Production) + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: myservice-api +spec: + replicas: 3 + template: + spec: + containers: + - name: api + image: myservice:latest + ports: + - containerPort: 8080 + livenessProbe: + httpGet: + path: /health/live + port: 8080 + readinessProbe: + httpGet: + path: /health/ready + port: 8080 +``` + +## Cân Nhắc Bảo Mật + +1. **Authentication**: JWT Bearer token (cấu hình trong production) +2. **Authorization**: Role-based access control +3. **Input Validation**: FluentValidation trên tất cả requests +4. **SQL Injection**: EF Core parameterized queries +5. **Secrets**: Biến môi trường, không bao giờ trong code + +## Tối Ưu Hiệu Năng + +1. **Connection Pooling**: EF Core với Npgsql connection resilience +2. **Async/Await**: Tất cả I/O operations đều async +3. **Response Caching**: Thêm caching headers cho queries +4. **Database Indexes**: Cấu hình trong EntityConfigurations + +## Tài Liệu Tham Khảo + +- [eShopOnContainers](https://github.com/dotnet-architecture/eShopOnContainers) +- [Hướng dẫn Kiến trúc .NET Microservices](https://docs.microsoft.com/en-us/dotnet/architecture/microservices/) +- [Domain-Driven Design](https://martinfowler.com/bliki/DomainDrivenDesign.html) +- [CQRS Pattern](https://docs.microsoft.com/en-us/azure/architecture/patterns/cqrs) diff --git a/services/chat-service-net/docs/vi/README.md b/services/chat-service-net/docs/vi/README.md new file mode 100644 index 00000000..3cd0a304 --- /dev/null +++ b/services/chat-service-net/docs/vi/README.md @@ -0,0 +1,265 @@ +# Template Microservice .NET 10 + +> Template microservice .NET 10 cấp doanh nghiệp theo các pattern DDD, CQRS và Clean Architecture. + +## Tổng Quan + +Template này cung cấp cấu trúc sẵn sàng production cho microservices .NET dựa trên kiến trúc tham chiếu eShopOnContainers với: + +- **Domain-Driven Design (DDD)** - Aggregates, Entities, Value Objects, Domain Events +- **CQRS Pattern** - Tách biệt Commands (ghi) và Queries (đọc) với MediatR +- **Clean Architecture** - Phân tầng Domain, Infrastructure, API +- **EF Core 10** - PostgreSQL với connection resilience +- **FluentValidation** - Validation request +- **API Versioning** - Versioning theo URL segment +- **Health Checks** - Probes sẵn sàng cho Kubernetes +- **Structured Logging** - Serilog với console và Seq + +## Yêu Cầu + +| Yêu cầu | Phiên bản | +|---------|-----------| +| .NET SDK | 10.0.101+ | +| Docker | 24.0+ | +| PostgreSQL | 15+ (hoặc dùng Docker) | + +```bash +# Kiểm tra phiên bản .NET +dotnet --version +# Kết quả nên là: 10.0.xxx +``` + +## Bắt Đầu Nhanh + +### 1. Tạo Service Mới + +```bash +# Sao chép template sang service mới +cp -r services/_template_dot_net services/your-service-name + +# Di chuyển đến thư mục service +cd services/your-service-name + +# Đổi tên tất cả "ChatService" thành "YourService" +find . -type f -name "*.cs" -exec sed -i '' 's/ChatService/YourService/g' {} + +find . -type f -name "*.csproj" -exec sed -i '' 's/ChatService/YourService/g' {} + +``` + +### 2. Cấu Hình Môi Trường + +```bash +# Sao chép template môi trường +cp .env.example .env + +# Chỉnh sửa với cấu hình của bạn +nano .env +``` + +### 3. Chạy với Docker + +```bash +# Khởi động tất cả services (API + PostgreSQL + Redis) +docker-compose up -d + +# Xem logs +docker-compose logs -f myservice-api +``` + +### 4. Chạy Local + +```bash +# Khôi phục dependencies +dotnet restore + +# Build tất cả projects +dotnet build + +# Chạy API +dotnet run --project src/ChatService.API +``` + +## Cấu Trúc Dự Án + +``` +_template_dot_net/ +├── src/ +│ ├── ChatService.API/ # Lớp Presentation (Controllers, CQRS) +│ │ ├── Controllers/ # Các API endpoints +│ │ ├── Application/ # Triển khai CQRS +│ │ │ ├── Commands/ # Thao tác ghi (MediatR) +│ │ │ ├── Queries/ # Thao tác đọc +│ │ │ ├── Behaviors/ # MediatR pipeline behaviors +│ │ │ └── Validations/ # FluentValidation validators +│ │ ├── Middleware/ # Custom middleware +│ │ └── Program.cs # Điểm vào ứng dụng +│ │ +│ ├── ChatService.Domain/ # Lớp Domain (Business logic thuần túy) +│ │ ├── AggregatesModel/ # Aggregate roots và entities +│ │ ├── Events/ # Domain events +│ │ ├── Exceptions/ # Domain exceptions +│ │ └── SeedWork/ # Base classes (Entity, ValueObject, etc.) +│ │ +│ └── ChatService.Infrastructure/ # Lớp Infrastructure (Truy cập dữ liệu) +│ ├── EntityConfigurations/ # Cấu hình EF Core Fluent API +│ ├── Repositories/ # Triển khai repositories +│ ├── Idempotency/ # Xử lý idempotency request +│ └── ChatServiceContext.cs # DbContext với Unit of Work +│ +├── tests/ +│ ├── ChatService.UnitTests/ # Unit tests (Domain, Application) +│ └── ChatService.FunctionalTests/ # Integration tests (API endpoints) +│ +├── Dockerfile # Multi-stage Docker build +├── docker-compose.yml # Thiết lập phát triển local +├── global.json # Pin phiên bản .NET SDK +└── Directory.Build.props # Thuộc tính MSBuild chung +``` + +## Các Endpoint API + +| Method | Endpoint | Mô Tả | +|--------|----------|-------| +| `GET` | `/api/v1/samples` | Lấy tất cả samples | +| `GET` | `/api/v1/samples/{id}` | Lấy sample theo ID | +| `POST` | `/api/v1/samples` | Tạo sample mới | +| `PUT` | `/api/v1/samples/{id}` | Cập nhật sample | +| `DELETE` | `/api/v1/samples/{id}` | Xóa sample | +| `PATCH` | `/api/v1/samples/{id}/status` | Thay đổi trạng thái | + +### Health Endpoints + +| Endpoint | Mục Đích | +|----------|----------| +| `/health` | Trạng thái health đầy đủ | +| `/health/live` | Kiểm tra sống | +| `/health/ready` | Kiểm tra sẵn sàng | + +## Pattern CQRS + +### Commands (Thao Tác Ghi) + +```csharp +// Định nghĩa command +public record CreateSampleCommand(string Name, string? Description) + : IRequest; + +// Xử lý command +public class CreateSampleCommandHandler : IRequestHandler +{ + public async Task Handle(CreateSampleCommand request, CancellationToken ct) + { + var sample = new Sample(request.Name, request.Description); + _repository.Add(sample); + await _repository.UnitOfWork.SaveEntitiesAsync(ct); + return new CreateSampleCommandResult(sample.Id); + } +} +``` + +### Queries (Thao Tác Đọc) + +```csharp +// Định nghĩa query +public record GetSampleQuery(Guid SampleId) : IRequest; +``` + +## Domain Model + +### Aggregate Root + +```csharp +public class Sample : Entity, IAggregateRoot +{ + public string Name => _name; + public SampleStatus Status => _status; + + public Sample(string name, string? description) { + // Validation business logic + if (string.IsNullOrWhiteSpace(name)) + throw new SampleDomainException("Tên sample không được để trống"); + + // Domain event + AddDomainEvent(new SampleCreatedDomainEvent(this)); + } + + public void Activate() { + if (_status != SampleStatus.Draft) + throw new SampleDomainException("Chỉ sample draft mới có thể kích hoạt"); + // Chuyển đổi trạng thái + } +} +``` + +## Kiểm Thử + +```bash +# Chạy tất cả tests +dotnet test + +# Chạy với coverage +dotnet test /p:CollectCoverage=true /p:CoverageReportFormat=cobertura + +# Chạy project test cụ thể +dotnet test tests/ChatService.UnitTests +``` + +## Cấu Hình + +### Biến Môi Trường + +| Biến | Mô Tả | Mặc định | +|------|-------|----------| +| `ASPNETCORE_ENVIRONMENT` | Tên môi trường | `Development` | +| `DATABASE_URL` | Connection string PostgreSQL | - | +| `REDIS_URL` | Connection string Redis | - | +| `JWT_SECRET` | Secret ký JWT (tối thiểu 32 ký tự) | - | + +### appsettings.json + +```json +{ + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Database=myservice;Username=postgres;Password=postgres" + }, + "Serilog": { + "MinimumLevel": "Information" + } +} +``` + +## Triển Khai + +### Docker Build + +```bash +# Build Docker image +docker build -t myservice:latest . + +# Chạy container +docker run -p 5000:8080 --env-file .env myservice:latest +``` + +### Kubernetes + +Xem [ARCHITECTURE.md](./ARCHITECTURE.md) để biết manifests triển khai Kubernetes. + +## Có Gì Mới Trong .NET 10 + +- Tính năng ngôn ngữ **C# 14** +- Hỗ trợ **Native AOT** được cải thiện +- Hiệu suất **async/await** tốt hơn +- **JSON serialization** được nâng cao +- Cải thiện hiệu suất toàn diện +- Hỗ trợ **LTS** 3 năm (đến tháng 11/2028) + +## Tài Nguyên + +- [eShopOnContainers](https://github.com/dotnet-architecture/eShopOnContainers) - Kiến trúc tham chiếu +- [Tài liệu .NET 10](https://docs.microsoft.com/en-us/dotnet/core/whats-new/dotnet-10) +- [DDD với .NET](https://docs.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/) +- [MediatR](https://github.com/jbogard/MediatR) - Thư viện CQRS +- [FluentValidation](https://docs.fluentvalidation.net/) - Thư viện validation + +## Giấy Phép + +Độc quyền - GoodGo Platform diff --git a/services/chat-service-net/global.json b/services/chat-service-net/global.json new file mode 100644 index 00000000..f78eeaf4 --- /dev/null +++ b/services/chat-service-net/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "10.0.101", + "rollForward": "latestMinor", + "allowPrerelease": false + } +} \ No newline at end of file diff --git a/services/chat-service-net/src/ChatService.API/Application/Behaviors/LoggingBehavior.cs b/services/chat-service-net/src/ChatService.API/Application/Behaviors/LoggingBehavior.cs new file mode 100644 index 00000000..b2677c38 --- /dev/null +++ b/services/chat-service-net/src/ChatService.API/Application/Behaviors/LoggingBehavior.cs @@ -0,0 +1,58 @@ +using System.Diagnostics; +using MediatR; + +namespace ChatService.API.Application.Behaviors; + +/// +/// EN: MediatR behavior for logging request handling. +/// VI: MediatR behavior để logging việc xử lý request. +/// +/// EN: Request type / VI: Loại request +/// EN: Response type / VI: Loại response +public class LoggingBehavior : IPipelineBehavior + where TRequest : IRequest +{ + private readonly ILogger> _logger; + + public LoggingBehavior(ILogger> logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + var requestName = typeof(TRequest).Name; + + _logger.LogInformation( + "Handling {RequestName} / Đang xử lý {RequestName}", + requestName); + + var stopwatch = Stopwatch.StartNew(); + + try + { + var response = await next(); + + stopwatch.Stop(); + + _logger.LogInformation( + "Handled {RequestName} in {ElapsedMs}ms / Đã xử lý {RequestName} trong {ElapsedMs}ms", + requestName, stopwatch.ElapsedMilliseconds); + + return response; + } + catch (Exception ex) + { + stopwatch.Stop(); + + _logger.LogError(ex, + "Error handling {RequestName} after {ElapsedMs}ms / Lỗi xử lý {RequestName} sau {ElapsedMs}ms", + requestName, stopwatch.ElapsedMilliseconds); + + throw; + } + } +} diff --git a/services/chat-service-net/src/ChatService.API/Application/Behaviors/TransactionBehavior.cs b/services/chat-service-net/src/ChatService.API/Application/Behaviors/TransactionBehavior.cs new file mode 100644 index 00000000..47687bc4 --- /dev/null +++ b/services/chat-service-net/src/ChatService.API/Application/Behaviors/TransactionBehavior.cs @@ -0,0 +1,84 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using ChatService.Infrastructure; + +namespace ChatService.API.Application.Behaviors; + +/// +/// EN: MediatR behavior for handling database transactions. +/// VI: MediatR behavior để xử lý database transactions. +/// +/// EN: Request type / VI: Loại request +/// EN: Response type / VI: Loại response +public class TransactionBehavior : IPipelineBehavior + where TRequest : IRequest +{ + private readonly ChatServiceContext _dbContext; + private readonly ILogger> _logger; + + public TransactionBehavior( + ChatServiceContext dbContext, + ILogger> logger) + { + _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + var requestName = typeof(TRequest).Name; + + // EN: Skip transaction for queries (read operations) + // VI: Bỏ qua transaction cho queries (các thao tác đọc) + if (requestName.EndsWith("Query")) + { + return await next(); + } + + // EN: Skip if already in a transaction + // VI: Bỏ qua nếu đã trong transaction + if (_dbContext.HasActiveTransaction) + { + return await next(); + } + + var strategy = _dbContext.Database.CreateExecutionStrategy(); + + return await strategy.ExecuteAsync(async () => + { + await using var transaction = await _dbContext.BeginTransactionAsync(); + + _logger.LogInformation( + "Begin transaction {TransactionId} for {RequestName} / Bắt đầu transaction {TransactionId} cho {RequestName}", + transaction?.TransactionId, requestName); + + try + { + var response = await next(); + + if (transaction != null) + { + await _dbContext.CommitTransactionAsync(transaction); + + _logger.LogInformation( + "Committed transaction {TransactionId} for {RequestName} / Đã commit transaction {TransactionId} cho {RequestName}", + transaction.TransactionId, requestName); + } + + return response; + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error during transaction {TransactionId} for {RequestName} / Lỗi trong transaction {TransactionId} cho {RequestName}", + transaction?.TransactionId, requestName); + + _dbContext.RollbackTransaction(); + throw; + } + }); + } +} diff --git a/services/chat-service-net/src/ChatService.API/Application/Behaviors/ValidatorBehavior.cs b/services/chat-service-net/src/ChatService.API/Application/Behaviors/ValidatorBehavior.cs new file mode 100644 index 00000000..acf51acb --- /dev/null +++ b/services/chat-service-net/src/ChatService.API/Application/Behaviors/ValidatorBehavior.cs @@ -0,0 +1,63 @@ +using FluentValidation; +using MediatR; + +namespace ChatService.API.Application.Behaviors; + +/// +/// EN: MediatR behavior for FluentValidation integration. +/// VI: MediatR behavior để tích hợp FluentValidation. +/// +/// EN: Request type / VI: Loại request +/// EN: Response type / VI: Loại response +public class ValidatorBehavior : IPipelineBehavior + where TRequest : IRequest +{ + private readonly IEnumerable> _validators; + private readonly ILogger> _logger; + + public ValidatorBehavior( + IEnumerable> validators, + ILogger> logger) + { + _validators = validators ?? throw new ArgumentNullException(nameof(validators)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + var requestName = typeof(TRequest).Name; + + if (!_validators.Any()) + { + return await next(); + } + + _logger.LogDebug( + "Validating {RequestName} / Đang validate {RequestName}", + requestName); + + var context = new ValidationContext(request); + + var validationResults = await Task.WhenAll( + _validators.Select(v => v.ValidateAsync(context, cancellationToken))); + + var failures = validationResults + .SelectMany(r => r.Errors) + .Where(f => f != null) + .ToList(); + + if (failures.Count != 0) + { + _logger.LogWarning( + "Validation failed for {RequestName} with {ErrorCount} errors / Validation thất bại cho {RequestName} với {ErrorCount} lỗi", + requestName, failures.Count); + + throw new ValidationException(failures); + } + + return await next(); + } +} diff --git a/services/chat-service-net/src/ChatService.API/Application/Commands/Conversations/CreateConversationCommand.cs b/services/chat-service-net/src/ChatService.API/Application/Commands/Conversations/CreateConversationCommand.cs new file mode 100644 index 00000000..dc5223b3 --- /dev/null +++ b/services/chat-service-net/src/ChatService.API/Application/Commands/Conversations/CreateConversationCommand.cs @@ -0,0 +1,30 @@ +using MediatR; + +namespace ChatService.API.Application.Commands.Conversations; + +/// +/// EN: Command to create a new conversation. +/// VI: Command để tạo cuộc hội thoại mới. +/// +public record CreateConversationCommand( + Guid CreatorId, + IEnumerable ParticipantIds, + string? Name = null, + string? AvatarUrl = null, + bool IsGroup = false +) : IRequest; + +public record CreateConversationResult( + Guid ConversationId, + string Type, + string? Name, + IEnumerable Participants, + DateTime CreatedAt +); + +public record ParticipantDto( + Guid ChatUserId, + string DisplayName, + string? AvatarUrl, + string Role +); diff --git a/services/chat-service-net/src/ChatService.API/Application/Commands/Conversations/CreateConversationCommandHandler.cs b/services/chat-service-net/src/ChatService.API/Application/Commands/Conversations/CreateConversationCommandHandler.cs new file mode 100644 index 00000000..43b9fdb2 --- /dev/null +++ b/services/chat-service-net/src/ChatService.API/Application/Commands/Conversations/CreateConversationCommandHandler.cs @@ -0,0 +1,100 @@ +using MediatR; +using ChatService.Domain.AggregatesModel.ConversationAggregate; +using ChatService.Domain.AggregatesModel.UserAggregate; + +namespace ChatService.API.Application.Commands.Conversations; + +/// +/// EN: Handler for CreateConversationCommand. +/// VI: Handler cho CreateConversationCommand. +/// +public class CreateConversationCommandHandler : IRequestHandler +{ + private readonly IConversationRepository _conversationRepository; + private readonly IChatUserRepository _chatUserRepository; + private readonly ILogger _logger; + + public CreateConversationCommandHandler( + IConversationRepository conversationRepository, + IChatUserRepository chatUserRepository, + ILogger logger) + { + _conversationRepository = conversationRepository; + _chatUserRepository = chatUserRepository; + _logger = logger; + } + + public async Task Handle(CreateConversationCommand request, CancellationToken cancellationToken) + { + _logger.LogInformation("Creating conversation for creator {CreatorId} with {ParticipantCount} participants", + request.CreatorId, request.ParticipantIds.Count()); + + // EN: Validate all participants exist + // VI: Kiểm tra tất cả participants tồn tại + var allParticipantIds = request.ParticipantIds.Append(request.CreatorId).Distinct().ToList(); + var users = await _chatUserRepository.GetByIdsAsync(allParticipantIds, cancellationToken); + var userDict = users.ToDictionary(u => u.Id); + + if (userDict.Count != allParticipantIds.Count) + { + var missingIds = allParticipantIds.Where(id => !userDict.ContainsKey(id)); + throw new InvalidOperationException($"ChatUsers not found: {string.Join(", ", missingIds)}"); + } + + Conversation conversation; + + // EN: Check for existing direct conversation + // VI: Kiểm tra direct conversation đã tồn tại + if (!request.IsGroup && request.ParticipantIds.Count() == 1) + { + var existingConversation = await _conversationRepository.FindDirectConversationAsync( + request.CreatorId, request.ParticipantIds.First(), cancellationToken); + + if (existingConversation != null) + { + _logger.LogInformation("Found existing direct conversation {ConversationId}", existingConversation.Id); + return CreateResult(existingConversation, userDict); + } + + // EN: Create new direct conversation + // VI: Tạo direct conversation mới + conversation = Conversation.CreateDirect(request.CreatorId, request.ParticipantIds.First()); + } + else + { + // EN: Create new group conversation + // VI: Tạo group conversation mới + conversation = Conversation.CreateGroup( + request.Name ?? "Group Chat", + request.CreatorId, + request.ParticipantIds, + request.AvatarUrl); + } + + _conversationRepository.Add(conversation); + await _conversationRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation("Created conversation {ConversationId} of type {Type}", + conversation.Id, conversation.Type.Name); + + return CreateResult(conversation, userDict); + } + + private static CreateConversationResult CreateResult(Conversation conversation, Dictionary userDict) + { + var participants = conversation.Participants.Select(p => new ParticipantDto( + p.UserId, + userDict.TryGetValue(p.UserId, out var user) ? user.DisplayName : "Unknown", + userDict.TryGetValue(p.UserId, out var u) ? u.AvatarUrl : null, + p.IsAdmin ? "admin" : "member" + )).ToList(); + + return new CreateConversationResult( + conversation.Id, + conversation.Type.Name, + conversation.Name, + participants, + conversation.CreatedAt + ); + } +} diff --git a/services/chat-service-net/src/ChatService.API/Application/Commands/Keys/RegisterUserKeysCommand.cs b/services/chat-service-net/src/ChatService.API/Application/Commands/Keys/RegisterUserKeysCommand.cs new file mode 100644 index 00000000..cafd4887 --- /dev/null +++ b/services/chat-service-net/src/ChatService.API/Application/Commands/Keys/RegisterUserKeysCommand.cs @@ -0,0 +1,24 @@ +using MediatR; + +namespace ChatService.API.Application.Commands.Keys; + +/// +/// EN: Command to register a user's E2EE key bundle. +/// VI: Command để đăng ký E2EE key bundle của user. +/// +public record RegisterUserKeysCommand( + string IdentityUserId, + string DisplayName, + string? AvatarUrl, + string IdentityPublicKey, + string SignedPreKey, + string SignedPreKeySignature, + IEnumerable? OneTimePreKeys = null +) : IRequest; + +public record OneTimePreKeyDto(int KeyId, string PublicKey); + +public record RegisterUserKeysResult( + Guid ChatUserId, + int OneTimePreKeysUploaded +); diff --git a/services/chat-service-net/src/ChatService.API/Application/Commands/Keys/RegisterUserKeysCommandHandler.cs b/services/chat-service-net/src/ChatService.API/Application/Commands/Keys/RegisterUserKeysCommandHandler.cs new file mode 100644 index 00000000..2188bc61 --- /dev/null +++ b/services/chat-service-net/src/ChatService.API/Application/Commands/Keys/RegisterUserKeysCommandHandler.cs @@ -0,0 +1,84 @@ +using MediatR; +using ChatService.Domain.AggregatesModel.UserAggregate; + +namespace ChatService.API.Application.Commands.Keys; + +/// +/// EN: Handler for RegisterUserKeysCommand. +/// VI: Handler cho RegisterUserKeysCommand. +/// +public class RegisterUserKeysCommandHandler : IRequestHandler +{ + private readonly IChatUserRepository _chatUserRepository; + private readonly ILogger _logger; + + public RegisterUserKeysCommandHandler( + IChatUserRepository chatUserRepository, + ILogger logger) + { + _chatUserRepository = chatUserRepository; + _logger = logger; + } + + public async Task Handle(RegisterUserKeysCommand request, CancellationToken cancellationToken) + { + _logger.LogInformation("Registering keys for user {IdentityUserId}", request.IdentityUserId); + + // EN: Check if user already exists + // VI: Kiểm tra xem user đã tồn tại chưa + var existingUser = await _chatUserRepository.GetByIdentityUserIdAsync(request.IdentityUserId, cancellationToken); + + if (existingUser != null) + { + // EN: Update existing user's keys + // VI: Cập nhật keys của user đã tồn tại + existingUser.RegisterKeyBundle( + request.IdentityPublicKey, + request.SignedPreKey, + request.SignedPreKeySignature); + + if (request.OneTimePreKeys?.Any() == true) + { + existingUser.UploadOneTimePreKeys( + request.OneTimePreKeys.Select(k => (k.KeyId, k.PublicKey))); + } + + _chatUserRepository.Update(existingUser); + await _chatUserRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation("Updated keys for existing user {ChatUserId}", existingUser.Id); + + return new RegisterUserKeysResult( + existingUser.Id, + request.OneTimePreKeys?.Count() ?? 0); + } + + // EN: Create new chat user + // VI: Tạo chat user mới + var chatUser = new ChatUser( + request.IdentityUserId, + request.DisplayName, + request.AvatarUrl); + + chatUser.RegisterKeyBundle( + request.IdentityPublicKey, + request.SignedPreKey, + request.SignedPreKeySignature); + + if (request.OneTimePreKeys?.Any() == true) + { + chatUser.UploadOneTimePreKeys( + request.OneTimePreKeys.Select(k => (k.KeyId, k.PublicKey))); + } + + _chatUserRepository.Add(chatUser); + await _chatUserRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation("Created new chat user {ChatUserId} for {IdentityUserId}", + chatUser.Id, request.IdentityUserId); + + return new RegisterUserKeysResult( + chatUser.Id, + request.OneTimePreKeys?.Count() ?? 0); + } +} diff --git a/services/chat-service-net/src/ChatService.API/Application/Commands/Keys/RotatePreKeyCommand.cs b/services/chat-service-net/src/ChatService.API/Application/Commands/Keys/RotatePreKeyCommand.cs new file mode 100644 index 00000000..fced381f --- /dev/null +++ b/services/chat-service-net/src/ChatService.API/Application/Commands/Keys/RotatePreKeyCommand.cs @@ -0,0 +1,17 @@ +using MediatR; + +namespace ChatService.API.Application.Commands.Keys; + +/// +/// EN: Command to rotate the signed pre-key. +/// VI: Command để xoay vòng signed pre-key. +/// +public record RotatePreKeyCommand( + Guid ChatUserId, + string NewSignedPreKey, + string NewSignedPreKeySignature +) : IRequest; + +public record RotatePreKeyResult( + DateTime RotatedAt +); diff --git a/services/chat-service-net/src/ChatService.API/Application/Commands/Keys/RotatePreKeyCommandHandler.cs b/services/chat-service-net/src/ChatService.API/Application/Commands/Keys/RotatePreKeyCommandHandler.cs new file mode 100644 index 00000000..256d2a05 --- /dev/null +++ b/services/chat-service-net/src/ChatService.API/Application/Commands/Keys/RotatePreKeyCommandHandler.cs @@ -0,0 +1,43 @@ +using MediatR; +using ChatService.Domain.AggregatesModel.UserAggregate; + +namespace ChatService.API.Application.Commands.Keys; + +/// +/// EN: Handler for RotatePreKeyCommand. +/// VI: Handler cho RotatePreKeyCommand. +/// +public class RotatePreKeyCommandHandler : IRequestHandler +{ + private readonly IChatUserRepository _chatUserRepository; + private readonly ILogger _logger; + + public RotatePreKeyCommandHandler( + IChatUserRepository chatUserRepository, + ILogger logger) + { + _chatUserRepository = chatUserRepository; + _logger = logger; + } + + public async Task Handle(RotatePreKeyCommand request, CancellationToken cancellationToken) + { + _logger.LogInformation("Rotating pre-key for user {ChatUserId}", request.ChatUserId); + + var user = await _chatUserRepository.GetByIdAsync(request.ChatUserId, cancellationToken); + + if (user == null) + { + throw new InvalidOperationException($"Chat user {request.ChatUserId} not found"); + } + + user.RotateSignedPreKey(request.NewSignedPreKey, request.NewSignedPreKeySignature); + + _chatUserRepository.Update(user); + await _chatUserRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation("Pre-key rotated for user {ChatUserId}", request.ChatUserId); + + return new RotatePreKeyResult(DateTime.UtcNow); + } +} diff --git a/services/chat-service-net/src/ChatService.API/Application/Commands/Keys/UploadOneTimeKeysCommand.cs b/services/chat-service-net/src/ChatService.API/Application/Commands/Keys/UploadOneTimeKeysCommand.cs new file mode 100644 index 00000000..85fea1f3 --- /dev/null +++ b/services/chat-service-net/src/ChatService.API/Application/Commands/Keys/UploadOneTimeKeysCommand.cs @@ -0,0 +1,17 @@ +using MediatR; + +namespace ChatService.API.Application.Commands.Keys; + +/// +/// EN: Command to upload one-time pre-keys. +/// VI: Command để upload các one-time pre-keys. +/// +public record UploadOneTimeKeysCommand( + Guid ChatUserId, + IEnumerable OneTimePreKeys +) : IRequest; + +public record UploadOneTimeKeysResult( + int KeysUploaded, + int TotalAvailableKeys +); diff --git a/services/chat-service-net/src/ChatService.API/Application/Commands/Keys/UploadOneTimeKeysCommandHandler.cs b/services/chat-service-net/src/ChatService.API/Application/Commands/Keys/UploadOneTimeKeysCommandHandler.cs new file mode 100644 index 00000000..318afd6c --- /dev/null +++ b/services/chat-service-net/src/ChatService.API/Application/Commands/Keys/UploadOneTimeKeysCommandHandler.cs @@ -0,0 +1,50 @@ +using MediatR; +using ChatService.Domain.AggregatesModel.UserAggregate; + +namespace ChatService.API.Application.Commands.Keys; + +/// +/// EN: Handler for UploadOneTimeKeysCommand. +/// VI: Handler cho UploadOneTimeKeysCommand. +/// +public class UploadOneTimeKeysCommandHandler : IRequestHandler +{ + private readonly IChatUserRepository _chatUserRepository; + private readonly ILogger _logger; + + public UploadOneTimeKeysCommandHandler( + IChatUserRepository chatUserRepository, + ILogger logger) + { + _chatUserRepository = chatUserRepository; + _logger = logger; + } + + public async Task Handle(UploadOneTimeKeysCommand request, CancellationToken cancellationToken) + { + _logger.LogInformation("Uploading {Count} one-time keys for user {ChatUserId}", + request.OneTimePreKeys.Count(), request.ChatUserId); + + var user = await _chatUserRepository.GetWithKeysAsync(request.ChatUserId, cancellationToken); + + if (user == null) + { + throw new InvalidOperationException($"Chat user {request.ChatUserId} not found"); + } + + user.UploadOneTimePreKeys( + request.OneTimePreKeys.Select(k => (k.KeyId, k.PublicKey))); + + _chatUserRepository.Update(user); + await _chatUserRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + var totalAvailable = user.GetAvailableOneTimePreKeyCount(); + + _logger.LogInformation("Uploaded {Count} keys, total available: {Total}", + request.OneTimePreKeys.Count(), totalAvailable); + + return new UploadOneTimeKeysResult( + request.OneTimePreKeys.Count(), + totalAvailable); + } +} diff --git a/services/chat-service-net/src/ChatService.API/Application/Commands/Messages/MarkMessagesReadCommand.cs b/services/chat-service-net/src/ChatService.API/Application/Commands/Messages/MarkMessagesReadCommand.cs new file mode 100644 index 00000000..888321d6 --- /dev/null +++ b/services/chat-service-net/src/ChatService.API/Application/Commands/Messages/MarkMessagesReadCommand.cs @@ -0,0 +1,19 @@ +using MediatR; + +namespace ChatService.API.Application.Commands.Messages; + +/// +/// EN: Command to mark messages as read. +/// VI: Command để đánh dấu tin nhắn đã đọc. +/// +public record MarkMessagesReadCommand( + Guid ConversationId, + Guid UserId, + Guid? LastReadMessageId = null, + DateTime? ReadUpTo = null +) : IRequest; + +public record MarkMessagesReadResult( + int MessagesMarked, + DateTime? LastReadAt +); diff --git a/services/chat-service-net/src/ChatService.API/Application/Commands/Messages/MarkMessagesReadCommandHandler.cs b/services/chat-service-net/src/ChatService.API/Application/Commands/Messages/MarkMessagesReadCommandHandler.cs new file mode 100644 index 00000000..d0a682c6 --- /dev/null +++ b/services/chat-service-net/src/ChatService.API/Application/Commands/Messages/MarkMessagesReadCommandHandler.cs @@ -0,0 +1,103 @@ +using MediatR; +using ChatService.Domain.AggregatesModel.ConversationAggregate; + +namespace ChatService.API.Application.Commands.Messages; + +/// +/// EN: Handler for MarkMessagesReadCommand. +/// VI: Handler cho MarkMessagesReadCommand. +/// +public class MarkMessagesReadCommandHandler : IRequestHandler +{ + private readonly IConversationRepository _conversationRepository; + private readonly ILogger _logger; + + public MarkMessagesReadCommandHandler( + IConversationRepository conversationRepository, + ILogger logger) + { + _conversationRepository = conversationRepository; + _logger = logger; + } + + public async Task Handle(MarkMessagesReadCommand request, CancellationToken cancellationToken) + { + _logger.LogInformation("Marking messages as read for user {UserId} in conversation {ConversationId}", + request.UserId, request.ConversationId); + + var conversation = await _conversationRepository.GetByIdWithMessagesAsync(request.ConversationId, cancellationToken); + + if (conversation == null) + { + throw new InvalidOperationException($"Conversation {request.ConversationId} not found"); + } + + // EN: Get participant + // VI: Lấy participant + var participant = conversation.Participants.FirstOrDefault(p => p.UserId == request.UserId && p.IsActive); + if (participant == null) + { + throw new InvalidOperationException("User is not a participant in this conversation"); + } + + int markedCount = 0; + DateTime? lastReadAt = null; + + if (request.LastReadMessageId.HasValue) + { + // EN: Mark up to specific message + // VI: Đánh dấu đến tin nhắn cụ thể + participant.UpdateLastRead(request.LastReadMessageId.Value); + lastReadAt = participant.LastReadAt; + + // EN: Count messages marked + // VI: Đếm số tin nhắn đã đánh dấu + var message = conversation.Messages.FirstOrDefault(m => m.Id == request.LastReadMessageId.Value); + if (message != null) + { + markedCount = conversation.Messages + .Count(m => m.SenderId != request.UserId && m.CreatedAt <= message.CreatedAt); + } + } + else if (request.ReadUpTo.HasValue) + { + // EN: Mark all messages up to timestamp + // VI: Đánh dấu tất cả tin nhắn đến thời điểm + var latestMessage = conversation.Messages + .Where(m => m.CreatedAt <= request.ReadUpTo.Value) + .OrderByDescending(m => m.CreatedAt) + .FirstOrDefault(); + + if (latestMessage != null) + { + participant.UpdateLastRead(latestMessage.Id); + lastReadAt = participant.LastReadAt; + markedCount = conversation.Messages + .Count(m => m.SenderId != request.UserId && m.CreatedAt <= request.ReadUpTo.Value); + } + } + else + { + // EN: Mark all messages as read + // VI: Đánh dấu tất cả tin nhắn + var latestMessage = conversation.Messages + .OrderByDescending(m => m.CreatedAt) + .FirstOrDefault(); + + if (latestMessage != null) + { + participant.UpdateLastRead(latestMessage.Id); + lastReadAt = participant.LastReadAt; + markedCount = conversation.Messages + .Count(m => m.SenderId != request.UserId); + } + } + + _conversationRepository.Update(conversation); + await _conversationRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation("Marked {Count} messages as read", markedCount); + + return new MarkMessagesReadResult(markedCount, lastReadAt); + } +} diff --git a/services/chat-service-net/src/ChatService.API/Application/Commands/Messages/SendMessageCommand.cs b/services/chat-service-net/src/ChatService.API/Application/Commands/Messages/SendMessageCommand.cs new file mode 100644 index 00000000..10f6e7f1 --- /dev/null +++ b/services/chat-service-net/src/ChatService.API/Application/Commands/Messages/SendMessageCommand.cs @@ -0,0 +1,30 @@ +using MediatR; + +namespace ChatService.API.Application.Commands.Messages; + +/// +/// EN: Command to send an encrypted message. +/// VI: Command để gửi tin nhắn đã mã hóa. +/// +/// +/// EN: The message content is encrypted on client-side using X3DH + Double Ratchet. +/// VI: Nội dung tin nhắn được mã hóa ở client-side bằng X3DH + Double Ratchet. +/// +public record SendMessageCommand( + Guid ConversationId, + Guid SenderId, + string EncryptedContent, + string Nonce, + string? AuthTag = null, + string MessageType = "text", + string? Metadata = null, + Guid? ReplyToMessageId = null +) : IRequest; + +public record SendMessageResult( + Guid MessageId, + Guid ConversationId, + Guid SenderId, + string Status, + DateTime CreatedAt +); diff --git a/services/chat-service-net/src/ChatService.API/Application/Commands/Messages/SendMessageCommandHandler.cs b/services/chat-service-net/src/ChatService.API/Application/Commands/Messages/SendMessageCommandHandler.cs new file mode 100644 index 00000000..3b44057c --- /dev/null +++ b/services/chat-service-net/src/ChatService.API/Application/Commands/Messages/SendMessageCommandHandler.cs @@ -0,0 +1,67 @@ +using MediatR; +using ChatService.Domain.AggregatesModel.ConversationAggregate; + +namespace ChatService.API.Application.Commands.Messages; + +/// +/// EN: Handler for SendMessageCommand. +/// VI: Handler cho SendMessageCommand. +/// +public class SendMessageCommandHandler : IRequestHandler +{ + private readonly IConversationRepository _conversationRepository; + private readonly ILogger _logger; + + public SendMessageCommandHandler( + IConversationRepository conversationRepository, + ILogger logger) + { + _conversationRepository = conversationRepository; + _logger = logger; + } + + public async Task Handle(SendMessageCommand request, CancellationToken cancellationToken) + { + _logger.LogInformation("Sending message from {SenderId} to conversation {ConversationId}", + request.SenderId, request.ConversationId); + + var conversation = await _conversationRepository.GetByIdAsync(request.ConversationId, cancellationToken); + + if (conversation == null) + { + throw new InvalidOperationException($"Conversation {request.ConversationId} not found"); + } + + // EN: Parse message type + // VI: Parse message type + var messageType = MessageType.FromName(request.MessageType); + if (messageType == null) + { + messageType = MessageType.Text; + } + + // EN: Send encrypted message via Conversation aggregate + // VI: Gửi tin nhắn đã mã hóa qua Conversation aggregate + var message = conversation.SendMessage( + request.SenderId, + request.EncryptedContent, + request.Nonce, + messageType, + request.AuthTag, + request.Metadata, + request.ReplyToMessageId); + + _conversationRepository.Update(conversation); + await _conversationRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation("Message {MessageId} sent to conversation {ConversationId}", + message.Id, conversation.Id); + + return new SendMessageResult( + message.Id, + conversation.Id, + request.SenderId, + message.Status.Name, + message.CreatedAt); + } +} diff --git a/services/chat-service-net/src/ChatService.API/Application/Queries/Conversations/GetConversationsQuery.cs b/services/chat-service-net/src/ChatService.API/Application/Queries/Conversations/GetConversationsQuery.cs new file mode 100644 index 00000000..1c7d1b34 --- /dev/null +++ b/services/chat-service-net/src/ChatService.API/Application/Queries/Conversations/GetConversationsQuery.cs @@ -0,0 +1,48 @@ +using MediatR; + +namespace ChatService.API.Application.Queries.Conversations; + +/// +/// EN: Query to get user's conversations. +/// VI: Query để lấy các cuộc hội thoại của user. +/// +public record GetConversationsQuery( + Guid UserId, + int Page = 1, + int PageSize = 20 +) : IRequest; + +public record GetConversationsResult( + IEnumerable Conversations, + int TotalCount, + int Page, + int PageSize +); + +public record ConversationDto( + Guid Id, + string Type, + string? Name, + string? AvatarUrl, + IEnumerable Participants, + LastMessageDto? LastMessage, + int UnreadCount, + DateTime CreatedAt, + DateTime UpdatedAt +); + +public record ConversationParticipantDto( + Guid ChatUserId, + string DisplayName, + string? AvatarUrl, + string Role, + DateTime? LastReadAt +); + +public record LastMessageDto( + Guid MessageId, + Guid SenderId, + string SenderName, + string Type, + DateTime CreatedAt +); diff --git a/services/chat-service-net/src/ChatService.API/Application/Queries/Conversations/GetConversationsQueryHandler.cs b/services/chat-service-net/src/ChatService.API/Application/Queries/Conversations/GetConversationsQueryHandler.cs new file mode 100644 index 00000000..12c07802 --- /dev/null +++ b/services/chat-service-net/src/ChatService.API/Application/Queries/Conversations/GetConversationsQueryHandler.cs @@ -0,0 +1,102 @@ +using MediatR; +using ChatService.Domain.AggregatesModel.ConversationAggregate; +using ChatService.Domain.AggregatesModel.UserAggregate; + +namespace ChatService.API.Application.Queries.Conversations; + +/// +/// EN: Handler for GetConversationsQuery. +/// VI: Handler cho GetConversationsQuery. +/// +public class GetConversationsQueryHandler : IRequestHandler +{ + private readonly IConversationRepository _conversationRepository; + private readonly IChatUserRepository _chatUserRepository; + private readonly ILogger _logger; + + public GetConversationsQueryHandler( + IConversationRepository conversationRepository, + IChatUserRepository chatUserRepository, + ILogger logger) + { + _conversationRepository = conversationRepository; + _chatUserRepository = chatUserRepository; + _logger = logger; + } + + public async Task Handle(GetConversationsQuery request, CancellationToken cancellationToken) + { + _logger.LogInformation("Getting conversations for user {UserId}, page {Page}", request.UserId, request.Page); + + var (conversations, totalCount) = await _conversationRepository.GetConversationsForUserAsync( + request.UserId, request.Page, request.PageSize, cancellationToken); + + var conversationList = conversations.ToList(); + var conversationDtos = new List(); + + foreach (var conversation in conversationList) + { + // EN: Get participant user info + // VI: Lấy thông tin user của participants + var participantIds = conversation.Participants.Select(p => p.UserId).ToList(); + var users = await _chatUserRepository.GetByIdsAsync(participantIds, cancellationToken); + var userDict = users.ToDictionary(u => u.Id); + + var participantDtos = conversation.Participants.Select(p => new ConversationParticipantDto( + p.UserId, + userDict.TryGetValue(p.UserId, out var user) ? user.DisplayName : "Unknown", + userDict.TryGetValue(p.UserId, out var u) ? u.AvatarUrl : null, + p.IsAdmin ? "admin" : "member", + p.LastReadAt + )).ToList(); + + // EN: Get unread count + // VI: Lấy số tin chưa đọc + var unreadCount = await _conversationRepository.GetUnreadMessageCountAsync( + conversation.Id, request.UserId, cancellationToken); + + // EN: Get last message info + // VI: Lấy thông tin tin nhắn cuối + LastMessageDto? lastMessage = null; + + if (conversation.LastMessageId.HasValue) + { + var messages = await _conversationRepository.GetMessagesAsync( + conversation.Id, 0, 1, null, cancellationToken); + var lastMsg = messages.FirstOrDefault(); + if (lastMsg != null) + { + var senderName = userDict.TryGetValue(lastMsg.SenderId, out var sender) + ? sender.DisplayName + : "Unknown"; + lastMessage = new LastMessageDto( + lastMsg.Id, + lastMsg.SenderId, + senderName, + lastMsg.Type.Name, + lastMsg.CreatedAt + ); + } + } + + conversationDtos.Add(new ConversationDto( + conversation.Id, + conversation.Type.Name, + conversation.Name, + conversation.AvatarUrl, + participantDtos, + lastMessage, + unreadCount, + conversation.CreatedAt, + conversation.UpdatedAt + )); + } + + return new GetConversationsResult( + conversationDtos, + totalCount, + request.Page, + request.PageSize + ); + } +} diff --git a/services/chat-service-net/src/ChatService.API/Application/Queries/Keys/GetMyKeyBundleQuery.cs b/services/chat-service-net/src/ChatService.API/Application/Queries/Keys/GetMyKeyBundleQuery.cs new file mode 100644 index 00000000..a7997903 --- /dev/null +++ b/services/chat-service-net/src/ChatService.API/Application/Queries/Keys/GetMyKeyBundleQuery.cs @@ -0,0 +1,23 @@ +using MediatR; + +namespace ChatService.API.Application.Queries.Keys; + +/// +/// EN: Query to get current user's own key bundle status. +/// VI: Query để lấy trạng thái key bundle của user hiện tại. +/// +public record GetMyKeyBundleQuery( + string IdentityUserId +) : IRequest; + +/// +/// EN: User's own key bundle status. +/// VI: Trạng thái key bundle của user. +/// +public record MyKeyBundleDto( + Guid ChatUserId, + bool HasKeyBundle, + DateTime? SignedPreKeyTimestamp, + bool NeedsKeyRotation, + int AvailableOneTimeKeys +); diff --git a/services/chat-service-net/src/ChatService.API/Application/Queries/Keys/GetMyKeyBundleQueryHandler.cs b/services/chat-service-net/src/ChatService.API/Application/Queries/Keys/GetMyKeyBundleQueryHandler.cs new file mode 100644 index 00000000..d8b19ca2 --- /dev/null +++ b/services/chat-service-net/src/ChatService.API/Application/Queries/Keys/GetMyKeyBundleQueryHandler.cs @@ -0,0 +1,45 @@ +using MediatR; +using ChatService.Domain.AggregatesModel.UserAggregate; + +namespace ChatService.API.Application.Queries.Keys; + +/// +/// EN: Handler for GetMyKeyBundleQuery. +/// VI: Handler cho GetMyKeyBundleQuery. +/// +public class GetMyKeyBundleQueryHandler : IRequestHandler +{ + private readonly IChatUserRepository _chatUserRepository; + private readonly ILogger _logger; + + public GetMyKeyBundleQueryHandler( + IChatUserRepository chatUserRepository, + ILogger logger) + { + _chatUserRepository = chatUserRepository; + _logger = logger; + } + + public async Task Handle(GetMyKeyBundleQuery request, CancellationToken cancellationToken) + { + _logger.LogInformation("Getting my key bundle for {IdentityUserId}", request.IdentityUserId); + + var user = await _chatUserRepository.GetByIdentityUserIdAsync(request.IdentityUserId, cancellationToken); + + if (user == null) + { + _logger.LogInformation("Chat user not found for {IdentityUserId}", request.IdentityUserId); + return null; + } + + var availableKeys = await _chatUserRepository.GetAvailablePreKeyCountAsync(user.Id, cancellationToken); + + return new MyKeyBundleDto( + user.Id, + user.KeyBundle != null, + user.KeyBundle?.SignedPreKeyTimestamp, + user.KeyBundle?.NeedsRotation() ?? true, + availableKeys + ); + } +} diff --git a/services/chat-service-net/src/ChatService.API/Application/Queries/Keys/GetUserKeyBundleQuery.cs b/services/chat-service-net/src/ChatService.API/Application/Queries/Keys/GetUserKeyBundleQuery.cs new file mode 100644 index 00000000..abe5dfc0 --- /dev/null +++ b/services/chat-service-net/src/ChatService.API/Application/Queries/Keys/GetUserKeyBundleQuery.cs @@ -0,0 +1,29 @@ +using MediatR; + +namespace ChatService.API.Application.Queries.Keys; + +/// +/// EN: Query to get a user's key bundle for initiating E2EE session. +/// VI: Query để lấy key bundle của user để khởi tạo E2EE session. +/// +public record GetUserKeyBundleQuery( + Guid TargetUserId +) : IRequest; + +/// +/// EN: Key bundle DTO returned for key exchange. +/// VI: Key bundle DTO trả về cho key exchange. +/// +public record UserKeyBundleDto( + Guid UserId, + string IdentityPublicKey, + string SignedPreKey, + string SignedPreKeySignature, + DateTime SignedPreKeyTimestamp, + OneTimePreKeyResultDto? OneTimePreKey +); + +public record OneTimePreKeyResultDto( + int KeyId, + string PublicKey +); diff --git a/services/chat-service-net/src/ChatService.API/Application/Queries/Keys/GetUserKeyBundleQueryHandler.cs b/services/chat-service-net/src/ChatService.API/Application/Queries/Keys/GetUserKeyBundleQueryHandler.cs new file mode 100644 index 00000000..94ac33e9 --- /dev/null +++ b/services/chat-service-net/src/ChatService.API/Application/Queries/Keys/GetUserKeyBundleQueryHandler.cs @@ -0,0 +1,58 @@ +using MediatR; +using ChatService.Domain.AggregatesModel.UserAggregate; + +namespace ChatService.API.Application.Queries.Keys; + +/// +/// EN: Handler for GetUserKeyBundleQuery. +/// VI: Handler cho GetUserKeyBundleQuery. +/// +public class GetUserKeyBundleQueryHandler : IRequestHandler +{ + private readonly IChatUserRepository _chatUserRepository; + private readonly ILogger _logger; + + public GetUserKeyBundleQueryHandler( + IChatUserRepository chatUserRepository, + ILogger logger) + { + _chatUserRepository = chatUserRepository; + _logger = logger; + } + + public async Task Handle(GetUserKeyBundleQuery request, CancellationToken cancellationToken) + { + _logger.LogInformation("Getting key bundle for user {UserId}", request.TargetUserId); + + // EN: Get key bundle and consume one-time pre-key + // VI: Lấy key bundle và consume one-time pre-key + var (identityPublicKey, signedPreKey, signedPreKeySignature, oneTimePreKey) = + await _chatUserRepository.GetKeyBundleAsync(request.TargetUserId, cancellationToken); + + if (identityPublicKey == null || signedPreKey == null || signedPreKeySignature == null) + { + _logger.LogWarning("Key bundle not found for user {UserId}", request.TargetUserId); + return null; + } + + // EN: Save the consumed one-time pre-key + // VI: Lưu one-time pre-key đã consume + await _chatUserRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation("Key bundle retrieved for user {UserId}, one-time key consumed: {Consumed}", + request.TargetUserId, oneTimePreKey != null); + + var user = await _chatUserRepository.GetByIdAsync(request.TargetUserId, cancellationToken); + + return new UserKeyBundleDto( + request.TargetUserId, + identityPublicKey, + signedPreKey, + signedPreKeySignature, + user?.KeyBundle?.SignedPreKeyTimestamp ?? DateTime.UtcNow, + oneTimePreKey != null + ? new OneTimePreKeyResultDto(oneTimePreKey.KeyId, oneTimePreKey.PublicKey) + : null + ); + } +} diff --git a/services/chat-service-net/src/ChatService.API/Application/Queries/Messages/GetMessagesQuery.cs b/services/chat-service-net/src/ChatService.API/Application/Queries/Messages/GetMessagesQuery.cs new file mode 100644 index 00000000..6fb5fd65 --- /dev/null +++ b/services/chat-service-net/src/ChatService.API/Application/Queries/Messages/GetMessagesQuery.cs @@ -0,0 +1,40 @@ +using MediatR; + +namespace ChatService.API.Application.Queries.Messages; + +/// +/// EN: Query to get messages in a conversation. +/// VI: Query để lấy tin nhắn trong cuộc hội thoại. +/// +public record GetMessagesQuery( + Guid ConversationId, + Guid UserId, + int Page = 1, + int PageSize = 50, + DateTime? Before = null +) : IRequest; + +public record GetMessagesResult( + IEnumerable Messages, + int TotalCount, + int Page, + int PageSize, + bool HasMore +); + +public record MessageDto( + Guid Id, + Guid ConversationId, + Guid SenderId, + string SenderName, + string EncryptedContent, + string Nonce, + string? AuthTag, + string Type, + string Status, + string? Metadata, + Guid? ReplyToMessageId, + DateTime CreatedAt, + DateTime? DeliveredAt, + DateTime? ReadAt +); diff --git a/services/chat-service-net/src/ChatService.API/Application/Queries/Messages/GetMessagesQueryHandler.cs b/services/chat-service-net/src/ChatService.API/Application/Queries/Messages/GetMessagesQueryHandler.cs new file mode 100644 index 00000000..2ba58354 --- /dev/null +++ b/services/chat-service-net/src/ChatService.API/Application/Queries/Messages/GetMessagesQueryHandler.cs @@ -0,0 +1,81 @@ +using MediatR; +using ChatService.Domain.AggregatesModel.ConversationAggregate; +using ChatService.Domain.AggregatesModel.UserAggregate; + +namespace ChatService.API.Application.Queries.Messages; + +/// +/// EN: Handler for GetMessagesQuery. +/// VI: Handler cho GetMessagesQuery. +/// +public class GetMessagesQueryHandler : IRequestHandler +{ + private readonly IConversationRepository _conversationRepository; + private readonly IChatUserRepository _chatUserRepository; + private readonly ILogger _logger; + + public GetMessagesQueryHandler( + IConversationRepository conversationRepository, + IChatUserRepository chatUserRepository, + ILogger logger) + { + _conversationRepository = conversationRepository; + _chatUserRepository = chatUserRepository; + _logger = logger; + } + + public async Task Handle(GetMessagesQuery request, CancellationToken cancellationToken) + { + _logger.LogInformation("Getting messages for conversation {ConversationId}, user {UserId}", + request.ConversationId, request.UserId); + + // EN: Verify user is participant + // VI: Xác thực user là participant + var isParticipant = await _conversationRepository.IsUserParticipantAsync( + request.ConversationId, request.UserId, cancellationToken); + + if (!isParticipant) + { + throw new UnauthorizedAccessException("User is not a participant in this conversation"); + } + + var (messages, totalCount) = await _conversationRepository.GetMessagesPaginatedAsync( + request.ConversationId, request.Page, request.PageSize, request.Before, cancellationToken); + + var messageList = messages.ToList(); + + // EN: Get sender info for all messages + // VI: Lấy thông tin người gửi cho tất cả tin nhắn + var senderIds = messageList.Select(m => m.SenderId).Distinct().ToList(); + var users = await _chatUserRepository.GetByIdsAsync(senderIds, cancellationToken); + var userDict = users.ToDictionary(u => u.Id); + + var messageDtos = messageList.Select(m => new MessageDto( + m.Id, + m.ConversationId, + m.SenderId, + userDict.TryGetValue(m.SenderId, out var user) ? user.DisplayName : "Unknown", + m.EncryptedContent, + m.Nonce, + m.AuthTag, + m.Type.Name, + m.Status.Name, + m.Metadata, + m.ReplyToMessageId, + m.CreatedAt, + m.DeliveredAt, + m.ReadAt + )).ToList(); + + var totalPages = (int)Math.Ceiling((double)totalCount / request.PageSize); + var hasMore = request.Page < totalPages; + + return new GetMessagesResult( + messageDtos, + totalCount, + request.Page, + request.PageSize, + hasMore + ); + } +} diff --git a/services/chat-service-net/src/ChatService.API/Application/Validations/Keys/KeyCommandValidators.cs b/services/chat-service-net/src/ChatService.API/Application/Validations/Keys/KeyCommandValidators.cs new file mode 100644 index 00000000..4b17e6ad --- /dev/null +++ b/services/chat-service-net/src/ChatService.API/Application/Validations/Keys/KeyCommandValidators.cs @@ -0,0 +1,158 @@ +using FluentValidation; +using ChatService.API.Application.Commands.Keys; + +namespace ChatService.API.Application.Validations.Keys; + +/// +/// EN: Validator for RegisterUserKeysCommand. +/// VI: Validator cho RegisterUserKeysCommand. +/// +public class RegisterUserKeysCommandValidator : AbstractValidator +{ + public RegisterUserKeysCommandValidator() + { + RuleFor(x => x.IdentityUserId) + .NotEmpty() + .WithMessage("Identity user ID is required"); + + RuleFor(x => x.DisplayName) + .NotEmpty() + .MaximumLength(255) + .WithMessage("Display name must not exceed 255 characters"); + + RuleFor(x => x.IdentityPublicKey) + .NotEmpty() + .Must(BeValidBase64) + .WithMessage("Identity public key must be valid Base64"); + + RuleFor(x => x.SignedPreKey) + .NotEmpty() + .Must(BeValidBase64) + .WithMessage("Signed pre-key must be valid Base64"); + + RuleFor(x => x.SignedPreKeySignature) + .NotEmpty() + .Must(BeValidBase64) + .WithMessage("Signed pre-key signature must be valid Base64"); + + RuleForEach(x => x.OneTimePreKeys) + .ChildRules(preKey => + { + preKey.RuleFor(k => k.KeyId) + .GreaterThanOrEqualTo(0) + .WithMessage("Key ID must be non-negative"); + + preKey.RuleFor(k => k.PublicKey) + .NotEmpty() + .Must(BeValidBase64) + .WithMessage("Public key must be valid Base64"); + }); + } + + private static bool BeValidBase64(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return false; + + try + { + Convert.FromBase64String(value); + return true; + } + catch + { + return false; + } + } +} + +/// +/// EN: Validator for RotatePreKeyCommand. +/// VI: Validator cho RotatePreKeyCommand. +/// +public class RotatePreKeyCommandValidator : AbstractValidator +{ + public RotatePreKeyCommandValidator() + { + RuleFor(x => x.ChatUserId) + .NotEmpty() + .WithMessage("Chat user ID is required"); + + RuleFor(x => x.NewSignedPreKey) + .NotEmpty() + .Must(BeValidBase64) + .WithMessage("New signed pre-key must be valid Base64"); + + RuleFor(x => x.NewSignedPreKeySignature) + .NotEmpty() + .Must(BeValidBase64) + .WithMessage("New signed pre-key signature must be valid Base64"); + } + + private static bool BeValidBase64(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return false; + + try + { + Convert.FromBase64String(value); + return true; + } + catch + { + return false; + } + } +} + +/// +/// EN: Validator for UploadOneTimeKeysCommand. +/// VI: Validator cho UploadOneTimeKeysCommand. +/// +public class UploadOneTimeKeysCommandValidator : AbstractValidator +{ + public UploadOneTimeKeysCommandValidator() + { + RuleFor(x => x.ChatUserId) + .NotEmpty() + .WithMessage("Chat user ID is required"); + + RuleFor(x => x.OneTimePreKeys) + .NotEmpty() + .WithMessage("At least one pre-key is required"); + + RuleFor(x => x.OneTimePreKeys.Count()) + .LessThanOrEqualTo(100) + .WithMessage("Cannot upload more than 100 pre-keys at once"); + + RuleForEach(x => x.OneTimePreKeys) + .ChildRules(preKey => + { + preKey.RuleFor(k => k.KeyId) + .GreaterThanOrEqualTo(0) + .WithMessage("Key ID must be non-negative"); + + preKey.RuleFor(k => k.PublicKey) + .NotEmpty() + .Must(BeValidBase64) + .WithMessage("Public key must be valid Base64"); + }); + } + + private static bool BeValidBase64(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return false; + + try + { + Convert.FromBase64String(value); + return true; + } + catch + { + return false; + } + } +} diff --git a/services/chat-service-net/src/ChatService.API/Application/Validations/Messaging/MessagingCommandValidators.cs b/services/chat-service-net/src/ChatService.API/Application/Validations/Messaging/MessagingCommandValidators.cs new file mode 100644 index 00000000..8870f412 --- /dev/null +++ b/services/chat-service-net/src/ChatService.API/Application/Validations/Messaging/MessagingCommandValidators.cs @@ -0,0 +1,108 @@ +using FluentValidation; +using ChatService.API.Application.Commands.Conversations; +using ChatService.API.Application.Commands.Messages; + +namespace ChatService.API.Application.Validations.Messaging; + +/// +/// EN: Validator for CreateConversationCommand. +/// VI: Validator cho CreateConversationCommand. +/// +public class CreateConversationCommandValidator : AbstractValidator +{ + public CreateConversationCommandValidator() + { + RuleFor(x => x.CreatorId) + .NotEmpty().WithMessage("CreatorId is required"); + + RuleFor(x => x.ParticipantIds) + .NotEmpty().WithMessage("At least one participant is required") + .Must(x => x.Distinct().Count() == x.Count()) + .WithMessage("Participant IDs must be unique"); + + When(x => x.IsGroup, () => + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Group name is required for group conversations") + .MaximumLength(100).WithMessage("Group name must be at most 100 characters"); + + RuleFor(x => x.ParticipantIds) + .Must(x => x.Count() >= 1) + .WithMessage("Group conversation must have at least 1 participant besides the creator"); + }); + + When(x => !x.IsGroup, () => + { + RuleFor(x => x.ParticipantIds) + .Must(x => x.Count() == 1) + .WithMessage("Direct conversation must have exactly 1 participant besides the creator"); + }); + + RuleFor(x => x.AvatarUrl) + .MaximumLength(500).WithMessage("Avatar URL must be at most 500 characters") + .When(x => !string.IsNullOrEmpty(x.AvatarUrl)); + } +} + +/// +/// EN: Validator for SendMessageCommand. +/// VI: Validator cho SendMessageCommand. +/// +public class SendMessageCommandValidator : AbstractValidator +{ + private static readonly HashSet ValidMessageTypes = new() + { + "text", "image", "video", "audio", "file", "location", "contact", "sticker", "system" + }; + + public SendMessageCommandValidator() + { + RuleFor(x => x.ConversationId) + .NotEmpty().WithMessage("ConversationId is required"); + + RuleFor(x => x.SenderId) + .NotEmpty().WithMessage("SenderId is required"); + + RuleFor(x => x.EncryptedContent) + .NotEmpty().WithMessage("EncryptedContent is required") + .MaximumLength(100000).WithMessage("EncryptedContent must be at most 100000 characters"); + + RuleFor(x => x.Nonce) + .NotEmpty().WithMessage("Nonce is required") + .MaximumLength(100).WithMessage("Nonce must be at most 100 characters"); + + RuleFor(x => x.AuthTag) + .MaximumLength(100).WithMessage("AuthTag must be at most 100 characters") + .When(x => !string.IsNullOrEmpty(x.AuthTag)); + + RuleFor(x => x.MessageType) + .Must(type => ValidMessageTypes.Contains(type.ToLowerInvariant())) + .WithMessage($"MessageType must be one of: {string.Join(", ", ValidMessageTypes)}"); + + RuleFor(x => x.Metadata) + .MaximumLength(10000).WithMessage("Metadata must be at most 10000 characters") + .When(x => !string.IsNullOrEmpty(x.Metadata)); + } +} + +/// +/// EN: Validator for MarkMessagesReadCommand. +/// VI: Validator cho MarkMessagesReadCommand. +/// +public class MarkMessagesReadCommandValidator : AbstractValidator +{ + public MarkMessagesReadCommandValidator() + { + RuleFor(x => x.ConversationId) + .NotEmpty().WithMessage("ConversationId is required"); + + RuleFor(x => x.UserId) + .NotEmpty().WithMessage("UserId is required"); + + // EN: At least one of LastReadMessageId or ReadUpTo should be provided, or both can be null (mark all) + // VI: Ít nhất một trong LastReadMessageId hoặc ReadUpTo nên được cung cấp, hoặc cả hai có thể null (đánh dấu tất cả) + RuleFor(x => x) + .Must(x => true) // EN: Always valid - all scenarios are handled / VI: Luôn hợp lệ - tất cả scenarios được xử lý + .WithMessage("Either LastReadMessageId, ReadUpTo, or neither (to mark all) should be provided"); + } +} diff --git a/services/chat-service-net/src/ChatService.API/ChatService.API.csproj b/services/chat-service-net/src/ChatService.API/ChatService.API.csproj new file mode 100644 index 00000000..e2281c06 --- /dev/null +++ b/services/chat-service-net/src/ChatService.API/ChatService.API.csproj @@ -0,0 +1,46 @@ + + + + ChatService.API + ChatService.API + Web API layer with CQRS pattern + myservice-api + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/services/chat-service-net/src/ChatService.API/Controllers/ConversationsController.cs b/services/chat-service-net/src/ChatService.API/Controllers/ConversationsController.cs new file mode 100644 index 00000000..73d93da3 --- /dev/null +++ b/services/chat-service-net/src/ChatService.API/Controllers/ConversationsController.cs @@ -0,0 +1,102 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using MediatR; +using ChatService.API.Application.Commands.Conversations; +using ChatService.API.Application.Queries.Conversations; + +namespace ChatService.API.Controllers; + +/// +/// EN: Controller for conversation management. +/// VI: Controller để quản lý cuộc hội thoại. +/// +[ApiController] +[Route("api/[controller]")] +[Authorize] +public class ConversationsController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public ConversationsController(IMediator mediator, ILogger logger) + { + _mediator = mediator; + _logger = logger; + } + + /// + /// EN: Create a new conversation. + /// VI: Tạo cuộc hội thoại mới. + /// + [HttpPost] + [ProducesResponseType(typeof(CreateConversationResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task CreateConversation([FromBody] CreateConversationRequest request) + { + var command = new CreateConversationCommand( + request.CreatorId, + request.ParticipantIds, + request.Name, + request.AvatarUrl, + request.IsGroup); + + try + { + var result = await _mediator.Send(command); + return Ok(result); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + + /// + /// EN: Get user's conversations. + /// VI: Lấy các cuộc hội thoại của user. + /// + [HttpGet] + [ProducesResponseType(typeof(GetConversationsResult), StatusCodes.Status200OK)] + public async Task GetConversations( + [FromQuery] Guid userId, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20) + { + var query = new GetConversationsQuery(userId, page, pageSize); + var result = await _mediator.Send(query); + return Ok(result); + } + + /// + /// EN: Get a specific conversation by ID. + /// VI: Lấy cuộc hội thoại theo ID. + /// + [HttpGet("{conversationId:guid}")] + [ProducesResponseType(typeof(ConversationDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetConversation(Guid conversationId, [FromQuery] Guid userId) + { + var query = new GetConversationsQuery(userId, 1, 1); + var result = await _mediator.Send(query); + + var conversation = result.Conversations.FirstOrDefault(c => c.Id == conversationId); + if (conversation == null) + { + return NotFound($"Conversation {conversationId} not found"); + } + + return Ok(conversation); + } +} + +#region Request DTOs + +public record CreateConversationRequest( + Guid CreatorId, + List ParticipantIds, + string? Name = null, + string? AvatarUrl = null, + bool IsGroup = false +); + +#endregion diff --git a/services/chat-service-net/src/ChatService.API/Controllers/KeysController.cs b/services/chat-service-net/src/ChatService.API/Controllers/KeysController.cs new file mode 100644 index 00000000..1e1477fb --- /dev/null +++ b/services/chat-service-net/src/ChatService.API/Controllers/KeysController.cs @@ -0,0 +1,187 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using MediatR; +using ChatService.API.Application.Commands.Keys; +using ChatService.API.Application.Queries.Keys; + +namespace ChatService.API.Controllers; + +/// +/// EN: Controller for E2EE key management. +/// VI: Controller để quản lý E2EE keys. +/// +[ApiController] +[Route("api/[controller]")] +[Authorize] +public class KeysController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public KeysController(IMediator mediator, ILogger logger) + { + _mediator = mediator; + _logger = logger; + } + + /// + /// EN: Register or update user's E2EE key bundle. + /// VI: Đăng ký hoặc cập nhật E2EE key bundle của user. + /// + [HttpPost("register")] + [ProducesResponseType(typeof(RegisterUserKeysResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task RegisterKeys([FromBody] RegisterUserKeysRequest request) + { + var identityUserId = User.FindFirst("sub")?.Value + ?? User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; + + if (string.IsNullOrEmpty(identityUserId)) + { + return Unauthorized("User ID not found in token"); + } + + var command = new RegisterUserKeysCommand( + identityUserId, + request.DisplayName, + request.AvatarUrl, + request.IdentityPublicKey, + request.SignedPreKey, + request.SignedPreKeySignature, + request.OneTimePreKeys?.Select(k => new OneTimePreKeyDto(k.KeyId, k.PublicKey)) + ); + + var result = await _mediator.Send(command); + return Ok(result); + } + + /// + /// EN: Rotate the signed pre-key. + /// VI: Xoay vòng signed pre-key. + /// + [HttpPost("rotate")] + [ProducesResponseType(typeof(RotatePreKeyResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task RotatePreKey([FromBody] RotatePreKeyRequest request) + { + var command = new RotatePreKeyCommand( + request.ChatUserId, + request.NewSignedPreKey, + request.NewSignedPreKeySignature + ); + + try + { + var result = await _mediator.Send(command); + return Ok(result); + } + catch (InvalidOperationException ex) + { + return NotFound(ex.Message); + } + } + + /// + /// EN: Upload one-time pre-keys. + /// VI: Upload one-time pre-keys. + /// + [HttpPost("prekeys")] + [ProducesResponseType(typeof(UploadOneTimeKeysResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task UploadPreKeys([FromBody] UploadPreKeysRequest request) + { + var command = new UploadOneTimeKeysCommand( + request.ChatUserId, + request.OneTimePreKeys.Select(k => new OneTimePreKeyDto(k.KeyId, k.PublicKey)) + ); + + try + { + var result = await _mediator.Send(command); + return Ok(result); + } + catch (InvalidOperationException ex) + { + return NotFound(ex.Message); + } + } + + /// + /// EN: Get a user's key bundle for initiating E2EE session. + /// VI: Lấy key bundle của user để khởi tạo E2EE session. + /// + [HttpGet("bundle/{userId:guid}")] + [ProducesResponseType(typeof(UserKeyBundleDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetUserKeyBundle(Guid userId) + { + var query = new GetUserKeyBundleQuery(userId); + var result = await _mediator.Send(query); + + if (result == null) + { + return NotFound($"Key bundle not found for user {userId}"); + } + + return Ok(result); + } + + /// + /// EN: Get current user's own key bundle status. + /// VI: Lấy trạng thái key bundle của user hiện tại. + /// + [HttpGet("my-bundle")] + [ProducesResponseType(typeof(MyKeyBundleDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetMyKeyBundle() + { + var identityUserId = User.FindFirst("sub")?.Value + ?? User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; + + if (string.IsNullOrEmpty(identityUserId)) + { + return Unauthorized("User ID not found in token"); + } + + var query = new GetMyKeyBundleQuery(identityUserId); + var result = await _mediator.Send(query); + + if (result == null) + { + return NotFound("Key bundle not registered. Please register your keys first."); + } + + return Ok(result); + } +} + +#region Request DTOs + +public record RegisterUserKeysRequest( + string DisplayName, + string? AvatarUrl, + string IdentityPublicKey, + string SignedPreKey, + string SignedPreKeySignature, + List? OneTimePreKeys = null +); + +public record RotatePreKeyRequest( + Guid ChatUserId, + string NewSignedPreKey, + string NewSignedPreKeySignature +); + +public record UploadPreKeysRequest( + Guid ChatUserId, + List OneTimePreKeys +); + +public record PreKeyRequest( + int KeyId, + string PublicKey +); + +#endregion diff --git a/services/chat-service-net/src/ChatService.API/Controllers/MessagesController.cs b/services/chat-service-net/src/ChatService.API/Controllers/MessagesController.cs new file mode 100644 index 00000000..85daed9d --- /dev/null +++ b/services/chat-service-net/src/ChatService.API/Controllers/MessagesController.cs @@ -0,0 +1,132 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using MediatR; +using ChatService.API.Application.Commands.Messages; +using ChatService.API.Application.Queries.Messages; + +namespace ChatService.API.Controllers; + +/// +/// EN: Controller for message management. +/// VI: Controller để quản lý tin nhắn. +/// +[ApiController] +[Route("api/[controller]")] +[Authorize] +public class MessagesController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public MessagesController(IMediator mediator, ILogger logger) + { + _mediator = mediator; + _logger = logger; + } + + /// + /// EN: Send an encrypted message. + /// VI: Gửi tin nhắn đã mã hóa. + /// + [HttpPost] + [ProducesResponseType(typeof(SendMessageResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task SendMessage([FromBody] SendMessageRequest request) + { + var command = new SendMessageCommand( + request.ConversationId, + request.SenderId, + request.EncryptedContent, + request.Nonce, + request.AuthTag, + request.MessageType, + request.Metadata, + request.ReplyToMessageId); + + try + { + var result = await _mediator.Send(command); + return Ok(result); + } + catch (InvalidOperationException ex) + { + return NotFound(ex.Message); + } + } + + /// + /// EN: Get messages in a conversation. + /// VI: Lấy tin nhắn trong cuộc hội thoại. + /// + [HttpGet("conversation/{conversationId:guid}")] + [ProducesResponseType(typeof(GetMessagesResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task GetMessages( + Guid conversationId, + [FromQuery] Guid userId, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 50, + [FromQuery] DateTime? before = null) + { + var query = new GetMessagesQuery(conversationId, userId, page, pageSize, before); + + try + { + var result = await _mediator.Send(query); + return Ok(result); + } + catch (UnauthorizedAccessException ex) + { + return Forbid(ex.Message); + } + } + + /// + /// EN: Mark messages as read. + /// VI: Đánh dấu tin nhắn đã đọc. + /// + [HttpPost("read")] + [ProducesResponseType(typeof(MarkMessagesReadResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task MarkAsRead([FromBody] MarkReadRequest request) + { + var command = new MarkMessagesReadCommand( + request.ConversationId, + request.UserId, + request.LastReadMessageId, + request.ReadUpTo); + + try + { + var result = await _mediator.Send(command); + return Ok(result); + } + catch (InvalidOperationException ex) + { + return NotFound(ex.Message); + } + } +} + +#region Request DTOs + +public record SendMessageRequest( + Guid ConversationId, + Guid SenderId, + string EncryptedContent, + string Nonce, + string? AuthTag = null, + string MessageType = "text", + string? Metadata = null, + Guid? ReplyToMessageId = null +); + +public record MarkReadRequest( + Guid ConversationId, + Guid UserId, + Guid? LastReadMessageId = null, + DateTime? ReadUpTo = null +); + +#endregion diff --git a/services/chat-service-net/src/ChatService.API/Program.cs b/services/chat-service-net/src/ChatService.API/Program.cs new file mode 100644 index 00000000..b4efc1ae --- /dev/null +++ b/services/chat-service-net/src/ChatService.API/Program.cs @@ -0,0 +1,144 @@ +using Asp.Versioning; +using FluentValidation; +using Hellang.Middleware.ProblemDetails; +using ChatService.API.Application.Behaviors; +using ChatService.Infrastructure; +using Serilog; + +// EN: Configure Serilog early / VI: Cấu hình Serilog sớm +Log.Logger = new LoggerConfiguration() + .WriteTo.Console() + .CreateBootstrapLogger(); + +try +{ + Log.Information("Starting ChatService API / Khởi động ChatService API"); + + var builder = WebApplication.CreateBuilder(args); + + // EN: Configure Serilog / VI: Cấu hình Serilog + builder.Host.UseSerilog((context, services, configuration) => configuration + .ReadFrom.Configuration(context.Configuration) + .ReadFrom.Services(services) + .Enrich.FromLogContext() + .WriteTo.Console()); + + // EN: Add Infrastructure services / VI: Thêm Infrastructure services + builder.Services.AddInfrastructure(builder.Configuration); + + // EN: Add MediatR with behaviors / VI: Thêm MediatR với behaviors + builder.Services.AddMediatR(cfg => + { + cfg.RegisterServicesFromAssemblyContaining(); + cfg.AddOpenBehavior(typeof(LoggingBehavior<,>)); + cfg.AddOpenBehavior(typeof(ValidatorBehavior<,>)); + cfg.AddOpenBehavior(typeof(TransactionBehavior<,>)); + }); + + // EN: Add FluentValidation / VI: Thêm FluentValidation + builder.Services.AddValidatorsFromAssemblyContaining(); + + // EN: Add API versioning / VI: Thêm API versioning + builder.Services.AddApiVersioning(options => + { + options.DefaultApiVersion = new ApiVersion(1, 0); + options.AssumeDefaultVersionWhenUnspecified = true; + options.ReportApiVersions = true; + options.ApiVersionReader = ApiVersionReader.Combine( + new UrlSegmentApiVersionReader(), + new HeaderApiVersionReader("X-Api-Version")); + }) + .AddApiExplorer(options => + { + options.GroupNameFormat = "'v'VVV"; + options.SubstituteApiVersionInUrl = true; + }); + + // EN: Add controllers / VI: Thêm controllers + builder.Services.AddControllers(); + + // EN: Add ProblemDetails middleware (RFC 7807) / VI: Thêm ProblemDetails middleware + builder.Services.AddProblemDetails(options => + { + options.IncludeExceptionDetails = (ctx, ex) => + builder.Environment.IsDevelopment(); + }); + + // EN: Add Swagger / VI: Thêm Swagger + builder.Services.AddEndpointsApiExplorer(); + builder.Services.AddSwaggerGen(options => + { + options.SwaggerDoc("v1", new() + { + Title = "ChatService API", + Version = "v1", + Description = "ChatService microservice API / API microservice ChatService" + }); + }); + + // EN: Add health checks / VI: Thêm health checks + builder.Services.AddHealthChecks() + .AddNpgSql( + builder.Configuration.GetConnectionString("DefaultConnection") + ?? builder.Configuration["DATABASE_URL"] + ?? "", + name: "postgresql", + tags: ["db", "postgresql"]); + + // EN: Add CORS / VI: Thêm CORS + builder.Services.AddCors(options => + { + options.AddDefaultPolicy(policy => + { + policy.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); + }); + + var app = builder.Build(); + + // EN: Configure middleware pipeline / VI: Cấu hình middleware pipeline + app.UseSerilogRequestLogging(); + app.UseProblemDetails(); + + if (app.Environment.IsDevelopment()) + { + app.UseSwagger(); + app.UseSwaggerUI(c => + { + c.SwaggerEndpoint("/swagger/v1/swagger.json", "ChatService API v1"); + c.RoutePrefix = "swagger"; + }); + } + + app.UseCors(); + app.UseRouting(); + + // EN: Map health check endpoints / VI: Map health check endpoints + app.MapHealthChecks("/health"); + app.MapHealthChecks("/health/live", new() + { + Predicate = _ => false // EN: Just checks app is running / VI: Chỉ kiểm tra app đang chạy + }); + app.MapHealthChecks("/health/ready"); + + // EN: Map controllers / VI: Map controllers + app.MapControllers(); + + // EN: Run the application / VI: Chạy ứng dụng + app.Run(); +} +catch (Exception ex) +{ + Log.Fatal(ex, "Application terminated unexpectedly / Ứng dụng kết thúc bất ngờ"); + throw; +} +finally +{ + Log.CloseAndFlush(); +} + +// EN: Make Program class accessible for integration tests +// VI: Làm cho class Program có thể truy cập cho integration tests +public partial class Program { } diff --git a/services/chat-service-net/src/ChatService.API/Properties/launchSettings.json b/services/chat-service-net/src/ChatService.API/Properties/launchSettings.json new file mode 100644 index 00000000..6355d40b --- /dev/null +++ b/services/chat-service-net/src/ChatService.API/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} \ No newline at end of file diff --git a/services/chat-service-net/src/ChatService.API/appsettings.Development.json b/services/chat-service-net/src/ChatService.API/appsettings.Development.json new file mode 100644 index 00000000..e407ac85 --- /dev/null +++ b/services/chat-service-net/src/ChatService.API/appsettings.Development.json @@ -0,0 +1,19 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft.AspNetCore": "Information", + "Microsoft.EntityFrameworkCore.Database.Command": "Information" + } + }, + "Serilog": { + "MinimumLevel": { + "Default": "Debug", + "Override": { + "Microsoft": "Information", + "Microsoft.EntityFrameworkCore.Database.Command": "Information", + "System": "Information" + } + } + } +} \ No newline at end of file diff --git a/services/chat-service-net/src/ChatService.API/appsettings.json b/services/chat-service-net/src/ChatService.API/appsettings.json new file mode 100644 index 00000000..523dc0fc --- /dev/null +++ b/services/chat-service-net/src/ChatService.API/appsettings.json @@ -0,0 +1,46 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + }, + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "Microsoft.EntityFrameworkCore": "Warning", + "System": "Warning" + } + }, + "WriteTo": [ + { + "Name": "Console", + "Args": { + "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}" + } + } + ], + "Enrich": [ + "FromLogContext", + "WithMachineName", + "WithThreadId" + ] + }, + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Port=5432;Database=myservice_db;Username=postgres;Password=postgres" + }, + "Redis": { + "ConnectionString": "localhost:6379" + }, + "Jwt": { + "Secret": "your-super-secret-key-min-32-characters", + "Issuer": "goodgo-platform", + "Audience": "goodgo-services", + "AccessTokenExpiryMinutes": 15, + "RefreshTokenExpiryDays": 7 + }, + "AllowedHosts": "*" +} \ No newline at end of file diff --git a/services/chat-service-net/src/ChatService.Domain/AggregatesModel/ConversationAggregate/Conversation.cs b/services/chat-service-net/src/ChatService.Domain/AggregatesModel/ConversationAggregate/Conversation.cs new file mode 100644 index 00000000..8d1956e7 --- /dev/null +++ b/services/chat-service-net/src/ChatService.Domain/AggregatesModel/ConversationAggregate/Conversation.cs @@ -0,0 +1,300 @@ +namespace ChatService.Domain.AggregatesModel.ConversationAggregate; + +using ChatService.Domain.Events; +using ChatService.Domain.Exceptions; +using ChatService.Domain.SeedWork; + +/// +/// EN: Conversation aggregate root - represents a chat conversation. +/// VI: Conversation aggregate root - đại diện một cuộc hội thoại chat. +/// +public class Conversation : Entity, IAggregateRoot +{ +#pragma warning disable CS0169 // Field is used by EF Core + private int _typeId; +#pragma warning restore CS0169 + private ConversationType _type; + private string? _name; + private string? _avatarUrl; + private readonly List _participants; + private readonly List _messages; + private DateTime _createdAt; + private DateTime _updatedAt; + private Guid? _lastMessageId; + private DateTime? _lastMessageAt; + + /// + /// EN: Type of conversation (Direct or Group). + /// VI: Loại cuộc hội thoại (Direct hoặc Group). + /// + public ConversationType Type => _type; + + /// + /// EN: Conversation name (optional for direct, required for group). + /// VI: Tên cuộc hội thoại (tùy chọn cho direct, bắt buộc cho group). + /// + public string? Name => _name; + + /// + /// EN: Conversation avatar URL (for group chats). + /// VI: URL avatar cuộc hội thoại (cho group chats). + /// + public string? AvatarUrl => _avatarUrl; + + /// + /// EN: Participants in this conversation. + /// VI: Các thành viên trong cuộc hội thoại này. + /// + public IReadOnlyCollection Participants => _participants.AsReadOnly(); + + /// + /// EN: Messages in this conversation (should be loaded explicitly). + /// VI: Các tin nhắn trong cuộc hội thoại này (nên load rõ ràng). + /// + public IReadOnlyCollection Messages => _messages.AsReadOnly(); + + /// + /// EN: ID of the last message in this conversation. + /// VI: ID của tin nhắn cuối cùng trong cuộc hội thoại này. + /// + public Guid? LastMessageId => _lastMessageId; + + /// + /// EN: Timestamp of the last message. + /// VI: Thời điểm của tin nhắn cuối cùng. + /// + public DateTime? LastMessageAt => _lastMessageAt; + + public DateTime CreatedAt => _createdAt; + public DateTime UpdatedAt => _updatedAt; + + protected Conversation() + { + // EN: Required by EF Core + // VI: Yêu cầu bởi EF Core + _type = ConversationType.Direct; + _participants = new List(); + _messages = new List(); + } + + /// + /// EN: Create a direct (1:1) conversation. + /// VI: Tạo cuộc hội thoại trực tiếp (1:1). + /// + public static Conversation CreateDirect(Guid user1Id, Guid user2Id) + { + if (user1Id == user2Id) + throw new ChatDomainException("Cannot create conversation with yourself"); + + var conversation = new Conversation + { + Id = Guid.NewGuid(), + _type = ConversationType.Direct, + _createdAt = DateTime.UtcNow, + _updatedAt = DateTime.UtcNow + }; + + conversation._participants.Add(new ConversationParticipant(conversation.Id, user1Id)); + conversation._participants.Add(new ConversationParticipant(conversation.Id, user2Id)); + + conversation.AddDomainEvent(new ConversationCreatedDomainEvent(conversation)); + + return conversation; + } + + /// + /// EN: Create a group conversation. + /// VI: Tạo cuộc hội thoại nhóm. + /// + public static Conversation CreateGroup(string name, Guid creatorId, IEnumerable participantIds, string? avatarUrl = null) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ChatDomainException("Group name is required"); + + var allParticipants = participantIds.Append(creatorId).Distinct().ToList(); + if (allParticipants.Count < 2) + throw new ChatDomainException("Group must have at least 2 participants"); + + var conversation = new Conversation + { + Id = Guid.NewGuid(), + _type = ConversationType.Group, + _name = name, + _avatarUrl = avatarUrl, + _createdAt = DateTime.UtcNow, + _updatedAt = DateTime.UtcNow + }; + + // EN: Creator is admin + // VI: Creator là admin + conversation._participants.Add(new ConversationParticipant(conversation.Id, creatorId, isAdmin: true)); + + foreach (var participantId in allParticipants.Where(p => p != creatorId)) + { + conversation._participants.Add(new ConversationParticipant(conversation.Id, participantId)); + } + + conversation.AddDomainEvent(new ConversationCreatedDomainEvent(conversation)); + + return conversation; + } + + /// + /// EN: Send a new message. + /// VI: Gửi một tin nhắn mới. + /// + public Message SendMessage( + Guid senderId, + string encryptedContent, + string nonce, + MessageType? type = null, + string? authTag = null, + string? metadata = null, + Guid? replyToMessageId = null) + { + // EN: Validate sender is a participant + // VI: Xác thực người gửi là thành viên + var participant = _participants.FirstOrDefault(p => p.UserId == senderId && p.IsActive); + if (participant == null) + throw new ChatDomainException("Sender is not a participant of this conversation"); + + var message = new Message( + Id, + senderId, + encryptedContent, + nonce, + type, + authTag, + metadata, + replyToMessageId); + + _messages.Add(message); + _lastMessageId = message.Id; + _lastMessageAt = message.CreatedAt; + _updatedAt = DateTime.UtcNow; + + AddDomainEvent(new MessageSentDomainEvent(message, this)); + + return message; + } + + /// + /// EN: Add a participant to group conversation. + /// VI: Thêm thành viên vào cuộc hội thoại nhóm. + /// + public void AddParticipant(Guid userId, Guid addedByUserId) + { + if (_type == ConversationType.Direct) + throw new ChatDomainException("Cannot add participants to direct conversation"); + + // EN: Check if adder is admin + // VI: Kiểm tra người thêm có phải admin không + var adder = _participants.FirstOrDefault(p => p.UserId == addedByUserId && p.IsActive); + if (adder == null || !adder.IsAdmin) + throw new ChatDomainException("Only admins can add participants"); + + // EN: Check if user already exists + // VI: Kiểm tra user đã tồn tại chưa + var existing = _participants.FirstOrDefault(p => p.UserId == userId); + if (existing != null && existing.IsActive) + throw new ChatDomainException("User is already a participant"); + + _participants.Add(new ConversationParticipant(Id, userId)); + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Remove a participant from group conversation. + /// VI: Xóa thành viên khỏi cuộc hội thoại nhóm. + /// + public void RemoveParticipant(Guid userId, Guid removedByUserId) + { + if (_type == ConversationType.Direct) + throw new ChatDomainException("Cannot remove participants from direct conversation"); + + var participant = _participants.FirstOrDefault(p => p.UserId == userId && p.IsActive); + if (participant == null) + throw new ChatDomainException("User is not a participant"); + + // EN: User can remove themselves, or admin can remove others + // VI: User có thể tự rời, hoặc admin có thể xóa người khác + if (userId != removedByUserId) + { + var remover = _participants.FirstOrDefault(p => p.UserId == removedByUserId && p.IsActive); + if (remover == null || !remover.IsAdmin) + throw new ChatDomainException("Only admins can remove other participants"); + } + + participant.Leave(); + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Update group name. + /// VI: Cập nhật tên nhóm. + /// + public void UpdateName(string name, Guid updatedByUserId) + { + if (_type == ConversationType.Direct) + throw new ChatDomainException("Cannot update name of direct conversation"); + + var updater = _participants.FirstOrDefault(p => p.UserId == updatedByUserId && p.IsActive); + if (updater == null || !updater.IsAdmin) + throw new ChatDomainException("Only admins can update group name"); + + if (string.IsNullOrWhiteSpace(name)) + throw new ChatDomainException("Group name is required"); + + _name = name; + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Update group avatar. + /// VI: Cập nhật avatar nhóm. + /// + public void UpdateAvatar(string? avatarUrl, Guid updatedByUserId) + { + if (_type == ConversationType.Direct) + throw new ChatDomainException("Cannot update avatar of direct conversation"); + + var updater = _participants.FirstOrDefault(p => p.UserId == updatedByUserId && p.IsActive); + if (updater == null || !updater.IsAdmin) + throw new ChatDomainException("Only admins can update group avatar"); + + _avatarUrl = avatarUrl; + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Get active participant count. + /// VI: Lấy số lượng thành viên đang hoạt động. + /// + public int GetActiveParticipantCount() + { + return _participants.Count(p => p.IsActive); + } + + /// + /// EN: Check if user is a participant. + /// VI: Kiểm tra user có phải thành viên không. + /// + public bool IsParticipant(Guid userId) + { + return _participants.Any(p => p.UserId == userId && p.IsActive); + } + + /// + /// EN: Get the other participant in a direct conversation. + /// VI: Lấy thành viên kia trong cuộc hội thoại trực tiếp. + /// + public Guid? GetOtherParticipant(Guid userId) + { + if (_type != ConversationType.Direct) + return null; + + return _participants + .FirstOrDefault(p => p.UserId != userId && p.IsActive) + ?.UserId; + } +} diff --git a/services/chat-service-net/src/ChatService.Domain/AggregatesModel/ConversationAggregate/ConversationParticipant.cs b/services/chat-service-net/src/ChatService.Domain/AggregatesModel/ConversationAggregate/ConversationParticipant.cs new file mode 100644 index 00000000..0fd10d23 --- /dev/null +++ b/services/chat-service-net/src/ChatService.Domain/AggregatesModel/ConversationAggregate/ConversationParticipant.cs @@ -0,0 +1,117 @@ +namespace ChatService.Domain.AggregatesModel.ConversationAggregate; + +using ChatService.Domain.SeedWork; + +/// +/// EN: Participant in a conversation. +/// VI: Thành viên trong cuộc hội thoại. +/// +public class ConversationParticipant : Entity +{ + /// + /// EN: The conversation this participant belongs to. + /// VI: Cuộc hội thoại mà thành viên này thuộc về. + /// + public Guid ConversationId { get; private set; } + + /// + /// EN: The chat user ID of this participant. + /// VI: Chat user ID của thành viên này. + /// + public Guid UserId { get; private set; } + + /// + /// EN: When the user joined this conversation. + /// VI: Thời điểm user tham gia cuộc hội thoại. + /// + public DateTime JoinedAt { get; private set; } + + /// + /// EN: Last time this user read messages in this conversation. + /// VI: Lần cuối user đọc tin nhắn trong cuộc hội thoại này. + /// + public DateTime? LastReadAt { get; private set; } + + /// + /// EN: ID of the last message read by this user. + /// VI: ID của tin nhắn cuối cùng user đã đọc. + /// + public Guid? LastReadMessageId { get; private set; } + + /// + /// EN: Whether this participant is an admin (for group chats). + /// VI: Thành viên này có phải admin không (cho group chats). + /// + public bool IsAdmin { get; private set; } + + /// + /// EN: Whether notifications are muted for this user. + /// VI: Thông báo có bị tắt cho user này không. + /// + public bool IsMuted { get; private set; } + + /// + /// EN: When the user left this conversation (null if still active). + /// VI: Thời điểm user rời khỏi cuộc hội thoại (null nếu còn hoạt động). + /// + public DateTime? LeftAt { get; private set; } + + protected ConversationParticipant() + { + // EN: Required by EF Core + // VI: Yêu cầu bởi EF Core + } + + public ConversationParticipant(Guid conversationId, Guid userId, bool isAdmin = false) + { + Id = Guid.NewGuid(); + ConversationId = conversationId; + UserId = userId; + IsAdmin = isAdmin; + IsMuted = false; + JoinedAt = DateTime.UtcNow; + } + + /// + /// EN: Update last read information. + /// VI: Cập nhật thông tin đã đọc cuối cùng. + /// + public void UpdateLastRead(Guid messageId) + { + LastReadMessageId = messageId; + LastReadAt = DateTime.UtcNow; + } + + /// + /// EN: Toggle mute status. + /// VI: Bật/tắt trạng thái mute. + /// + public void SetMuted(bool muted) + { + IsMuted = muted; + } + + /// + /// EN: Set admin status. + /// VI: Đặt trạng thái admin. + /// + public void SetAdmin(bool isAdmin) + { + IsAdmin = isAdmin; + } + + /// + /// EN: Mark participant as left. + /// VI: Đánh dấu thành viên đã rời đi. + /// + public void Leave() + { + LeftAt = DateTime.UtcNow; + } + + /// + /// EN: Check if participant is still active. + /// VI: Kiểm tra thành viên còn hoạt động không. + /// + public bool IsActive => LeftAt == null; +} diff --git a/services/chat-service-net/src/ChatService.Domain/AggregatesModel/ConversationAggregate/ConversationType.cs b/services/chat-service-net/src/ChatService.Domain/AggregatesModel/ConversationAggregate/ConversationType.cs new file mode 100644 index 00000000..0ecc364c --- /dev/null +++ b/services/chat-service-net/src/ChatService.Domain/AggregatesModel/ConversationAggregate/ConversationType.cs @@ -0,0 +1,54 @@ +namespace ChatService.Domain.AggregatesModel.ConversationAggregate; + +using ChatService.Domain.SeedWork; + +/// +/// EN: Conversation type enumeration. +/// VI: Enumeration loại cuộc hội thoại. +/// +public class ConversationType : Enumeration +{ + /// + /// EN: Direct 1:1 conversation between two users. + /// VI: Hội thoại trực tiếp 1:1 giữa hai users. + /// + public static readonly ConversationType Direct = new(1, nameof(Direct).ToLowerInvariant()); + + /// + /// EN: Group conversation with multiple users. + /// VI: Hội thoại nhóm với nhiều users. + /// + public static readonly ConversationType Group = new(2, nameof(Group).ToLowerInvariant()); + + public ConversationType(int id, string name) : base(id, name) + { + } + + public static IEnumerable List() => + new[] { Direct, Group }; + + public static ConversationType FromName(string name) + { + var state = List().SingleOrDefault(s => + string.Equals(s.Name, name, StringComparison.CurrentCultureIgnoreCase)); + + if (state == null) + { + throw new ArgumentException($"Possible values for ConversationType: {string.Join(",", List().Select(s => s.Name))}"); + } + + return state; + } + + public static ConversationType From(int id) + { + var state = List().SingleOrDefault(s => s.Id == id); + + if (state == null) + { + throw new ArgumentException($"Possible values for ConversationType: {string.Join(",", List().Select(s => s.Name))}"); + } + + return state; + } +} diff --git a/services/chat-service-net/src/ChatService.Domain/AggregatesModel/ConversationAggregate/IConversationRepository.cs b/services/chat-service-net/src/ChatService.Domain/AggregatesModel/ConversationAggregate/IConversationRepository.cs new file mode 100644 index 00000000..d7bceb42 --- /dev/null +++ b/services/chat-service-net/src/ChatService.Domain/AggregatesModel/ConversationAggregate/IConversationRepository.cs @@ -0,0 +1,112 @@ +namespace ChatService.Domain.AggregatesModel.ConversationAggregate; + +using ChatService.Domain.SeedWork; + +/// +/// EN: Repository interface for Conversation aggregate. +/// VI: Interface repository cho Conversation aggregate. +/// +public interface IConversationRepository : IRepository +{ + /// + /// EN: Get conversation by ID with participants. + /// VI: Lấy conversation theo ID với danh sách thành viên. + /// + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + + /// + /// EN: Get conversation by ID with participants and recent messages. + /// VI: Lấy conversation theo ID với thành viên và tin nhắn gần đây. + /// + Task GetWithMessagesAsync(Guid id, int messageCount = 50, CancellationToken cancellationToken = default); + + /// + /// EN: Get all conversations for a user. + /// VI: Lấy tất cả conversations của một user. + /// + Task> GetUserConversationsAsync( + Guid userId, + int skip = 0, + int take = 20, + CancellationToken cancellationToken = default); + + /// + /// EN: Find existing direct conversation between two users. + /// VI: Tìm conversation trực tiếp đã tồn tại giữa hai users. + /// + Task FindDirectConversationAsync(Guid user1Id, Guid user2Id, CancellationToken cancellationToken = default); + + /// + /// EN: Get messages for a conversation with pagination. + /// VI: Lấy tin nhắn cho một conversation với phân trang. + /// + Task> GetMessagesAsync( + Guid conversationId, + int skip = 0, + int take = 50, + DateTime? before = null, + CancellationToken cancellationToken = default); + + /// + /// EN: Get unread message count for a user in a conversation. + /// VI: Lấy số tin nhắn chưa đọc cho user trong một conversation. + /// + Task GetUnreadCountAsync(Guid conversationId, Guid userId, CancellationToken cancellationToken = default); + + /// + /// EN: Get total unread count across all conversations for a user. + /// VI: Lấy tổng số tin chưa đọc trên tất cả conversations cho một user. + /// + Task GetTotalUnreadCountAsync(Guid userId, CancellationToken cancellationToken = default); + + /// + /// EN: Add a new conversation. + /// VI: Thêm một conversation mới. + /// + Conversation Add(Conversation conversation); + + /// + /// EN: Update an existing conversation. + /// VI: Cập nhật một conversation. + /// + void Update(Conversation conversation); + + /// + /// EN: Get conversations for user with pagination and total count. + /// VI: Lấy conversations cho user với phân trang và tổng số. + /// + Task<(IEnumerable Conversations, int TotalCount)> GetConversationsForUserAsync( + Guid userId, + int page = 1, + int pageSize = 20, + CancellationToken cancellationToken = default); + + /// + /// EN: Get messages for a conversation with pagination and total count. + /// VI: Lấy tin nhắn với phân trang và tổng số. + /// + Task<(IEnumerable Messages, int TotalCount)> GetMessagesPaginatedAsync( + Guid conversationId, + int page = 1, + int pageSize = 50, + DateTime? before = null, + CancellationToken cancellationToken = default); + + /// + /// EN: Get unread message count for a user in a conversation. + /// VI: Lấy số tin nhắn chưa đọc cho user trong một conversation. + /// + Task GetUnreadMessageCountAsync(Guid conversationId, Guid userId, CancellationToken cancellationToken = default); + + /// + /// EN: Check if user is participant in conversation. + /// VI: Kiểm tra user có phải participant trong conversation. + /// + Task IsUserParticipantAsync(Guid conversationId, Guid userId, CancellationToken cancellationToken = default); + + /// + /// EN: Get conversation by ID with all messages. + /// VI: Lấy conversation theo ID với tất cả messages. + /// + Task GetByIdWithMessagesAsync(Guid id, CancellationToken cancellationToken = default); +} diff --git a/services/chat-service-net/src/ChatService.Domain/AggregatesModel/ConversationAggregate/Message.cs b/services/chat-service-net/src/ChatService.Domain/AggregatesModel/ConversationAggregate/Message.cs new file mode 100644 index 00000000..e7a3afd0 --- /dev/null +++ b/services/chat-service-net/src/ChatService.Domain/AggregatesModel/ConversationAggregate/Message.cs @@ -0,0 +1,193 @@ +namespace ChatService.Domain.AggregatesModel.ConversationAggregate; + +using ChatService.Domain.Exceptions; +using ChatService.Domain.SeedWork; + +/// +/// EN: Message entity - stores encrypted message content. +/// VI: Message entity - lưu trữ nội dung tin nhắn đã mã hóa. +/// +/// +/// EN: The server stores ONLY encrypted content - cannot decrypt without client's private key. +/// VI: Server CHỈ lưu nội dung đã mã hóa - không thể giải mã mà không có private key của client. +/// +public class Message : Entity +{ + private Guid _conversationId; + private Guid _senderId; + private string _encryptedContent; + private string _nonce; + private string? _authTag; +#pragma warning disable CS0169 // Fields are used by EF Core + private int _typeId; +#pragma warning restore CS0169 + private MessageType _type; +#pragma warning disable CS0169 // Fields are used by EF Core + private int _statusId; +#pragma warning restore CS0169 + private MessageStatus _status; + private DateTime _createdAt; + private DateTime? _updatedAt; + private DateTime? _deliveredAt; + private DateTime? _readAt; + private string? _metadata; + private Guid? _replyToMessageId; + + /// + /// EN: The conversation this message belongs to. + /// VI: Cuộc hội thoại mà tin nhắn này thuộc về. + /// + public Guid ConversationId => _conversationId; + + /// + /// EN: The sender's chat user ID. + /// VI: Chat user ID của người gửi. + /// + public Guid SenderId => _senderId; + + /// + /// EN: Encrypted message content (Base64 encoded). + /// VI: Nội dung tin nhắn đã mã hóa (mã hóa Base64). + /// + /// + /// EN: Encrypted using AES-256-GCM with session key derived from X3DH. + /// VI: Mã hóa bằng AES-256-GCM với session key được derive từ X3DH. + /// + public string EncryptedContent => _encryptedContent; + + /// + /// EN: Nonce/IV for AES-GCM decryption (Base64 encoded). + /// VI: Nonce/IV cho giải mã AES-GCM (mã hóa Base64). + /// + public string Nonce => _nonce; + + /// + /// EN: Authentication tag for AES-GCM (Base64 encoded). + /// VI: Authentication tag cho AES-GCM (mã hóa Base64). + /// + public string? AuthTag => _authTag; + + /// + /// EN: Type of message content. + /// VI: Loại nội dung tin nhắn. + /// + public MessageType Type => _type; + + /// + /// EN: Current status of the message. + /// VI: Trạng thái hiện tại của tin nhắn. + /// + public MessageStatus Status => _status; + + /// + /// EN: When the message was sent. + /// VI: Thời điểm tin nhắn được gửi. + /// + public DateTime CreatedAt => _createdAt; + + /// + /// EN: When the message was last updated. + /// VI: Thời điểm tin nhắn được cập nhật lần cuối. + /// + public DateTime? UpdatedAt => _updatedAt; + + /// + /// EN: When the message was delivered. + /// VI: Thời điểm tin nhắn được gửi đến. + /// + public DateTime? DeliveredAt => _deliveredAt; + + /// + /// EN: When the message was read. + /// VI: Thời điểm tin nhắn được đọc. + /// + public DateTime? ReadAt => _readAt; + + /// + /// EN: Optional metadata (e.g., file name, size for file messages). + /// VI: Metadata tùy chọn (vd: tên file, kích thước cho tin nhắn file). + /// + public string? Metadata => _metadata; + + /// + /// EN: ID of the message this is replying to (if any). + /// VI: ID của tin nhắn đang trả lời (nếu có). + /// + public Guid? ReplyToMessageId => _replyToMessageId; + + protected Message() + { + // EN: Required by EF Core + // VI: Yêu cầu bởi EF Core + _encryptedContent = string.Empty; + _nonce = string.Empty; + _type = MessageType.Text; + _status = MessageStatus.Sent; + } + + public Message( + Guid conversationId, + Guid senderId, + string encryptedContent, + string nonce, + MessageType? type = null, + string? authTag = null, + string? metadata = null, + Guid? replyToMessageId = null) + { + if (string.IsNullOrWhiteSpace(encryptedContent)) + throw new ChatDomainException("Encrypted content is required"); + if (string.IsNullOrWhiteSpace(nonce)) + throw new ChatDomainException("Nonce is required for decryption"); + + Id = Guid.NewGuid(); + _conversationId = conversationId; + _senderId = senderId; + _encryptedContent = encryptedContent; + _nonce = nonce; + _authTag = authTag; + _type = type ?? MessageType.Text; + _status = MessageStatus.Sent; + _metadata = metadata; + _replyToMessageId = replyToMessageId; + _createdAt = DateTime.UtcNow; + } + + /// + /// EN: Mark message as delivered. + /// VI: Đánh dấu tin nhắn đã được gửi đến. + /// + public void MarkAsDelivered() + { + if (_status == MessageStatus.Read) + return; // EN: Already read, no need to mark delivered / VI: Đã đọc, không cần đánh dấu đã gửi + + _status = MessageStatus.Delivered; + _deliveredAt = DateTime.UtcNow; + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Mark message as read. + /// VI: Đánh dấu tin nhắn đã được đọc. + /// + public void MarkAsRead() + { + _status = MessageStatus.Read; + _readAt = DateTime.UtcNow; + _updatedAt = DateTime.UtcNow; + + if (_deliveredAt == null) + _deliveredAt = _readAt; + } + + /// + /// EN: Mark message as failed. + /// VI: Đánh dấu tin nhắn gửi thất bại. + /// + public void MarkAsFailed() + { + _status = MessageStatus.Failed; + _updatedAt = DateTime.UtcNow; + } +} diff --git a/services/chat-service-net/src/ChatService.Domain/AggregatesModel/ConversationAggregate/MessageStatus.cs b/services/chat-service-net/src/ChatService.Domain/AggregatesModel/ConversationAggregate/MessageStatus.cs new file mode 100644 index 00000000..7f04c179 --- /dev/null +++ b/services/chat-service-net/src/ChatService.Domain/AggregatesModel/ConversationAggregate/MessageStatus.cs @@ -0,0 +1,66 @@ +namespace ChatService.Domain.AggregatesModel.ConversationAggregate; + +using ChatService.Domain.SeedWork; + +/// +/// EN: Message status enumeration. +/// VI: Enumeration trạng thái tin nhắn. +/// +public class MessageStatus : Enumeration +{ + /// + /// EN: Message has been sent by sender. + /// VI: Tin nhắn đã được gửi bởi người gửi. + /// + public static readonly MessageStatus Sent = new(1, nameof(Sent).ToLowerInvariant()); + + /// + /// EN: Message has been delivered to recipient's device. + /// VI: Tin nhắn đã được gửi đến thiết bị người nhận. + /// + public static readonly MessageStatus Delivered = new(2, nameof(Delivered).ToLowerInvariant()); + + /// + /// EN: Message has been read by recipient. + /// VI: Tin nhắn đã được đọc bởi người nhận. + /// + public static readonly MessageStatus Read = new(3, nameof(Read).ToLowerInvariant()); + + /// + /// EN: Message sending failed. + /// VI: Gửi tin nhắn thất bại. + /// + public static readonly MessageStatus Failed = new(4, nameof(Failed).ToLowerInvariant()); + + public MessageStatus(int id, string name) : base(id, name) + { + } + + public static IEnumerable List() => + new[] { Sent, Delivered, Read, Failed }; + + public static MessageStatus FromName(string name) + { + var state = List().SingleOrDefault(s => + string.Equals(s.Name, name, StringComparison.CurrentCultureIgnoreCase)); + + if (state == null) + { + throw new ArgumentException($"Possible values for MessageStatus: {string.Join(",", List().Select(s => s.Name))}"); + } + + return state; + } + + public static MessageStatus From(int id) + { + var state = List().SingleOrDefault(s => s.Id == id); + + if (state == null) + { + throw new ArgumentException($"Possible values for MessageStatus: {string.Join(",", List().Select(s => s.Name))}"); + } + + return state; + } +} diff --git a/services/chat-service-net/src/ChatService.Domain/AggregatesModel/ConversationAggregate/MessageType.cs b/services/chat-service-net/src/ChatService.Domain/AggregatesModel/ConversationAggregate/MessageType.cs new file mode 100644 index 00000000..4208d699 --- /dev/null +++ b/services/chat-service-net/src/ChatService.Domain/AggregatesModel/ConversationAggregate/MessageType.cs @@ -0,0 +1,72 @@ +namespace ChatService.Domain.AggregatesModel.ConversationAggregate; + +using ChatService.Domain.SeedWork; + +/// +/// EN: Message type enumeration. +/// VI: Enumeration loại tin nhắn. +/// +public class MessageType : Enumeration +{ + /// + /// EN: Text message. + /// VI: Tin nhắn văn bản. + /// + public static readonly MessageType Text = new(1, nameof(Text).ToLowerInvariant()); + + /// + /// EN: Image message. + /// VI: Tin nhắn hình ảnh. + /// + public static readonly MessageType Image = new(2, nameof(Image).ToLowerInvariant()); + + /// + /// EN: File attachment message. + /// VI: Tin nhắn file đính kèm. + /// + public static readonly MessageType File = new(3, nameof(File).ToLowerInvariant()); + + /// + /// EN: Voice message. + /// VI: Tin nhắn thoại. + /// + public static readonly MessageType Voice = new(4, nameof(Voice).ToLowerInvariant()); + + /// + /// EN: System message (e.g., user joined). + /// VI: Tin nhắn hệ thống (vd: user tham gia). + /// + public static readonly MessageType System = new(5, nameof(System).ToLowerInvariant()); + + public MessageType(int id, string name) : base(id, name) + { + } + + public static IEnumerable List() => + new[] { Text, Image, File, Voice, System }; + + public static MessageType FromName(string name) + { + var state = List().SingleOrDefault(s => + string.Equals(s.Name, name, StringComparison.CurrentCultureIgnoreCase)); + + if (state == null) + { + throw new ArgumentException($"Possible values for MessageType: {string.Join(",", List().Select(s => s.Name))}"); + } + + return state; + } + + public static MessageType From(int id) + { + var state = List().SingleOrDefault(s => s.Id == id); + + if (state == null) + { + throw new ArgumentException($"Possible values for MessageType: {string.Join(",", List().Select(s => s.Name))}"); + } + + return state; + } +} diff --git a/services/chat-service-net/src/ChatService.Domain/AggregatesModel/UserAggregate/ChatUser.cs b/services/chat-service-net/src/ChatService.Domain/AggregatesModel/UserAggregate/ChatUser.cs new file mode 100644 index 00000000..a17cb201 --- /dev/null +++ b/services/chat-service-net/src/ChatService.Domain/AggregatesModel/UserAggregate/ChatUser.cs @@ -0,0 +1,226 @@ +namespace ChatService.Domain.AggregatesModel.UserAggregate; + +using ChatService.Domain.Events; +using ChatService.Domain.Exceptions; +using ChatService.Domain.SeedWork; + +/// +/// EN: Chat user aggregate root - represents a user in the chat system. +/// VI: Chat user aggregate root - đại diện một user trong hệ thống chat. +/// +/// +/// EN: Links to IAM Service user via IdentityUserId. Contains E2EE public keys. +/// VI: Liên kết với IAM Service user qua IdentityUserId. Chứa E2EE public keys. +/// +public class ChatUser : Entity, IAggregateRoot +{ + private string _identityUserId; + private string _displayName; + private string? _avatarUrl; +#pragma warning disable CS0169 // Field is used by EF Core + private int _statusId; +#pragma warning restore CS0169 + private UserStatus _status; + private UserKeyBundle? _keyBundle; + private readonly List _oneTimePreKeys; + private DateTime _lastSeenAt; + private DateTime _createdAt; + private DateTime _updatedAt; + + /// + /// EN: Reference to IAM Service user ID. + /// VI: Tham chiếu đến IAM Service user ID. + /// + public string IdentityUserId => _identityUserId; + + /// + /// EN: Display name shown in chat. + /// VI: Tên hiển thị trong chat. + /// + public string DisplayName => _displayName; + + /// + /// EN: URL to user's avatar image. + /// VI: URL ảnh avatar của user. + /// + public string? AvatarUrl => _avatarUrl; + + /// + /// EN: Current online status. + /// VI: Trạng thái online hiện tại. + /// + public UserStatus Status => _status; + + /// + /// EN: E2EE public key bundle. + /// VI: Bundle E2EE public keys. + /// + public UserKeyBundle? KeyBundle => _keyBundle; + + /// + /// EN: Available one-time pre-keys for X3DH. + /// VI: Các one-time pre-keys khả dụng cho X3DH. + /// + public IReadOnlyCollection OneTimePreKeys => _oneTimePreKeys.AsReadOnly(); + + /// + /// EN: Last time user was active. + /// VI: Lần cuối user hoạt động. + /// + public DateTime LastSeenAt => _lastSeenAt; + + public DateTime CreatedAt => _createdAt; + public DateTime UpdatedAt => _updatedAt; + + protected ChatUser() + { + // EN: Required by EF Core + // VI: Yêu cầu bởi EF Core + _identityUserId = string.Empty; + _displayName = string.Empty; + _status = UserStatus.Offline; + _oneTimePreKeys = new List(); + } + + public ChatUser(string identityUserId, string displayName, string? avatarUrl = null) : this() + { + if (string.IsNullOrWhiteSpace(identityUserId)) + throw new ChatDomainException("Identity user ID is required"); + if (string.IsNullOrWhiteSpace(displayName)) + throw new ChatDomainException("Display name is required"); + + Id = Guid.NewGuid(); + _identityUserId = identityUserId; + _displayName = displayName; + _avatarUrl = avatarUrl; + _status = UserStatus.Offline; + _lastSeenAt = DateTime.UtcNow; + _createdAt = DateTime.UtcNow; + _updatedAt = DateTime.UtcNow; + + AddDomainEvent(new ChatUserCreatedDomainEvent(this)); + } + + /// + /// EN: Register user's E2EE key bundle. + /// VI: Đăng ký E2EE key bundle của user. + /// + public void RegisterKeyBundle( + string identityPublicKey, + string signedPreKey, + string signedPreKeySignature) + { + _keyBundle = new UserKeyBundle( + identityPublicKey, + signedPreKey, + signedPreKeySignature); + + _updatedAt = DateTime.UtcNow; + + AddDomainEvent(new UserKeyBundleUpdatedDomainEvent(this)); + } + + /// + /// EN: Rotate signed pre-key. + /// VI: Xoay vòng signed pre-key. + /// + public void RotateSignedPreKey(string newSignedPreKey, string newSignature) + { + if (_keyBundle == null) + throw new ChatDomainException("Cannot rotate pre-key: no key bundle registered"); + + _keyBundle = _keyBundle.RotateSignedPreKey(newSignedPreKey, newSignature); + _updatedAt = DateTime.UtcNow; + + AddDomainEvent(new UserKeyBundleUpdatedDomainEvent(this)); + } + + /// + /// EN: Upload one-time pre-keys. + /// VI: Upload các one-time pre-keys. + /// + public void UploadOneTimePreKeys(IEnumerable<(int keyId, string publicKey)> keys) + { + foreach (var (keyId, publicKey) in keys) + { + // EN: Check for duplicate key IDs + // VI: Kiểm tra trùng lặp key ID + if (_oneTimePreKeys.Any(k => k.KeyId == keyId && !k.IsUsed)) + continue; + + var preKey = new OneTimePreKey(Id, keyId, publicKey); + _oneTimePreKeys.Add(preKey); + } + + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Consume a one-time pre-key (for key exchange). + /// VI: Sử dụng một one-time pre-key (cho key exchange). + /// + public OneTimePreKey? ConsumeOneTimePreKey() + { + var availableKey = _oneTimePreKeys.FirstOrDefault(k => !k.IsUsed); + + if (availableKey != null) + { + availableKey.MarkAsUsed(); + _updatedAt = DateTime.UtcNow; + } + + return availableKey; + } + + /// + /// EN: Get count of available one-time pre-keys. + /// VI: Lấy số lượng one-time pre-keys khả dụng. + /// + public int GetAvailableOneTimePreKeyCount() + { + return _oneTimePreKeys.Count(k => !k.IsUsed); + } + + /// + /// EN: Update display name. + /// VI: Cập nhật tên hiển thị. + /// + public void UpdateDisplayName(string displayName) + { + if (string.IsNullOrWhiteSpace(displayName)) + throw new ChatDomainException("Display name is required"); + + _displayName = displayName; + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Update avatar URL. + /// VI: Cập nhật URL avatar. + /// + public void UpdateAvatarUrl(string? avatarUrl) + { + _avatarUrl = avatarUrl; + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Set online status. + /// VI: Đặt trạng thái online. + /// + public void SetOnlineStatus(UserStatus status) + { + _status = status; + _lastSeenAt = DateTime.UtcNow; + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Update last seen timestamp. + /// VI: Cập nhật thời gian hoạt động cuối. + /// + public void UpdateLastSeen() + { + _lastSeenAt = DateTime.UtcNow; + } +} diff --git a/services/chat-service-net/src/ChatService.Domain/AggregatesModel/UserAggregate/IChatUserRepository.cs b/services/chat-service-net/src/ChatService.Domain/AggregatesModel/UserAggregate/IChatUserRepository.cs new file mode 100644 index 00000000..2ad44d9b --- /dev/null +++ b/services/chat-service-net/src/ChatService.Domain/AggregatesModel/UserAggregate/IChatUserRepository.cs @@ -0,0 +1,65 @@ +namespace ChatService.Domain.AggregatesModel.UserAggregate; + +using ChatService.Domain.SeedWork; + +/// +/// EN: Repository interface for ChatUser aggregate. +/// VI: Interface repository cho ChatUser aggregate. +/// +public interface IChatUserRepository : IRepository +{ + /// + /// EN: Get user by their IAM Service identity ID. + /// VI: Lấy user theo IAM Service identity ID. + /// + Task GetByIdentityUserIdAsync(string identityUserId, CancellationToken cancellationToken = default); + + /// + /// EN: Get user by their chat user ID. + /// VI: Lấy user theo chat user ID. + /// + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + + /// + /// EN: Get user with their key bundle and one-time pre-keys. + /// VI: Lấy user với key bundle và one-time pre-keys. + /// + Task GetWithKeysAsync(Guid id, CancellationToken cancellationToken = default); + + /// + /// EN: Get multiple users by their IDs. + /// VI: Lấy nhiều users theo danh sách ID. + /// + Task> GetByIdsAsync(IEnumerable ids, CancellationToken cancellationToken = default); + + /// + /// EN: Check if user exists by identity ID. + /// VI: Kiểm tra user tồn tại theo identity ID. + /// + Task ExistsByIdentityUserIdAsync(string identityUserId, CancellationToken cancellationToken = default); + + /// + /// EN: Add a new chat user. + /// VI: Thêm một chat user mới. + /// + ChatUser Add(ChatUser user); + + /// + /// EN: Update an existing chat user. + /// VI: Cập nhật một chat user. + /// + void Update(ChatUser user); + + /// + /// EN: Get key bundle for X3DH key exchange and consume one-time pre-key. + /// VI: Lấy key bundle cho X3DH key exchange và consume one-time pre-key. + /// + Task<(string? IdentityPublicKey, string? SignedPreKey, string? SignedPreKeySignature, OneTimePreKey? OneTimePreKey)> + GetKeyBundleAsync(Guid userId, CancellationToken cancellationToken = default); + + /// + /// EN: Get count of available one-time pre-keys. + /// VI: Lấy số lượng one-time pre-keys còn khả dụng. + /// + Task GetAvailablePreKeyCountAsync(Guid userId, CancellationToken cancellationToken = default); +} diff --git a/services/chat-service-net/src/ChatService.Domain/AggregatesModel/UserAggregate/OneTimePreKey.cs b/services/chat-service-net/src/ChatService.Domain/AggregatesModel/UserAggregate/OneTimePreKey.cs new file mode 100644 index 00000000..57e4f49c --- /dev/null +++ b/services/chat-service-net/src/ChatService.Domain/AggregatesModel/UserAggregate/OneTimePreKey.cs @@ -0,0 +1,79 @@ +namespace ChatService.Domain.AggregatesModel.UserAggregate; + +using ChatService.Domain.SeedWork; + +/// +/// EN: One-time pre-key for X3DH protocol - consumed after first use. +/// VI: One-time pre-key cho X3DH protocol - được xóa sau khi sử dụng. +/// +public class OneTimePreKey : Entity +{ + /// + /// EN: Key identifier used by client to reference this key. + /// VI: Mã định danh key được client sử dụng để tham chiếu. + /// + public int KeyId { get; private set; } + + /// + /// EN: The public key (Curve25519). + /// VI: Public key (Curve25519). + /// + public string PublicKey { get; private set; } + + /// + /// EN: Whether this key has been consumed. + /// VI: Key này đã được sử dụng chưa. + /// + public bool IsUsed { get; private set; } + + /// + /// EN: When this key was created. + /// VI: Thời điểm key được tạo. + /// + public DateTime CreatedAt { get; private set; } + + /// + /// EN: When this key was consumed (if applicable). + /// VI: Thời điểm key được sử dụng (nếu có). + /// + public DateTime? UsedAt { get; private set; } + + /// + /// EN: The user who owns this key. + /// VI: User sở hữu key này. + /// + public Guid UserId { get; private set; } + + protected OneTimePreKey() + { + // EN: Required by EF Core + // VI: Yêu cầu bởi EF Core + PublicKey = string.Empty; + } + + public OneTimePreKey(Guid userId, int keyId, string publicKey) + { + if (string.IsNullOrWhiteSpace(publicKey)) + throw new ArgumentException("Public key is required", nameof(publicKey)); + + Id = Guid.NewGuid(); + UserId = userId; + KeyId = keyId; + PublicKey = publicKey; + IsUsed = false; + CreatedAt = DateTime.UtcNow; + } + + /// + /// EN: Mark this key as consumed. + /// VI: Đánh dấu key này đã được sử dụng. + /// + public void MarkAsUsed() + { + if (IsUsed) + throw new InvalidOperationException("One-time pre-key has already been used"); + + IsUsed = true; + UsedAt = DateTime.UtcNow; + } +} diff --git a/services/chat-service-net/src/ChatService.Domain/AggregatesModel/UserAggregate/UserKeyBundle.cs b/services/chat-service-net/src/ChatService.Domain/AggregatesModel/UserAggregate/UserKeyBundle.cs new file mode 100644 index 00000000..72bec786 --- /dev/null +++ b/services/chat-service-net/src/ChatService.Domain/AggregatesModel/UserAggregate/UserKeyBundle.cs @@ -0,0 +1,96 @@ +namespace ChatService.Domain.AggregatesModel.UserAggregate; + +using ChatService.Domain.SeedWork; + +/// +/// EN: User's public key bundle for E2EE key exchange (X3DH protocol). +/// VI: Bundle public keys của user cho E2EE key exchange (X3DH protocol). +/// +/// +/// EN: Contains only PUBLIC keys - private keys are stored client-side only. +/// VI: Chỉ chứa PUBLIC keys - private keys được lưu trữ ở client. +/// +public class UserKeyBundle : ValueObject +{ + /// + /// EN: Long-term identity public key (Curve25519). + /// VI: Public key định danh lâu dài (Curve25519). + /// + public string IdentityPublicKey { get; private set; } + + /// + /// EN: Signed pre-key (rotated periodically, e.g., every 30 days). + /// VI: Signed pre-key (xoay vòng định kỳ, vd: mỗi 30 ngày). + /// + public string SignedPreKey { get; private set; } + + /// + /// EN: Signature of the signed pre-key using identity private key. + /// VI: Chữ ký của signed pre-key sử dụng identity private key. + /// + public string SignedPreKeySignature { get; private set; } + + /// + /// EN: Timestamp when signed pre-key was generated. + /// VI: Thời điểm signed pre-key được tạo. + /// + public DateTime SignedPreKeyTimestamp { get; private set; } + + protected UserKeyBundle() + { + // EN: Required by EF Core + // VI: Yêu cầu bởi EF Core + IdentityPublicKey = string.Empty; + SignedPreKey = string.Empty; + SignedPreKeySignature = string.Empty; + } + + public UserKeyBundle( + string identityPublicKey, + string signedPreKey, + string signedPreKeySignature, + DateTime? signedPreKeyTimestamp = null) + { + if (string.IsNullOrWhiteSpace(identityPublicKey)) + throw new ArgumentException("Identity public key is required", nameof(identityPublicKey)); + if (string.IsNullOrWhiteSpace(signedPreKey)) + throw new ArgumentException("Signed pre-key is required", nameof(signedPreKey)); + if (string.IsNullOrWhiteSpace(signedPreKeySignature)) + throw new ArgumentException("Signed pre-key signature is required", nameof(signedPreKeySignature)); + + IdentityPublicKey = identityPublicKey; + SignedPreKey = signedPreKey; + SignedPreKeySignature = signedPreKeySignature; + SignedPreKeyTimestamp = signedPreKeyTimestamp ?? DateTime.UtcNow; + } + + /// + /// EN: Update signed pre-key (key rotation). + /// VI: Cập nhật signed pre-key (xoay vòng key). + /// + public UserKeyBundle RotateSignedPreKey(string newSignedPreKey, string newSignature) + { + return new UserKeyBundle( + IdentityPublicKey, + newSignedPreKey, + newSignature, + DateTime.UtcNow); + } + + /// + /// EN: Check if signed pre-key needs rotation (older than 30 days). + /// VI: Kiểm tra xem signed pre-key có cần xoay vòng không (cũ hơn 30 ngày). + /// + public bool NeedsRotation(int maxAgeDays = 30) + { + return (DateTime.UtcNow - SignedPreKeyTimestamp).TotalDays > maxAgeDays; + } + + protected override IEnumerable GetEqualityComponents() + { + yield return IdentityPublicKey; + yield return SignedPreKey; + yield return SignedPreKeySignature; + yield return SignedPreKeyTimestamp; + } +} diff --git a/services/chat-service-net/src/ChatService.Domain/AggregatesModel/UserAggregate/UserStatus.cs b/services/chat-service-net/src/ChatService.Domain/AggregatesModel/UserAggregate/UserStatus.cs new file mode 100644 index 00000000..3831d980 --- /dev/null +++ b/services/chat-service-net/src/ChatService.Domain/AggregatesModel/UserAggregate/UserStatus.cs @@ -0,0 +1,47 @@ +namespace ChatService.Domain.AggregatesModel.UserAggregate; + +using ChatService.Domain.SeedWork; + +/// +/// EN: User online status enumeration. +/// VI: Enumeration trạng thái online của user. +/// +public class UserStatus : Enumeration +{ + public static readonly UserStatus Offline = new(1, nameof(Offline).ToLowerInvariant()); + public static readonly UserStatus Online = new(2, nameof(Online).ToLowerInvariant()); + public static readonly UserStatus Away = new(3, nameof(Away).ToLowerInvariant()); + public static readonly UserStatus DoNotDisturb = new(4, nameof(DoNotDisturb).ToLowerInvariant()); + + public UserStatus(int id, string name) : base(id, name) + { + } + + public static IEnumerable List() => + new[] { Offline, Online, Away, DoNotDisturb }; + + public static UserStatus FromName(string name) + { + var state = List().SingleOrDefault(s => + string.Equals(s.Name, name, StringComparison.CurrentCultureIgnoreCase)); + + if (state == null) + { + throw new ArgumentException($"Possible values for UserStatus: {string.Join(",", List().Select(s => s.Name))}"); + } + + return state; + } + + public static UserStatus From(int id) + { + var state = List().SingleOrDefault(s => s.Id == id); + + if (state == null) + { + throw new ArgumentException($"Possible values for UserStatus: {string.Join(",", List().Select(s => s.Name))}"); + } + + return state; + } +} diff --git a/services/chat-service-net/src/ChatService.Domain/ChatService.Domain.csproj b/services/chat-service-net/src/ChatService.Domain/ChatService.Domain.csproj new file mode 100644 index 00000000..262dfa5a --- /dev/null +++ b/services/chat-service-net/src/ChatService.Domain/ChatService.Domain.csproj @@ -0,0 +1,14 @@ + + + + ChatService.Domain + ChatService.Domain + Domain layer containing core business logic and entities + + + + + + + + diff --git a/services/chat-service-net/src/ChatService.Domain/Events/ConversationDomainEvents.cs b/services/chat-service-net/src/ChatService.Domain/Events/ConversationDomainEvents.cs new file mode 100644 index 00000000..db081372 --- /dev/null +++ b/services/chat-service-net/src/ChatService.Domain/Events/ConversationDomainEvents.cs @@ -0,0 +1,74 @@ +namespace ChatService.Domain.Events; + +using ChatService.Domain.AggregatesModel.ConversationAggregate; +using MediatR; + +/// +/// EN: Domain event raised when a new conversation is created. +/// VI: Domain event được phát ra khi một conversation mới được tạo. +/// +public class ConversationCreatedDomainEvent : INotification +{ + public Conversation Conversation { get; } + + public ConversationCreatedDomainEvent(Conversation conversation) + { + Conversation = conversation; + } +} + +/// +/// EN: Domain event raised when a message is sent. +/// VI: Domain event được phát ra khi một tin nhắn được gửi. +/// +public class MessageSentDomainEvent : INotification +{ + public Message Message { get; } + public Conversation Conversation { get; } + + public MessageSentDomainEvent(Message message, Conversation conversation) + { + Message = message; + Conversation = conversation; + } +} + +/// +/// EN: Domain event raised when a message is delivered. +/// VI: Domain event được phát ra khi tin nhắn được gửi đến. +/// +public class MessageDeliveredDomainEvent : INotification +{ + public Guid MessageId { get; } + public Guid ConversationId { get; } + public Guid RecipientId { get; } + public DateTime DeliveredAt { get; } + + public MessageDeliveredDomainEvent(Guid messageId, Guid conversationId, Guid recipientId) + { + MessageId = messageId; + ConversationId = conversationId; + RecipientId = recipientId; + DeliveredAt = DateTime.UtcNow; + } +} + +/// +/// EN: Domain event raised when a message is read. +/// VI: Domain event được phát ra khi tin nhắn được đọc. +/// +public class MessageReadDomainEvent : INotification +{ + public Guid MessageId { get; } + public Guid ConversationId { get; } + public Guid ReaderId { get; } + public DateTime ReadAt { get; } + + public MessageReadDomainEvent(Guid messageId, Guid conversationId, Guid readerId) + { + MessageId = messageId; + ConversationId = conversationId; + ReaderId = readerId; + ReadAt = DateTime.UtcNow; + } +} diff --git a/services/chat-service-net/src/ChatService.Domain/Events/UserDomainEvents.cs b/services/chat-service-net/src/ChatService.Domain/Events/UserDomainEvents.cs new file mode 100644 index 00000000..b1b2ee08 --- /dev/null +++ b/services/chat-service-net/src/ChatService.Domain/Events/UserDomainEvents.cs @@ -0,0 +1,32 @@ +namespace ChatService.Domain.Events; + +using ChatService.Domain.AggregatesModel.UserAggregate; +using MediatR; + +/// +/// EN: Domain event raised when a new chat user is created. +/// VI: Domain event được phát ra khi một chat user mới được tạo. +/// +public class ChatUserCreatedDomainEvent : INotification +{ + public ChatUser User { get; } + + public ChatUserCreatedDomainEvent(ChatUser user) + { + User = user; + } +} + +/// +/// EN: Domain event raised when user's key bundle is updated. +/// VI: Domain event được phát ra khi key bundle của user được cập nhật. +/// +public class UserKeyBundleUpdatedDomainEvent : INotification +{ + public ChatUser User { get; } + + public UserKeyBundleUpdatedDomainEvent(ChatUser user) + { + User = user; + } +} diff --git a/services/chat-service-net/src/ChatService.Domain/Exceptions/ChatDomainException.cs b/services/chat-service-net/src/ChatService.Domain/Exceptions/ChatDomainException.cs new file mode 100644 index 00000000..877fd1b4 --- /dev/null +++ b/services/chat-service-net/src/ChatService.Domain/Exceptions/ChatDomainException.cs @@ -0,0 +1,22 @@ +namespace ChatService.Domain.Exceptions; + +/// +/// EN: Base exception for chat domain errors. +/// VI: Exception cơ sở cho các lỗi trong chat domain. +/// +public class ChatDomainException : Exception +{ + public ChatDomainException() + { + } + + public ChatDomainException(string message) + : base(message) + { + } + + public ChatDomainException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/services/chat-service-net/src/ChatService.Domain/Exceptions/DomainException.cs b/services/chat-service-net/src/ChatService.Domain/Exceptions/DomainException.cs new file mode 100644 index 00000000..10012083 --- /dev/null +++ b/services/chat-service-net/src/ChatService.Domain/Exceptions/DomainException.cs @@ -0,0 +1,21 @@ +namespace ChatService.Domain.Exceptions; + +/// +/// EN: Base exception for domain errors. +/// VI: Exception cơ sở cho các lỗi domain. +/// +public class DomainException : Exception +{ + public DomainException() + { + } + + public DomainException(string message) : base(message) + { + } + + public DomainException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/services/chat-service-net/src/ChatService.Domain/SeedWork/Entity.cs b/services/chat-service-net/src/ChatService.Domain/SeedWork/Entity.cs new file mode 100644 index 00000000..90c09b6d --- /dev/null +++ b/services/chat-service-net/src/ChatService.Domain/SeedWork/Entity.cs @@ -0,0 +1,102 @@ +using MediatR; + +namespace ChatService.Domain.SeedWork; + +/// +/// EN: Base class for all domain entities. +/// VI: Lớp cơ sở cho tất cả các entity trong domain. +/// +public abstract class Entity +{ + private int? _requestedHashCode; + private Guid _id; + private List _domainEvents = new(); + + /// + /// EN: Unique identifier for the entity. + /// VI: Định danh duy nhất cho entity. + /// + public virtual Guid Id + { + get => _id; + protected set => _id = value; + } + + /// + /// EN: Domain events raised by this entity. + /// VI: Các domain event được phát ra bởi entity này. + /// + public IReadOnlyCollection DomainEvents => _domainEvents.AsReadOnly(); + + /// + /// EN: Add a domain event to be dispatched. + /// VI: Thêm một domain event để dispatch. + /// + public void AddDomainEvent(INotification eventItem) + { + _domainEvents.Add(eventItem); + } + + /// + /// EN: Remove a domain event. + /// VI: Xóa một domain event. + /// + public void RemoveDomainEvent(INotification eventItem) + { + _domainEvents.Remove(eventItem); + } + + /// + /// EN: Clear all domain events. + /// VI: Xóa tất cả domain events. + /// + public void ClearDomainEvents() + { + _domainEvents.Clear(); + } + + /// + /// EN: Check if entity is transient (not persisted yet). + /// VI: Kiểm tra xem entity có phải là transient (chưa lưu) không. + /// + public bool IsTransient() + { + return Id == default; + } + + public override bool Equals(object? obj) + { + if (obj is not Entity item) + return false; + + if (ReferenceEquals(this, item)) + return true; + + if (GetType() != item.GetType()) + return false; + + if (item.IsTransient() || IsTransient()) + return false; + + return item.Id == Id; + } + + public override int GetHashCode() + { + if (IsTransient()) + return base.GetHashCode(); + + _requestedHashCode ??= Id.GetHashCode() ^ 31; + return _requestedHashCode.Value; + } + + public static bool operator ==(Entity? left, Entity? right) + { + return left?.Equals(right) ?? right is null; + } + + public static bool operator !=(Entity? left, Entity? right) + { + return !(left == right); + } +} diff --git a/services/chat-service-net/src/ChatService.Domain/SeedWork/Enumeration.cs b/services/chat-service-net/src/ChatService.Domain/SeedWork/Enumeration.cs new file mode 100644 index 00000000..f57f2af5 --- /dev/null +++ b/services/chat-service-net/src/ChatService.Domain/SeedWork/Enumeration.cs @@ -0,0 +1,95 @@ +using System.Reflection; + +namespace ChatService.Domain.SeedWork; + +/// +/// EN: Base class for enumeration classes (type-safe enum pattern). +/// VI: Lớp cơ sở cho các lớp enumeration (pattern enum an toàn kiểu). +/// +/// +/// EN: This provides a type-safe alternative to enums with additional functionality +/// like validation, parsing, and rich behavior. +/// VI: Cung cấp một thay thế an toàn kiểu cho enums với các chức năng bổ sung +/// như validation, parsing, và hành vi phong phú. +/// +public abstract class Enumeration : IComparable +{ + /// + /// EN: The name of the enumeration value. + /// VI: Tên của giá trị enumeration. + /// + public string Name { get; private set; } + + /// + /// EN: The unique identifier of the enumeration value. + /// VI: Định danh duy nhất của giá trị enumeration. + /// + public int Id { get; private set; } + + protected Enumeration(int id, string name) => (Id, Name) = (id, name); + + public override string ToString() => Name; + + /// + /// EN: Get all enumeration values of a given type. + /// VI: Lấy tất cả các giá trị enumeration của một kiểu cho trước. + /// + public static IEnumerable GetAll() where T : Enumeration => + typeof(T).GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly) + .Select(f => f.GetValue(null)) + .Cast(); + + public override bool Equals(object? obj) + { + if (obj is not Enumeration otherValue) + return false; + + var typeMatches = GetType() == obj.GetType(); + var valueMatches = Id.Equals(otherValue.Id); + + return typeMatches && valueMatches; + } + + public override int GetHashCode() => Id.GetHashCode(); + + /// + /// EN: Get absolute difference between two enumeration values. + /// VI: Lấy sự khác biệt tuyệt đối giữa hai giá trị enumeration. + /// + public static int AbsoluteDifference(Enumeration firstValue, Enumeration secondValue) + { + return Math.Abs(firstValue.Id - secondValue.Id); + } + + /// + /// EN: Parse an integer ID to the corresponding enumeration value. + /// VI: Parse một ID integer thành giá trị enumeration tương ứng. + /// + public static T FromValue(int value) where T : Enumeration + { + var matchingItem = Parse(value, "value", item => item.Id == value); + return matchingItem; + } + + /// + /// EN: Parse a display name to the corresponding enumeration value. + /// VI: Parse một tên hiển thị thành giá trị enumeration tương ứng. + /// + public static T FromDisplayName(string displayName) where T : Enumeration + { + var matchingItem = Parse(displayName, "display name", item => item.Name == displayName); + return matchingItem; + } + + private static T Parse(TValue value, string description, Func predicate) where T : Enumeration + { + var matchingItem = GetAll().FirstOrDefault(predicate); + + if (matchingItem is null) + throw new InvalidOperationException($"'{value}' is not a valid {description} in {typeof(T)}"); + + return matchingItem; + } + + public int CompareTo(object? other) => Id.CompareTo(((Enumeration)other!).Id); +} diff --git a/services/chat-service-net/src/ChatService.Domain/SeedWork/IAggregateRoot.cs b/services/chat-service-net/src/ChatService.Domain/SeedWork/IAggregateRoot.cs new file mode 100644 index 00000000..1071b24c --- /dev/null +++ b/services/chat-service-net/src/ChatService.Domain/SeedWork/IAggregateRoot.cs @@ -0,0 +1,15 @@ +namespace ChatService.Domain.SeedWork; + +/// +/// EN: Marker interface for aggregate roots. +/// VI: Interface đánh dấu cho aggregate roots. +/// +/// +/// EN: Aggregate roots are the entry points to aggregates and are the only objects +/// that outside code should hold references to. +/// VI: Aggregate roots là điểm vào của aggregates và là đối tượng duy nhất +/// mà code bên ngoài nên giữ tham chiếu đến. +/// +public interface IAggregateRoot +{ +} diff --git a/services/chat-service-net/src/ChatService.Domain/SeedWork/IRepository.cs b/services/chat-service-net/src/ChatService.Domain/SeedWork/IRepository.cs new file mode 100644 index 00000000..aa98a1df --- /dev/null +++ b/services/chat-service-net/src/ChatService.Domain/SeedWork/IRepository.cs @@ -0,0 +1,15 @@ +namespace ChatService.Domain.SeedWork; + +/// +/// EN: Generic repository interface for aggregate roots. +/// VI: Interface repository generic cho aggregate roots. +/// +/// EN: The aggregate root type / VI: Kiểu aggregate root +public interface IRepository where T : IAggregateRoot +{ + /// + /// EN: The unit of work for this repository. + /// VI: Unit of work cho repository này. + /// + IUnitOfWork UnitOfWork { get; } +} diff --git a/services/chat-service-net/src/ChatService.Domain/SeedWork/IUnitOfWork.cs b/services/chat-service-net/src/ChatService.Domain/SeedWork/IUnitOfWork.cs new file mode 100644 index 00000000..03a4fbe9 --- /dev/null +++ b/services/chat-service-net/src/ChatService.Domain/SeedWork/IUnitOfWork.cs @@ -0,0 +1,30 @@ +namespace ChatService.Domain.SeedWork; + +/// +/// EN: Unit of Work pattern interface. +/// VI: Interface cho Unit of Work pattern. +/// +/// +/// EN: Maintains a list of objects affected by a business transaction +/// and coordinates the writing out of changes. +/// VI: Duy trì danh sách các đối tượng bị ảnh hưởng bởi một transaction nghiệp vụ +/// và điều phối việc ghi các thay đổi. +/// +public interface IUnitOfWork : IDisposable +{ + /// + /// EN: Save all changes made in this unit of work. + /// VI: Lưu tất cả các thay đổi được thực hiện trong unit of work này. + /// + /// EN: Cancellation token / VI: Token hủy + /// EN: Number of entities written / VI: Số entity đã ghi + Task SaveChangesAsync(CancellationToken cancellationToken = default); + + /// + /// EN: Save all changes and dispatch domain events. + /// VI: Lưu tất cả thay đổi và dispatch domain events. + /// + /// EN: Cancellation token / VI: Token hủy + /// EN: True if successful / VI: True nếu thành công + Task SaveEntitiesAsync(CancellationToken cancellationToken = default); +} diff --git a/services/chat-service-net/src/ChatService.Domain/SeedWork/ValueObject.cs b/services/chat-service-net/src/ChatService.Domain/SeedWork/ValueObject.cs new file mode 100644 index 00000000..47b6df04 --- /dev/null +++ b/services/chat-service-net/src/ChatService.Domain/SeedWork/ValueObject.cs @@ -0,0 +1,53 @@ +namespace ChatService.Domain.SeedWork; + +/// +/// EN: Base class for Value Objects following DDD patterns. +/// VI: Lớp cơ sở cho Value Objects theo mẫu DDD. +/// +/// +/// EN: Value objects are immutable and compared by their values, not identity. +/// VI: Value objects là bất biến và được so sánh theo giá trị, không phải định danh. +/// +public abstract class ValueObject +{ + /// + /// EN: Get the atomic values that make up this value object. + /// VI: Lấy các giá trị nguyên tử tạo nên value object này. + /// + protected abstract IEnumerable GetEqualityComponents(); + + public override bool Equals(object? obj) + { + if (obj is null || obj.GetType() != GetType()) + return false; + + var other = (ValueObject)obj; + return GetEqualityComponents().SequenceEqual(other.GetEqualityComponents()); + } + + public override int GetHashCode() + { + return GetEqualityComponents() + .Select(x => x?.GetHashCode() ?? 0) + .Aggregate((x, y) => x ^ y); + } + + public static bool operator ==(ValueObject? left, ValueObject? right) + { + return left?.Equals(right) ?? right is null; + } + + public static bool operator !=(ValueObject? left, ValueObject? right) + { + return !(left == right); + } + + /// + /// EN: Create a copy of this value object with modifications. + /// VI: Tạo bản sao của value object này với các thay đổi. + /// + protected ValueObject GetCopy() + { + return (ValueObject)MemberwiseClone(); + } +} diff --git a/services/chat-service-net/src/ChatService.Infrastructure/ChatService.Infrastructure.csproj b/services/chat-service-net/src/ChatService.Infrastructure/ChatService.Infrastructure.csproj new file mode 100644 index 00000000..17e00a51 --- /dev/null +++ b/services/chat-service-net/src/ChatService.Infrastructure/ChatService.Infrastructure.csproj @@ -0,0 +1,36 @@ + + + + ChatService.Infrastructure + ChatService.Infrastructure + Infrastructure layer for data access and external services + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + + + diff --git a/services/chat-service-net/src/ChatService.Infrastructure/ChatServiceContext.cs b/services/chat-service-net/src/ChatService.Infrastructure/ChatServiceContext.cs new file mode 100644 index 00000000..f541961e --- /dev/null +++ b/services/chat-service-net/src/ChatService.Infrastructure/ChatServiceContext.cs @@ -0,0 +1,200 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; +using ChatService.Domain.AggregatesModel.UserAggregate; +using ChatService.Domain.AggregatesModel.ConversationAggregate; +using ChatService.Domain.SeedWork; +using ChatService.Infrastructure.EntityConfigurations; + +namespace ChatService.Infrastructure; + +/// +/// EN: EF Core DbContext for ChatService. +/// VI: EF Core DbContext cho ChatService. +/// +public class ChatServiceContext : DbContext, IUnitOfWork +{ + private readonly IMediator _mediator; + private IDbContextTransaction? _currentTransaction; + + #region DbSets + + /// + /// EN: Chat users table. + /// VI: Bảng chat users. + /// + public DbSet ChatUsers => Set(); + + /// + /// EN: One-time pre-keys table. + /// VI: Bảng one-time pre-keys. + /// + public DbSet OneTimePreKeys => Set(); + + /// + /// EN: Conversations table. + /// VI: Bảng conversations. + /// + public DbSet Conversations => Set(); + + /// + /// EN: Conversation participants table. + /// VI: Bảng thành viên cuộc hội thoại. + /// + public DbSet ConversationParticipants => Set(); + + /// + /// EN: Messages table. + /// VI: Bảng tin nhắn. + /// + public DbSet Messages => Set(); + + #endregion + + /// + /// EN: Read-only access to current transaction. + /// VI: Truy cập chỉ đọc đến transaction hiện tại. + /// + public IDbContextTransaction? CurrentTransaction => _currentTransaction; + + /// + /// EN: Check if there is an active transaction. + /// VI: Kiểm tra xem có transaction đang hoạt động không. + /// + public bool HasActiveTransaction => _currentTransaction != null; + + public ChatServiceContext(DbContextOptions options) : base(options) + { + _mediator = null!; + } + + public ChatServiceContext(DbContextOptions options, IMediator mediator) : base(options) + { + _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); + + System.Diagnostics.Debug.WriteLine("ChatServiceContext::ctor - " + GetHashCode()); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // EN: Apply entity configurations + // VI: Áp dụng các cấu hình entity + + // User Aggregate + modelBuilder.ApplyConfiguration(new ChatUserEntityTypeConfiguration()); + modelBuilder.ApplyConfiguration(new OneTimePreKeyEntityTypeConfiguration()); + modelBuilder.ApplyConfiguration(new UserStatusEntityTypeConfiguration()); + + // Conversation Aggregate + modelBuilder.ApplyConfiguration(new ConversationEntityTypeConfiguration()); + modelBuilder.ApplyConfiguration(new ConversationParticipantEntityTypeConfiguration()); + modelBuilder.ApplyConfiguration(new MessageEntityTypeConfiguration()); + modelBuilder.ApplyConfiguration(new ConversationTypeEntityTypeConfiguration()); + modelBuilder.ApplyConfiguration(new MessageStatusEntityTypeConfiguration()); + modelBuilder.ApplyConfiguration(new MessageTypeEntityTypeConfiguration()); + } + + /// + /// EN: Save entities and dispatch domain events. + /// VI: Lưu entities và dispatch domain events. + /// + public async Task SaveEntitiesAsync(CancellationToken cancellationToken = default) + { + // EN: Dispatch domain events before saving (side effects) + // VI: Dispatch domain events trước khi lưu (side effects) + await DispatchDomainEventsAsync(); + + // EN: Save changes to database + // VI: Lưu thay đổi vào database + await base.SaveChangesAsync(cancellationToken); + + return true; + } + + /// + /// EN: Begin a new transaction if none is active. + /// VI: Bắt đầu một transaction mới nếu không có transaction nào đang hoạt động. + /// + public async Task BeginTransactionAsync() + { + if (_currentTransaction != null) return null; + + _currentTransaction = await Database.BeginTransactionAsync(System.Data.IsolationLevel.ReadCommitted); + + return _currentTransaction; + } + + /// + /// EN: Commit the current transaction. + /// VI: Commit transaction hiện tại. + /// + public async Task CommitTransactionAsync(IDbContextTransaction transaction) + { + ArgumentNullException.ThrowIfNull(transaction); + + if (transaction != _currentTransaction) + throw new InvalidOperationException($"Transaction {transaction.TransactionId} is not current"); + + try + { + await SaveChangesAsync(); + await transaction.CommitAsync(); + } + catch + { + RollbackTransaction(); + throw; + } + finally + { + if (_currentTransaction != null) + { + _currentTransaction.Dispose(); + _currentTransaction = null; + } + } + } + + /// + /// EN: Rollback the current transaction. + /// VI: Rollback transaction hiện tại. + /// + public void RollbackTransaction() + { + try + { + _currentTransaction?.Rollback(); + } + finally + { + if (_currentTransaction != null) + { + _currentTransaction.Dispose(); + _currentTransaction = null; + } + } + } + + /// + /// EN: Dispatch all domain events from tracked entities. + /// VI: Dispatch tất cả domain events từ các entities đang được track. + /// + private async Task DispatchDomainEventsAsync() + { + var domainEntities = ChangeTracker + .Entries() + .Where(x => x.Entity.DomainEvents.Any()) + .ToList(); + + var domainEvents = domainEntities + .SelectMany(x => x.Entity.DomainEvents) + .ToList(); + + domainEntities.ForEach(entity => entity.Entity.ClearDomainEvents()); + + foreach (var domainEvent in domainEvents) + { + await _mediator.Publish(domainEvent); + } + } +} diff --git a/services/chat-service-net/src/ChatService.Infrastructure/DependencyInjection.cs b/services/chat-service-net/src/ChatService.Infrastructure/DependencyInjection.cs new file mode 100644 index 00000000..43ec9067 --- /dev/null +++ b/services/chat-service-net/src/ChatService.Infrastructure/DependencyInjection.cs @@ -0,0 +1,59 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using ChatService.Domain.AggregatesModel.UserAggregate; +using ChatService.Domain.AggregatesModel.ConversationAggregate; +using ChatService.Infrastructure.Idempotency; +using ChatService.Infrastructure.Repositories; + +namespace ChatService.Infrastructure; + +/// +/// EN: Dependency injection extensions for Infrastructure layer. +/// VI: Extensions dependency injection cho lớp Infrastructure. +/// +public static class DependencyInjection +{ + /// + /// EN: Add infrastructure services to the DI container. + /// VI: Thêm các services infrastructure vào DI container. + /// + public static IServiceCollection AddInfrastructure( + this IServiceCollection services, + IConfiguration configuration) + { + // EN: Add DbContext with PostgreSQL / VI: Thêm DbContext với PostgreSQL + services.AddDbContext(options => + { + var connectionString = configuration.GetConnectionString("DefaultConnection") + ?? configuration["DATABASE_URL"] + ?? throw new InvalidOperationException("Connection string not configured"); + + options.UseNpgsql(connectionString, npgsqlOptions => + { + npgsqlOptions.MigrationsAssembly(typeof(ChatServiceContext).Assembly.FullName); + npgsqlOptions.EnableRetryOnFailure( + maxRetryCount: 5, + maxRetryDelay: TimeSpan.FromSeconds(30), + errorCodesToAdd: null); + }); + + // EN: Enable sensitive data logging in development only + // VI: Chỉ bật sensitive data logging trong development + if (Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == "Development") + { + options.EnableSensitiveDataLogging(); + options.EnableDetailedErrors(); + } + }); + + // EN: Register repositories / VI: Đăng ký repositories + services.AddScoped(); + services.AddScoped(); + + // EN: Register idempotency services / VI: Đăng ký idempotency services + services.AddScoped(); + + return services; + } +} diff --git a/services/chat-service-net/src/ChatService.Infrastructure/EntityConfigurations/ConversationEntityTypeConfigurations.cs b/services/chat-service-net/src/ChatService.Infrastructure/EntityConfigurations/ConversationEntityTypeConfigurations.cs new file mode 100644 index 00000000..4ba7d82e --- /dev/null +++ b/services/chat-service-net/src/ChatService.Infrastructure/EntityConfigurations/ConversationEntityTypeConfigurations.cs @@ -0,0 +1,302 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using ChatService.Domain.AggregatesModel.ConversationAggregate; + +namespace ChatService.Infrastructure.EntityConfigurations; + +/// +/// EN: Entity configuration for Conversation aggregate root. +/// VI: Cấu hình entity cho Conversation aggregate root. +/// +public class ConversationEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("conversations"); + + builder.HasKey(c => c.Id); + + builder.Property(c => c.Id) + .HasColumnName("id") + .ValueGeneratedNever(); + + builder.Property(c => c.Name) + .HasColumnName("name") + .HasMaxLength(256); + + builder.Property(c => c.AvatarUrl) + .HasColumnName("avatar_url") + .HasMaxLength(2048); + + builder.Property(c => c.CreatedAt) + .HasColumnName("created_at") + .IsRequired(); + + builder.Property(c => c.UpdatedAt) + .HasColumnName("updated_at") + .IsRequired(); + + builder.Property(c => c.LastMessageId) + .HasColumnName("last_message_id"); + + builder.Property(c => c.LastMessageAt) + .HasColumnName("last_message_at"); + + // EN: Configure Type as navigation to Enumeration + // VI: Cấu hình Type như navigation đến Enumeration + builder.Property("_typeId") + .UsePropertyAccessMode(PropertyAccessMode.Field) + .HasColumnName("type_id") + .IsRequired(); + + builder.HasOne(c => c.Type) + .WithMany() + .HasForeignKey("_typeId"); + + // EN: Configure Participants collection + // VI: Cấu hình collection Participants + var participantsNav = builder.Metadata.FindNavigation(nameof(Conversation.Participants)); + participantsNav?.SetPropertyAccessMode(PropertyAccessMode.Field); + + builder.HasMany(c => c.Participants) + .WithOne() + .HasForeignKey(p => p.ConversationId) + .OnDelete(DeleteBehavior.Cascade); + + // EN: Configure Messages collection + // VI: Cấu hình collection Messages + var messagesNav = builder.Metadata.FindNavigation(nameof(Conversation.Messages)); + messagesNav?.SetPropertyAccessMode(PropertyAccessMode.Field); + + builder.HasMany(c => c.Messages) + .WithOne() + .HasForeignKey(m => m.ConversationId) + .OnDelete(DeleteBehavior.Cascade); + + // EN: Index for ordering conversations + // VI: Index để sắp xếp conversations + builder.HasIndex(c => c.LastMessageAt); + } +} + +/// +/// EN: Entity configuration for ConversationParticipant entity. +/// VI: Cấu hình entity cho ConversationParticipant entity. +/// +public class ConversationParticipantEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("conversation_participants"); + + builder.HasKey(p => p.Id); + + builder.Property(p => p.Id) + .HasColumnName("id") + .ValueGeneratedNever(); + + builder.Property(p => p.ConversationId) + .HasColumnName("conversation_id") + .IsRequired(); + + builder.Property(p => p.UserId) + .HasColumnName("user_id") + .IsRequired(); + + builder.Property(p => p.JoinedAt) + .HasColumnName("joined_at") + .IsRequired(); + + builder.Property(p => p.LastReadAt) + .HasColumnName("last_read_at"); + + builder.Property(p => p.IsAdmin) + .HasColumnName("is_admin") + .IsRequired(); + + builder.Property(p => p.IsMuted) + .HasColumnName("is_muted") + .IsRequired(); + + builder.Property(p => p.LeftAt) + .HasColumnName("left_at"); + + // EN: Unique index for user per conversation + // VI: Index unique cho user trong mỗi conversation + builder.HasIndex(p => new { p.ConversationId, p.UserId }); + + // EN: Index for finding user's conversations + // VI: Index để tìm conversations của user + builder.HasIndex(p => p.UserId); + } +} + +/// +/// EN: Entity configuration for Message entity. +/// VI: Cấu hình entity cho Message entity. +/// +public class MessageEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("messages"); + + builder.HasKey(m => m.Id); + + builder.Property(m => m.Id) + .HasColumnName("id") + .ValueGeneratedNever(); + + builder.Property(m => m.ConversationId) + .HasColumnName("conversation_id") + .IsRequired(); + + builder.Property(m => m.SenderId) + .HasColumnName("sender_id") + .IsRequired(); + + // EN: Encrypted content - server cannot read this + // VI: Nội dung đã mã hóa - server không thể đọc được + builder.Property(m => m.EncryptedContent) + .HasColumnName("encrypted_content") + .IsRequired(); + + builder.Property(m => m.Nonce) + .HasColumnName("nonce") + .HasMaxLength(256) + .IsRequired(); + + builder.Property(m => m.AuthTag) + .HasColumnName("auth_tag") + .HasMaxLength(256); + + builder.Property(m => m.CreatedAt) + .HasColumnName("created_at") + .IsRequired(); + + builder.Property(m => m.UpdatedAt) + .HasColumnName("updated_at"); + + builder.Property(m => m.Metadata) + .HasColumnName("metadata"); + + builder.Property(m => m.ReplyToMessageId) + .HasColumnName("reply_to_message_id"); + + // EN: Configure Type as navigation to Enumeration + // VI: Cấu hình Type như navigation đến Enumeration + builder.Property("_typeId") + .UsePropertyAccessMode(PropertyAccessMode.Field) + .HasColumnName("type_id") + .IsRequired(); + + builder.HasOne(m => m.Type) + .WithMany() + .HasForeignKey("_typeId"); + + // EN: Configure Status as navigation to Enumeration + // VI: Cấu hình Status như navigation đến Enumeration + builder.Property("_statusId") + .UsePropertyAccessMode(PropertyAccessMode.Field) + .HasColumnName("status_id") + .IsRequired(); + + builder.HasOne(m => m.Status) + .WithMany() + .HasForeignKey("_statusId"); + + // EN: Indexes for querying messages + // VI: Indexes để query messages + builder.HasIndex(m => new { m.ConversationId, m.CreatedAt }); + builder.HasIndex(m => m.SenderId); + } +} + +/// +/// EN: Entity configuration for ConversationType enumeration. +/// VI: Cấu hình entity cho ConversationType enumeration. +/// +public class ConversationTypeEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("conversation_types"); + + builder.HasKey(t => t.Id); + + builder.Property(t => t.Id) + .HasColumnName("id") + .ValueGeneratedNever(); + + builder.Property(t => t.Name) + .HasColumnName("name") + .HasMaxLength(200) + .IsRequired(); + + builder.HasData( + ConversationType.Direct, + ConversationType.Group + ); + } +} + +/// +/// EN: Entity configuration for MessageStatus enumeration. +/// VI: Cấu hình entity cho MessageStatus enumeration. +/// +public class MessageStatusEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("message_statuses"); + + builder.HasKey(s => s.Id); + + builder.Property(s => s.Id) + .HasColumnName("id") + .ValueGeneratedNever(); + + builder.Property(s => s.Name) + .HasColumnName("name") + .HasMaxLength(200) + .IsRequired(); + + builder.HasData( + MessageStatus.Sent, + MessageStatus.Delivered, + MessageStatus.Read, + MessageStatus.Failed + ); + } +} + +/// +/// EN: Entity configuration for MessageType enumeration. +/// VI: Cấu hình entity cho MessageType enumeration. +/// +public class MessageTypeEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("message_types"); + + builder.HasKey(t => t.Id); + + builder.Property(t => t.Id) + .HasColumnName("id") + .ValueGeneratedNever(); + + builder.Property(t => t.Name) + .HasColumnName("name") + .HasMaxLength(200) + .IsRequired(); + + builder.HasData( + MessageType.Text, + MessageType.Image, + MessageType.File, + MessageType.Voice, + MessageType.System + ); + } +} diff --git a/services/chat-service-net/src/ChatService.Infrastructure/EntityConfigurations/UserEntityTypeConfigurations.cs b/services/chat-service-net/src/ChatService.Infrastructure/EntityConfigurations/UserEntityTypeConfigurations.cs new file mode 100644 index 00000000..ba27d2bc --- /dev/null +++ b/services/chat-service-net/src/ChatService.Infrastructure/EntityConfigurations/UserEntityTypeConfigurations.cs @@ -0,0 +1,170 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using ChatService.Domain.AggregatesModel.UserAggregate; + +namespace ChatService.Infrastructure.EntityConfigurations; + +/// +/// EN: Entity configuration for ChatUser aggregate root. +/// VI: Cấu hình entity cho ChatUser aggregate root. +/// +public class ChatUserEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("chat_users"); + + builder.HasKey(u => u.Id); + + builder.Property(u => u.Id) + .HasColumnName("id") + .ValueGeneratedNever(); + + builder.Property(u => u.IdentityUserId) + .HasColumnName("identity_user_id") + .HasMaxLength(256) + .IsRequired(); + + builder.HasIndex(u => u.IdentityUserId) + .IsUnique(); + + builder.Property(u => u.DisplayName) + .HasColumnName("display_name") + .HasMaxLength(256) + .IsRequired(); + + builder.Property(u => u.AvatarUrl) + .HasColumnName("avatar_url") + .HasMaxLength(2048); + + builder.Property(u => u.CreatedAt) + .HasColumnName("created_at") + .IsRequired(); + + builder.Property(u => u.UpdatedAt) + .HasColumnName("updated_at") + .IsRequired(); + + builder.Property(u => u.LastSeenAt) + .HasColumnName("last_seen_at"); + + // EN: Configure Status as navigation to Enumeration + // VI: Cấu hình Status như navigation đến Enumeration + builder.Property("_statusId") + .UsePropertyAccessMode(PropertyAccessMode.Field) + .HasColumnName("status_id") + .IsRequired(); + + builder.HasOne(u => u.Status) + .WithMany() + .HasForeignKey("_statusId"); + + // EN: Configure KeyBundle as owned entity + // VI: Cấu hình KeyBundle như owned entity + builder.OwnsOne(u => u.KeyBundle, kb => + { + kb.Property(k => k.IdentityPublicKey) + .HasColumnName("identity_public_key") + .HasMaxLength(1024); + + kb.Property(k => k.SignedPreKey) + .HasColumnName("signed_pre_key") + .HasMaxLength(1024); + + kb.Property(k => k.SignedPreKeySignature) + .HasColumnName("signed_pre_key_signature") + .HasMaxLength(1024); + + kb.Property(k => k.SignedPreKeyTimestamp) + .HasColumnName("signed_pre_key_timestamp"); + }); + + // EN: Configure OneTimePreKeys collection + // VI: Cấu hình collection OneTimePreKeys + var navigation = builder.Metadata.FindNavigation(nameof(ChatUser.OneTimePreKeys)); + navigation?.SetPropertyAccessMode(PropertyAccessMode.Field); + + builder.HasMany(u => u.OneTimePreKeys) + .WithOne() + .HasForeignKey(k => k.UserId) + .OnDelete(DeleteBehavior.Cascade); + } +} + +/// +/// EN: Entity configuration for OneTimePreKey entity. +/// VI: Cấu hình entity cho OneTimePreKey entity. +/// +public class OneTimePreKeyEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("one_time_pre_keys"); + + builder.HasKey(k => k.Id); + + builder.Property(k => k.Id) + .HasColumnName("id") + .ValueGeneratedNever(); + + builder.Property(k => k.UserId) + .HasColumnName("user_id") + .IsRequired(); + + builder.Property(k => k.KeyId) + .HasColumnName("key_id") + .IsRequired(); + + builder.Property(k => k.PublicKey) + .HasColumnName("public_key") + .HasMaxLength(1024) + .IsRequired(); + + builder.Property(k => k.IsUsed) + .HasColumnName("is_used") + .IsRequired(); + + builder.Property(k => k.CreatedAt) + .HasColumnName("created_at") + .IsRequired(); + + builder.Property(k => k.UsedAt) + .HasColumnName("used_at"); + + // EN: Index for finding available keys + // VI: Index để tìm các keys còn khả dụng + builder.HasIndex(k => new { k.UserId, k.IsUsed }); + } +} + +/// +/// EN: Entity configuration for UserStatus enumeration. +/// VI: Cấu hình entity cho UserStatus enumeration. +/// +public class UserStatusEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("user_statuses"); + + builder.HasKey(s => s.Id); + + builder.Property(s => s.Id) + .HasColumnName("id") + .ValueGeneratedNever(); + + builder.Property(s => s.Name) + .HasColumnName("name") + .HasMaxLength(200) + .IsRequired(); + + // EN: Seed data + // VI: Dữ liệu seed + builder.HasData( + UserStatus.Offline, + UserStatus.Online, + UserStatus.Away, + UserStatus.DoNotDisturb + ); + } +} diff --git a/services/chat-service-net/src/ChatService.Infrastructure/Idempotency/ClientRequest.cs b/services/chat-service-net/src/ChatService.Infrastructure/Idempotency/ClientRequest.cs new file mode 100644 index 00000000..3a0e9ac0 --- /dev/null +++ b/services/chat-service-net/src/ChatService.Infrastructure/Idempotency/ClientRequest.cs @@ -0,0 +1,26 @@ +namespace ChatService.Infrastructure.Idempotency; + +/// +/// EN: Entity for tracking client requests to ensure idempotency. +/// VI: Entity để theo dõi các requests từ client đảm bảo idempotency. +/// +public class ClientRequest +{ + /// + /// EN: Unique request identifier. + /// VI: Định danh request duy nhất. + /// + public Guid Id { get; set; } + + /// + /// EN: Name of the command/request type. + /// VI: Tên của loại command/request. + /// + public string Name { get; set; } = null!; + + /// + /// EN: Timestamp when the request was received. + /// VI: Thời điểm request được nhận. + /// + public DateTime Time { get; set; } +} diff --git a/services/chat-service-net/src/ChatService.Infrastructure/Idempotency/IRequestManager.cs b/services/chat-service-net/src/ChatService.Infrastructure/Idempotency/IRequestManager.cs new file mode 100644 index 00000000..0587381f --- /dev/null +++ b/services/chat-service-net/src/ChatService.Infrastructure/Idempotency/IRequestManager.cs @@ -0,0 +1,24 @@ +namespace ChatService.Infrastructure.Idempotency; + +/// +/// EN: Interface for managing client request idempotency. +/// VI: Interface để quản lý idempotency của client requests. +/// +public interface IRequestManager +{ + /// + /// EN: Check if a request with the given ID exists. + /// VI: Kiểm tra xem request với ID cho trước có tồn tại không. + /// + /// EN: Request ID / VI: ID của request + /// EN: True if exists / VI: True nếu tồn tại + Task ExistAsync(Guid id); + + /// + /// EN: Create a new request record for tracking. + /// VI: Tạo bản ghi request mới để theo dõi. + /// + /// EN: Command type / VI: Loại command + /// EN: Request ID / VI: ID của request + Task CreateRequestForCommandAsync(Guid id); +} diff --git a/services/chat-service-net/src/ChatService.Infrastructure/Idempotency/RequestManager.cs b/services/chat-service-net/src/ChatService.Infrastructure/Idempotency/RequestManager.cs new file mode 100644 index 00000000..753f1ece --- /dev/null +++ b/services/chat-service-net/src/ChatService.Infrastructure/Idempotency/RequestManager.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore; + +namespace ChatService.Infrastructure.Idempotency; + +/// +/// EN: Implementation of request manager for idempotency. +/// VI: Triển khai request manager cho idempotency. +/// +public class RequestManager : IRequestManager +{ + private readonly ChatServiceContext _context; + + public RequestManager(ChatServiceContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + /// + public async Task ExistAsync(Guid id) + { + var request = await _context + .FindAsync(id); + + return request != null; + } + + /// + public async Task CreateRequestForCommandAsync(Guid id) + { + var exists = await ExistAsync(id); + + var request = exists + ? throw new InvalidOperationException($"Request with {id} already exists") + : new ClientRequest + { + Id = id, + Name = typeof(T).Name, + Time = DateTime.UtcNow + }; + + _context.Add(request); + + await _context.SaveChangesAsync(); + } +} diff --git a/services/chat-service-net/src/ChatService.Infrastructure/Repositories/ChatUserRepository.cs b/services/chat-service-net/src/ChatService.Infrastructure/Repositories/ChatUserRepository.cs new file mode 100644 index 00000000..75b48365 --- /dev/null +++ b/services/chat-service-net/src/ChatService.Infrastructure/Repositories/ChatUserRepository.cs @@ -0,0 +1,102 @@ +using Microsoft.EntityFrameworkCore; +using ChatService.Domain.AggregatesModel.UserAggregate; +using ChatService.Domain.SeedWork; + +namespace ChatService.Infrastructure.Repositories; + +/// +/// EN: Repository implementation for ChatUser aggregate. +/// VI: Repository implementation cho ChatUser aggregate. +/// +public class ChatUserRepository : IChatUserRepository +{ + private readonly ChatServiceContext _context; + + public IUnitOfWork UnitOfWork => _context; + + public ChatUserRepository(ChatServiceContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _context.ChatUsers + .Include(u => u.Status) + .FirstOrDefaultAsync(u => u.Id == id, cancellationToken); + } + + public async Task GetByIdentityUserIdAsync(string identityUserId, CancellationToken cancellationToken = default) + { + return await _context.ChatUsers + .Include(u => u.Status) + .FirstOrDefaultAsync(u => u.IdentityUserId == identityUserId, cancellationToken); + } + + public async Task GetWithKeysAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _context.ChatUsers + .Include(u => u.Status) + .Include(u => u.OneTimePreKeys.Where(k => !k.IsUsed)) + .FirstOrDefaultAsync(u => u.Id == id, cancellationToken); + } + + public async Task> GetByIdsAsync(IEnumerable ids, CancellationToken cancellationToken = default) + { + return await _context.ChatUsers + .Include(u => u.Status) + .Where(u => ids.Contains(u.Id)) + .ToListAsync(cancellationToken); + } + + public async Task<(string? IdentityPublicKey, string? SignedPreKey, string? SignedPreKeySignature, OneTimePreKey? OneTimePreKey)> + GetKeyBundleAsync(Guid userId, CancellationToken cancellationToken = default) + { + var user = await _context.ChatUsers + .Include(u => u.OneTimePreKeys.Where(k => !k.IsUsed).Take(1)) + .FirstOrDefaultAsync(u => u.Id == userId, cancellationToken); + + if (user?.KeyBundle == null) + return (null, null, null, null); + + var oneTimePreKey = user.OneTimePreKeys.FirstOrDefault(); + if (oneTimePreKey != null) + { + oneTimePreKey.MarkAsUsed(); + } + + return ( + user.KeyBundle.IdentityPublicKey, + user.KeyBundle.SignedPreKey, + user.KeyBundle.SignedPreKeySignature, + oneTimePreKey + ); + } + + public async Task ExistsAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _context.ChatUsers.AnyAsync(u => u.Id == id, cancellationToken); + } + + public async Task ExistsByIdentityUserIdAsync(string identityUserId, CancellationToken cancellationToken = default) + { + return await _context.ChatUsers.AnyAsync(u => u.IdentityUserId == identityUserId, cancellationToken); + } + + public async Task GetAvailablePreKeyCountAsync(Guid userId, CancellationToken cancellationToken = default) + { + return await _context.OneTimePreKeys + .Where(k => k.UserId == userId && !k.IsUsed) + .CountAsync(cancellationToken); + } + + public ChatUser Add(ChatUser user) + { + return _context.ChatUsers.Add(user).Entity; + } + + public void Update(ChatUser user) + { + _context.Entry(user).State = EntityState.Modified; + } +} diff --git a/services/chat-service-net/src/ChatService.Infrastructure/Repositories/ConversationRepository.cs b/services/chat-service-net/src/ChatService.Infrastructure/Repositories/ConversationRepository.cs new file mode 100644 index 00000000..098c4d18 --- /dev/null +++ b/services/chat-service-net/src/ChatService.Infrastructure/Repositories/ConversationRepository.cs @@ -0,0 +1,229 @@ +using Microsoft.EntityFrameworkCore; +using ChatService.Domain.AggregatesModel.ConversationAggregate; +using ChatService.Domain.SeedWork; + +namespace ChatService.Infrastructure.Repositories; + +/// +/// EN: Repository implementation for Conversation aggregate. +/// VI: Repository implementation cho Conversation aggregate. +/// +public class ConversationRepository : IConversationRepository +{ + private readonly ChatServiceContext _context; + + public IUnitOfWork UnitOfWork => _context; + + public ConversationRepository(ChatServiceContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _context.Conversations + .Include(c => c.Type) + .Include(c => c.Participants.Where(p => p.LeftAt == null)) + .FirstOrDefaultAsync(c => c.Id == id, cancellationToken); + } + + public async Task GetWithMessagesAsync(Guid id, int messageCount = 50, CancellationToken cancellationToken = default) + { + var conversation = await _context.Conversations + .Include(c => c.Type) + .Include(c => c.Participants.Where(p => p.LeftAt == null)) + .FirstOrDefaultAsync(c => c.Id == id, cancellationToken); + + if (conversation == null) + return null; + + // EN: Load messages separately for pagination + // VI: Load messages riêng để phân trang + var messages = await _context.Messages + .Include(m => m.Type) + .Include(m => m.Status) + .Where(m => m.ConversationId == id) + .OrderByDescending(m => m.CreatedAt) + .Take(messageCount) + .ToListAsync(cancellationToken); + + return conversation; + } + + public async Task> GetUserConversationsAsync( + Guid userId, + int skip = 0, + int take = 20, + CancellationToken cancellationToken = default) + { + return await _context.Conversations + .Include(c => c.Type) + .Include(c => c.Participants.Where(p => p.LeftAt == null)) + .Where(c => c.Participants.Any(p => p.UserId == userId && p.LeftAt == null)) + .OrderByDescending(c => c.LastMessageAt ?? c.CreatedAt) + .Skip(skip) + .Take(take) + .ToListAsync(cancellationToken); + } + + public async Task FindDirectConversationAsync(Guid user1Id, Guid user2Id, CancellationToken cancellationToken = default) + { + // EN: Find direct conversation between two users + // VI: Tìm conversation trực tiếp giữa hai users + return await _context.Conversations + .Include(c => c.Type) + .Include(c => c.Participants) + .Where(c => c.Type == ConversationType.Direct) + .Where(c => c.Participants.Any(p => p.UserId == user1Id && p.LeftAt == null)) + .Where(c => c.Participants.Any(p => p.UserId == user2Id && p.LeftAt == null)) + .FirstOrDefaultAsync(cancellationToken); + } + + public async Task> GetMessagesAsync( + Guid conversationId, + int skip = 0, + int take = 50, + DateTime? before = null, + CancellationToken cancellationToken = default) + { + var query = _context.Messages + .Include(m => m.Type) + .Include(m => m.Status) + .Where(m => m.ConversationId == conversationId); + + if (before.HasValue) + { + query = query.Where(m => m.CreatedAt < before.Value); + } + + return await query + .OrderByDescending(m => m.CreatedAt) + .Skip(skip) + .Take(take) + .ToListAsync(cancellationToken); + } + + public async Task GetUnreadCountAsync(Guid conversationId, Guid userId, CancellationToken cancellationToken = default) + { + var participant = await _context.ConversationParticipants + .FirstOrDefaultAsync(p => p.ConversationId == conversationId && p.UserId == userId, cancellationToken); + + if (participant == null) + return 0; + + var lastReadAt = participant.LastReadAt ?? participant.JoinedAt; + + return await _context.Messages + .Where(m => m.ConversationId == conversationId) + .Where(m => m.SenderId != userId) + .Where(m => m.CreatedAt > lastReadAt) + .CountAsync(cancellationToken); + } + + public async Task GetTotalUnreadCountAsync(Guid userId, CancellationToken cancellationToken = default) + { + var userConversations = await _context.ConversationParticipants + .Where(p => p.UserId == userId && p.LeftAt == null) + .ToListAsync(cancellationToken); + + var totalUnread = 0; + + foreach (var participant in userConversations) + { + var lastReadAt = participant.LastReadAt ?? participant.JoinedAt; + var unreadCount = await _context.Messages + .Where(m => m.ConversationId == participant.ConversationId) + .Where(m => m.SenderId != userId) + .Where(m => m.CreatedAt > lastReadAt) + .CountAsync(cancellationToken); + + totalUnread += unreadCount; + } + + return totalUnread; + } + + public Conversation Add(Conversation conversation) + { + return _context.Conversations.Add(conversation).Entity; + } + + public void Update(Conversation conversation) + { + _context.Entry(conversation).State = EntityState.Modified; + } + + public async Task<(IEnumerable Conversations, int TotalCount)> GetConversationsForUserAsync( + Guid userId, + int page = 1, + int pageSize = 20, + CancellationToken cancellationToken = default) + { + var query = _context.Conversations + .Include(c => c.Type) + .Include(c => c.Participants.Where(p => p.LeftAt == null)) + .Where(c => c.Participants.Any(p => p.UserId == userId && p.LeftAt == null)); + + var totalCount = await query.CountAsync(cancellationToken); + + var conversations = await query + .OrderByDescending(c => c.LastMessageAt ?? c.CreatedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + + return (conversations, totalCount); + } + + public async Task<(IEnumerable Messages, int TotalCount)> GetMessagesPaginatedAsync( + Guid conversationId, + int page = 1, + int pageSize = 50, + DateTime? before = null, + CancellationToken cancellationToken = default) + { + var query = _context.Messages + .Include(m => m.Type) + .Include(m => m.Status) + .Where(m => m.ConversationId == conversationId); + + if (before.HasValue) + { + query = query.Where(m => m.CreatedAt < before.Value); + } + + var totalCount = await query.CountAsync(cancellationToken); + + var messages = await query + .OrderByDescending(m => m.CreatedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + + return (messages, totalCount); + } + + public async Task GetUnreadMessageCountAsync(Guid conversationId, Guid userId, CancellationToken cancellationToken = default) + { + return await GetUnreadCountAsync(conversationId, userId, cancellationToken); + } + + public async Task IsUserParticipantAsync(Guid conversationId, Guid userId, CancellationToken cancellationToken = default) + { + return await _context.ConversationParticipants + .AnyAsync(p => p.ConversationId == conversationId && p.UserId == userId && p.LeftAt == null, cancellationToken); + } + + public async Task GetByIdWithMessagesAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _context.Conversations + .Include(c => c.Type) + .Include(c => c.Participants.Where(p => p.LeftAt == null)) + .Include(c => c.Messages) + .ThenInclude(m => m.Type) + .Include(c => c.Messages) + .ThenInclude(m => m.Status) + .FirstOrDefaultAsync(c => c.Id == id, cancellationToken); + } +} + diff --git a/services/chat-service-net/tests/ChatService.FunctionalTests/ChatService.FunctionalTests.csproj b/services/chat-service-net/tests/ChatService.FunctionalTests/ChatService.FunctionalTests.csproj new file mode 100644 index 00000000..ec6da856 --- /dev/null +++ b/services/chat-service-net/tests/ChatService.FunctionalTests/ChatService.FunctionalTests.csproj @@ -0,0 +1,38 @@ + + + + ChatService.FunctionalTests + ChatService.FunctionalTests + false + true + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/services/chat-service-net/tests/ChatService.FunctionalTests/Controllers/SamplesControllerTests.cs b/services/chat-service-net/tests/ChatService.FunctionalTests/Controllers/SamplesControllerTests.cs new file mode 100644 index 00000000..48015684 --- /dev/null +++ b/services/chat-service-net/tests/ChatService.FunctionalTests/Controllers/SamplesControllerTests.cs @@ -0,0 +1,80 @@ +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using Xunit; + +namespace ChatService.FunctionalTests.Controllers; + +/// +/// EN: Functional tests for Samples API endpoints. +/// VI: Functional tests cho các endpoints API Samples. +/// +public class SamplesControllerTests : IClassFixture +{ + private readonly HttpClient _client; + + public SamplesControllerTests(CustomWebApplicationFactory factory) + { + _client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + } + + [Fact] + public async Task GetSamples_ShouldReturnOkWithEmptyList() + { + // Act + var response = await _client.GetAsync("/api/v1/samples"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadFromJsonAsync>>(); + content?.Success.Should().BeTrue(); + } + + [Fact] + public async Task CreateSample_WithValidData_ShouldReturnCreated() + { + // Arrange + var request = new { Name = "Test Sample", Description = "Test Description" }; + + // Act + var response = await _client.PostAsJsonAsync("/api/v1/samples", request); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Created); + var content = await response.Content.ReadFromJsonAsync>(); + content?.Success.Should().BeTrue(); + content?.Data?.Id.Should().NotBeEmpty(); + } + + [Fact] + public async Task GetSample_WithInvalidId_ShouldReturnNotFound() + { + // Arrange + var invalidId = Guid.NewGuid(); + + // Act + var response = await _client.GetAsync($"/api/v1/samples/{invalidId}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task HealthCheck_ShouldReturnHealthy() + { + // Act + var response = await _client.GetAsync("/health/live"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + // EN: Helper DTOs for deserialization + // VI: Helper DTOs để deserialize + private record ApiResponse(bool Success, T? Data); + private record CreateSampleResult(Guid Id); +} diff --git a/services/chat-service-net/tests/ChatService.FunctionalTests/CustomWebApplicationFactory.cs b/services/chat-service-net/tests/ChatService.FunctionalTests/CustomWebApplicationFactory.cs new file mode 100644 index 00000000..7c7575fd --- /dev/null +++ b/services/chat-service-net/tests/ChatService.FunctionalTests/CustomWebApplicationFactory.cs @@ -0,0 +1,56 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using ChatService.Infrastructure; + +namespace ChatService.FunctionalTests; + +/// +/// EN: Custom WebApplicationFactory for functional tests. +/// VI: WebApplicationFactory tùy chỉnh cho functional tests. +/// +public class CustomWebApplicationFactory : WebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Testing"); + + builder.ConfigureServices(services => + { + // EN: Remove the existing DbContext registration + // VI: Xóa đăng ký DbContext hiện tại + var descriptor = services.SingleOrDefault( + d => d.ServiceType == typeof(DbContextOptions)); + + if (descriptor != null) + { + services.Remove(descriptor); + } + + // EN: Remove DbContext service + // VI: Xóa DbContext service + var dbContextDescriptor = services.SingleOrDefault( + d => d.ServiceType == typeof(ChatServiceContext)); + + if (dbContextDescriptor != null) + { + services.Remove(dbContextDescriptor); + } + + // EN: Add in-memory database for testing + // VI: Thêm in-memory database để test + services.AddDbContext(options => + { + options.UseInMemoryDatabase("TestDatabase_" + Guid.NewGuid().ToString()); + }); + + // EN: Ensure database is created with seed data + // VI: Đảm bảo database được tạo với seed data + var sp = services.BuildServiceProvider(); + using var scope = sp.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.EnsureCreated(); + }); + } +} diff --git a/services/chat-service-net/tests/ChatService.UnitTests/ChatService.UnitTests.csproj b/services/chat-service-net/tests/ChatService.UnitTests/ChatService.UnitTests.csproj new file mode 100644 index 00000000..37baced3 --- /dev/null +++ b/services/chat-service-net/tests/ChatService.UnitTests/ChatService.UnitTests.csproj @@ -0,0 +1,35 @@ + + + + ChatService.UnitTests + ChatService.UnitTests + false + true + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/services/membership-service-net/.env.example b/services/membership-service-net/.env.example new file mode 100644 index 00000000..f9053bc3 --- /dev/null +++ b/services/membership-service-net/.env.example @@ -0,0 +1,40 @@ +# Environment / Môi Trường +ASPNETCORE_ENVIRONMENT=Development + +# Database / Cơ Sở Dữ Liệu +# PostgreSQL connection string (Neon or local) +DATABASE_URL=Host=localhost;Port=5432;Database=myservice_db;Username=postgres;Password=postgres + +# Redis Cache +REDIS_URL=localhost:6379 +REDIS_PASSWORD= + +# JWT Authentication / Xác Thực JWT +JWT_SECRET=your-secret-key-min-32-characters-long-here +JWT_ISSUER=goodgo-platform +JWT_AUDIENCE=goodgo-services +JWT_ACCESS_TOKEN_EXPIRY_MINUTES=15 +JWT_REFRESH_TOKEN_EXPIRY_DAYS=7 + +# API Configuration / Cấu Hình API +API_PORT=5000 +API_BASE_PATH=/api/v1/myservice + +# Observability / Quan Sát +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 +OTEL_SERVICE_NAME=myservice + +# Logging +LOG_LEVEL=Information +SEQ_URL=http://localhost:5341 + +# Feature Flags +FEATURE_SWAGGER_ENABLED=true +FEATURE_DETAILED_ERRORS=true + +# Rate Limiting +RATE_LIMIT_PERMITS_PER_MINUTE=100 +RATE_LIMIT_QUEUE_LIMIT=10 + +# Health Checks +HEALTHCHECK_TIMEOUT_SECONDS=5 diff --git a/services/membership-service-net/.gitignore b/services/membership-service-net/.gitignore new file mode 100644 index 00000000..84b02a53 --- /dev/null +++ b/services/membership-service-net/.gitignore @@ -0,0 +1,75 @@ +# Build results +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio +.vs/ +*.user +*.userosscache +*.suo +*.userprefs +*.sln.docstates + +# Rider +.idea/ +*.sln.iml + +# Visual Studio Code +.vscode/ + +# NuGet +*.nupkg +*.snupkg +.nuget/ +packages/ +project.lock.json +project.fragment.lock.json + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# Coverage +TestResults/ +*.coverage +*.coveragexml +coverage*.json +coverage*.xml + +# Publish output +publish/ +out/ + +# Environment files +.env +.env.local +.env.*.local +*.env + +# Secrets +appsettings.*.json +!appsettings.json +!appsettings.Development.json + +# macOS +.DS_Store + +# Windows +Thumbs.db +ehthumbs.db + +# JetBrains +*.resharper + +# dotnet tools +.config/dotnet-tools.json + +# Migration scripts (only keep structure) +Migrations/ + +# Temp files +*.tmp +*.temp +~$* diff --git a/services/membership-service-net/Directory.Build.props b/services/membership-service-net/Directory.Build.props new file mode 100644 index 00000000..c3b74373 --- /dev/null +++ b/services/membership-service-net/Directory.Build.props @@ -0,0 +1,22 @@ + + + net10.0 + 14.0 + enable + enable + true + true + $(NoWarn);1591;CA2017 + + + + GoodGo Team + GoodGo + © 2026 GoodGo. All rights reserved. + git + + + + + + diff --git a/services/membership-service-net/Dockerfile b/services/membership-service-net/Dockerfile new file mode 100644 index 00000000..192106ab --- /dev/null +++ b/services/membership-service-net/Dockerfile @@ -0,0 +1,66 @@ +# Build stage / Giai đoạn build +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +# EN: Copy project files for layer caching +# VI: Sao chép các file project để tận dụng layer caching +COPY ["src/MyService.API/MyService.API.csproj", "src/MyService.API/"] +COPY ["src/MyService.Domain/MyService.Domain.csproj", "src/MyService.Domain/"] +COPY ["src/MyService.Infrastructure/MyService.Infrastructure.csproj", "src/MyService.Infrastructure/"] +COPY ["Directory.Build.props", "./"] + +# EN: Restore dependencies +# VI: Khôi phục dependencies +RUN dotnet restore "src/MyService.API/MyService.API.csproj" + +# EN: Copy all source code +# VI: Sao chép toàn bộ source code +COPY src/ ./src/ + +# EN: Build the application +# VI: Build ứng dụng +WORKDIR "/src/src/MyService.API" +RUN dotnet build "MyService.API.csproj" -c Release -o /app/build --no-restore + +# Publish stage / Giai đoạn publish +FROM build AS publish +RUN dotnet publish "MyService.API.csproj" -c Release -o /app/publish /p:UseAppHost=false --no-restore + +# Runtime stage / Giai đoạn runtime +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final +WORKDIR /app + +# EN: Create non-root user for security +# VI: Tạo user non-root cho bảo mật +RUN groupadd -g 1001 dotnetuser && \ + useradd -u 1001 -g dotnetuser -s /bin/sh dotnetuser + +# EN: Copy published application +# VI: Sao chép ứng dụng đã publish +COPY --from=publish /app/publish . + +# EN: Change ownership to non-root user +# VI: Thay đổi quyền sở hữu sang user non-root +RUN chown -R dotnetuser:dotnetuser /app + +# EN: Switch to non-root user +# VI: Chuyển sang user non-root +USER dotnetuser + +# EN: Expose port +# VI: Mở cổng +EXPOSE 8080 + +# EN: Set environment variables +# VI: Thiết lập biến môi trường +ENV ASPNETCORE_URLS=http://+:8080 +ENV ASPNETCORE_ENVIRONMENT=Production + +# EN: Health check +# VI: Kiểm tra health +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD curl -f http://localhost:8080/health/live || exit 1 + +# EN: Start the application +# VI: Khởi động ứng dụng +ENTRYPOINT ["dotnet", "MyService.API.dll"] diff --git a/services/membership-service-net/MembershipService.slnx b/services/membership-service-net/MembershipService.slnx new file mode 100644 index 00000000..96753eb4 --- /dev/null +++ b/services/membership-service-net/MembershipService.slnx @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/services/membership-service-net/docker-compose.yml b/services/membership-service-net/docker-compose.yml new file mode 100644 index 00000000..254ceb12 --- /dev/null +++ b/services/membership-service-net/docker-compose.yml @@ -0,0 +1,72 @@ +version: '3.8' + +# EN: Docker Compose for local development +# VI: Docker Compose cho phát triển local + +services: + myservice-api: + build: + context: . + dockerfile: Dockerfile + container_name: myservice-api + ports: + - "5000:8080" + environment: + - ASPNETCORE_ENVIRONMENT=Development + - DATABASE_URL=Host=postgres;Port=5432;Database=myservice_db;Username=postgres;Password=postgres + - REDIS_URL=redis:6379 + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + networks: + - myservice-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health/live"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + + postgres: + image: postgres:16-alpine + container_name: myservice-postgres + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: myservice_db + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - myservice-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + container_name: myservice-redis + ports: + - "6379:6379" + volumes: + - redis_data:/data + networks: + - myservice-network + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + postgres_data: + redis_data: + +networks: + myservice-network: + driver: bridge diff --git a/services/membership-service-net/docs/en/ARCHITECTURE.md b/services/membership-service-net/docs/en/ARCHITECTURE.md new file mode 100644 index 00000000..1c92c619 --- /dev/null +++ b/services/membership-service-net/docs/en/ARCHITECTURE.md @@ -0,0 +1,378 @@ +# Membership Service Architecture + +## System Overview + +The Membership Service extends user profiles from the IAM Service with additional information like contact details, addresses, and membership tiers. + +```mermaid +graph TB + subgraph Client + WEB[Web App] + MOBILE[Mobile App] + end + + subgraph API_Gateway + TRAEFIK[Traefik] + end + + subgraph Services + IAM[IAM Service] + MEMBER[Membership Service] + STORAGE[Storage Service] + end + + subgraph Data + PG[(PostgreSQL)] + end + + WEB --> TRAEFIK + MOBILE --> TRAEFIK + TRAEFIK --> |/api/v1/auth| IAM + TRAEFIK --> |/api/v1/members| MEMBER + TRAEFIK --> |/api/v1/files| STORAGE + + IAM --> |JWT Token| MEMBER + MEMBER --> PG + MEMBER --> |Avatar URL| STORAGE +``` + +## Clean Architecture Layers + +``` +┌────────────────────────────────────────────────────────────┐ +│ API Layer (Presentation) │ +│ Controllers, Commands, Queries, Validators, DTOs │ +├────────────────────────────────────────────────────────────┤ +│ Domain Layer (Core) │ +│ Entities, Aggregates, Value Objects, Domain Events │ +├────────────────────────────────────────────────────────────┤ +│ Infrastructure Layer │ +│ DbContext, Repositories, Entity Configurations │ +└────────────────────────────────────────────────────────────┘ +``` + +## Domain Model + +### Aggregate Structure + +```mermaid +classDiagram + class Member { + +Guid Id + +Guid UserId + +string? PhoneNumber + +string? AvatarUrl + +string? AddressLine1 + +string? AddressLine2 + +string? City + +string? State + +string? PostalCode + +string CountryCode + +DateOnly? DateOfBirth + +string? Gender + +MembershipLevel MembershipLevel + +string? Preferences + +bool IsDeleted + +DateTime CreatedAt + +DateTime UpdatedAt + +UpdateProfile() + +UpdateAddress() + +ChangeMembershipLevel() + +Delete() + } + + class MembershipLevel { + <> + +int Id + +string Name + +Free + +Basic + +Premium + } + + Member --> MembershipLevel +``` + +### Domain Events + +| Event | Trigger | Purpose | +|-------|---------|---------| +| `MemberCreatedDomainEvent` | New member created | Welcome email, analytics | +| `MemberUpdatedDomainEvent` | Profile updated | Sync to other services | +| `MembershipLevelChangedDomainEvent` | Level changed | Billing, benefits update | + +## CQRS Pattern + +```mermaid +flowchart LR + subgraph Commands[Write Side] + CMD1[CreateMemberCommand] + CMD2[UpdateMemberProfileCommand] + CMD3[ChangeMembershipLevelCommand] + end + + subgraph Handlers[Command Handlers] + H1[CreateMemberHandler] + H2[UpdateProfileHandler] + H3[ChangeLevelHandler] + end + + subgraph Queries[Read Side] + Q1[GetMemberByIdQuery] + Q2[GetMembersQuery] + end + + subgraph QueryHandlers[Query Handlers] + QH1[GetMemberByIdHandler] + QH2[GetMembersHandler] + end + + CMD1 --> H1 --> DB[(Database)] + CMD2 --> H2 --> DB + CMD3 --> H3 --> DB + + Q1 --> QH1 --> DB + Q2 --> QH2 --> DB +``` + +## Request Flow + +### Create Member Flow + +```mermaid +sequenceDiagram + participant Client + participant Controller + participant MediatR + participant Handler + participant Repository + participant DB + + Client->>Controller: POST /api/v1/members + Controller->>MediatR: Send(CreateMemberCommand) + MediatR->>Handler: Handle(command) + Handler->>Repository: ExistsByUserIdAsync(userId) + Repository->>DB: SELECT EXISTS + DB-->>Repository: false + Handler->>Repository: Add(member) + Handler->>Repository: SaveEntitiesAsync() + Repository->>DB: INSERT + Domain Events + DB-->>Repository: OK + Repository-->>Handler: member + Handler-->>MediatR: CreateMemberResult + MediatR-->>Controller: result + Controller-->>Client: 201 Created +``` + +## Database Schema + +```mermaid +erDiagram + MEMBERS { + uuid id PK + varchar phone_number + varchar avatar_url + varchar address_line_1 + varchar address_line_2 + varchar city + varchar state + varchar postal_code + varchar country_code + date date_of_birth + varchar gender + int membership_level_id FK + jsonb preferences + boolean is_deleted + timestamp created_at + timestamp updated_at + } + + MEMBERSHIP_LEVELS { + int id PK + varchar name + } + + MEMBERS }o--|| MEMBERSHIP_LEVELS : has +``` + +## API Design + +### RESTful Endpoints + +``` +GET /api/v1/members # List (paginated) +GET /api/v1/members/{id} # Get by ID +GET /api/v1/members/me # Get current user +POST /api/v1/members # Create +PUT /api/v1/members/{id} # Update profile +PUT /api/v1/members/{id}/level # Change level +``` + +### Response Format + +```json +{ + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "phoneNumber": "+84901234567", + "avatarUrl": "https://storage.goodgo.com/avatars/xxx.jpg", + "countryCode": "VN", + "dateOfBirth": "1990-01-15", + "gender": "male", + "membershipLevel": { + "id": 1, + "name": "Free" + }, + "createdAt": "2024-01-15T10:30:00Z", + "updatedAt": "2024-01-15T10:30:00Z" +} +``` + +## Security + +### Authentication Flow + +```mermaid +sequenceDiagram + participant Client + participant Traefik + participant Membership + participant IAM + + Client->>IAM: Login + IAM-->>Client: JWT Token + Client->>Traefik: Request + Bearer Token + Traefik->>Membership: Forward request + Membership->>Membership: Validate JWT + Membership-->>Client: Response +``` + +### Authorization + +- All endpoints require JWT Bearer authentication +- Token validated against IAM Service authority +- UserId extracted from JWT claims + +## Infrastructure + +### Docker Compose Setup + +```yaml +membership-service-net: + build: + context: ../.. + dockerfile: services/membership-service-net/Dockerfile + environment: + - DATABASE_URL=postgresql://user:pass@db:5432/membership + - Jwt__Authority=http://iam-service-net:8080 + ports: + - "5002:8080" + labels: + - "traefik.http.routers.membership.rule=PathPrefix(`/api/v1/members`)" +``` + +### Kubernetes Deployment + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: membership-service +spec: + replicas: 3 + template: + spec: + containers: + - name: membership-service + image: goodgo/membership-service:latest + ports: + - containerPort: 8080 + resources: + requests: + memory: "256Mi" + cpu: "250m" + limits: + memory: "512Mi" + cpu: "500m" + livenessProbe: + httpGet: + path: /health/live + port: 8080 + readinessProbe: + httpGet: + path: /health/ready + port: 8080 +``` + +## Scalability + +### Horizontal Scaling + +- Stateless service design +- Database connection pooling +- Read replicas for queries + +### Caching Strategy (Future) + +``` +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ Client │────▶│ Redis │────▶│ PostgreSQL │ +└─────────────┘ └─────────────┘ └─────────────┘ + Cache Layer Persistence +``` + +## Monitoring + +### Health Checks + +| Endpoint | Check | Purpose | +|----------|-------|---------| +| `/health` | All dependencies | Full status | +| `/health/live` | App running | Kubernetes liveness | +| `/health/ready` | DB connected | Kubernetes readiness | + +### Metrics (Prometheus) + +- `membership_requests_total` - Request count +- `membership_request_duration_seconds` - Latency +- `membership_errors_total` - Error count + +### Logging + +Structured Serilog logging with: +- Request/Response logging +- Domain event logging +- Error tracking with stack traces + +## Error Handling + +### Error Response Format + +```json +{ + "type": "https://tools.ietf.org/html/rfc7231#section-6.5.4", + "title": "Not Found", + "status": 404, + "detail": "Member with ID 'xxx' was not found.", + "traceId": "00-1234567890abcdef-1234567890abcdef-00" +} +``` + +### Error Codes + +| HTTP Status | Scenario | +|-------------|----------| +| 400 | Validation error | +| 401 | Missing/invalid token | +| 404 | Member not found | +| 409 | Member already exists | +| 500 | Internal server error | + +## Future Enhancements + +- [ ] Redis caching for member lookups +- [ ] Event sourcing for audit trail +- [ ] GraphQL endpoint +- [ ] Batch member import/export +- [ ] Member search with Elasticsearch +- [ ] Webhook notifications on level changes diff --git a/services/membership-service-net/docs/en/README.md b/services/membership-service-net/docs/en/README.md new file mode 100644 index 00000000..80673cba --- /dev/null +++ b/services/membership-service-net/docs/en/README.md @@ -0,0 +1,322 @@ +# Membership Service + +> Extended user profile and membership management service for GoodGo platform. + +## Overview + +The Membership Service manages extended user profiles beyond basic IAM data. It handles: + +- **Member Profiles** - Extended user information (phone, address, avatar, preferences) +- **Membership Levels** - Free, Basic, Premium tier management +- **Address Management** - Multi-field address storage with country codes + +This service receives UserId from IAM Service and stores additional profile data using the same ID for consistency. + +## Prerequisites + +| Requirement | Version | +|-------------|---------| +| .NET SDK | 10.0.101+ | +| Docker | 24.0+ | +| PostgreSQL | 15+ (Neon recommended) | + +```bash +# Check .NET version +dotnet --version +# Should output: 10.0.xxx +``` + +## Quick Start + +### 1. Configure Environment + +```bash +# Copy environment template +cp .env.example .env + +# Edit with your configuration +nano .env +``` + +### 2. Run with Docker Compose + +```bash +# From deployments/local directory +cd deployments/local +docker-compose up -d membership-service-net + +# View logs +docker-compose logs -f membership-service-net +``` + +### 3. Run Locally + +```bash +# Navigate to service directory +cd services/membership-service-net + +# Restore dependencies +dotnet restore + +# Build all projects +dotnet build + +# Run the API +dotnet run --project src/MembershipService.API +``` + +## Project Structure + +``` +membership-service-net/ +├── src/ +│ ├── MembershipService.API/ # Presentation Layer +│ │ ├── Controllers/ +│ │ │ └── MembersController.cs # Member CRUD endpoints +│ │ ├── Application/ +│ │ │ ├── Commands/ # CreateMember, UpdateProfile, ChangeLevel +│ │ │ ├── Queries/ # GetMemberById, GetMembers +│ │ │ ├── Behaviors/ # Transaction, Validation +│ │ │ └── Validations/ # FluentValidation validators +│ │ └── Program.cs # App entry point +│ │ +│ ├── MembershipService.Domain/ # Domain Layer +│ │ ├── AggregatesModel/ +│ │ │ └── MemberAggregate/ +│ │ │ ├── Member.cs # Aggregate root +│ │ │ ├── MembershipLevel.cs # Enumeration +│ │ │ └── IMemberRepository.cs # Repository interface +│ │ ├── Events/ # Domain events +│ │ ├── Exceptions/ # Domain exceptions +│ │ └── SeedWork/ # Base classes +│ │ +│ └── MembershipService.Infrastructure/ +│ ├── EntityConfigurations/ # EF Core configs +│ ├── Repositories/ # Repository implementations +│ └── MembershipServiceContext.cs # DbContext +│ +├── tests/ +│ ├── MembershipService.UnitTests/ +│ └── MembershipService.FunctionalTests/ +│ +├── docs/ +│ ├── en/ # English documentation +│ └── vi/ # Vietnamese documentation +│ +├── Dockerfile +└── docker-compose.yml +``` + +## API Endpoints + +### Member Management + +| Method | Endpoint | Description | Auth Required | +|--------|----------|-------------|---------------| +| `GET` | `/api/v1/members` | Get paginated members | Yes | +| `GET` | `/api/v1/members/{id}` | Get member by ID | Yes | +| `GET` | `/api/v1/members/me` | Get current user's profile | Yes | +| `POST` | `/api/v1/members` | Create new member | Yes | +| `PUT` | `/api/v1/members/{id}` | Update member profile | Yes | +| `PUT` | `/api/v1/members/{id}/level` | Change membership level | Yes | + +### Health Endpoints + +| Endpoint | Purpose | +|----------|---------| +| `/health` | Full health status | +| `/health/live` | Liveness probe (Kubernetes) | +| `/health/ready` | Readiness probe (Kubernetes) | + +### Swagger Documentation + +Access Swagger UI at: `http://localhost:5002/swagger` + +## Domain Model + +### Member Aggregate + +```csharp +public class Member : Entity, IAggregateRoot +{ + public Guid UserId => Id; // Same as IAM Service UserId + public string? PhoneNumber { get; } + public string? AvatarUrl { get; } + public string? AddressLine1 { get; } + public string? City { get; } + public string CountryCode { get; } + public DateOnly? DateOfBirth { get; } + public string? Gender { get; } + public MembershipLevel MembershipLevel { get; } + public string? Preferences { get; } // JSON + public bool IsDeleted { get; } // Soft delete + + public void UpdateProfile(...); + public void UpdateAddress(...); + public void ChangeMembershipLevel(MembershipLevel newLevel); + public void Delete(); +} +``` + +### Membership Levels + +| Level | ID | Description | +|-------|-----|-------------| +| Free | 1 | Default free tier | +| Basic | 2 | Basic paid membership | +| Premium | 3 | Premium membership | + +### Domain Events + +- `MemberCreatedDomainEvent` - When new member is created +- `MemberUpdatedDomainEvent` - When profile is updated +- `MembershipLevelChangedDomainEvent` - When level changes + +## CQRS Pattern + +### Commands + +```csharp +// Create member +public record CreateMemberCommand(Guid UserId, string CountryCode, ...) + : IRequest; + +// Update profile +public record UpdateMemberProfileCommand(Guid MemberId, string? PhoneNumber, ...) + : IRequest; + +// Change membership level +public record ChangeMembershipLevelCommand(Guid MemberId, int NewLevelId) + : IRequest; +``` + +### Queries + +```csharp +// Get by ID +public record GetMemberByIdQuery(Guid Id) : IRequest; + +// Get paginated list +public record GetMembersQuery(int Page, int PageSize) : IRequest; +``` + +## Testing + +```bash +# Run all tests +dotnet test + +# Run unit tests only +dotnet test tests/MembershipService.UnitTests + +# Run functional tests only +dotnet test tests/MembershipService.FunctionalTests + +# Run with coverage +dotnet test /p:CollectCoverage=true +``` + +**Test Summary:** +- Unit Tests: 9 tests (Domain + MembershipLevel) +- Functional Tests: 3 tests (Controller authorization) + +## Configuration + +### Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `ASPNETCORE_ENVIRONMENT` | Environment name | `Development` | +| `DATABASE_URL` | PostgreSQL connection string | - | +| `Jwt__Authority` | JWT Authority URL | - | +| `Jwt__Audience` | JWT Audience | - | + +### appsettings.json + +```json +{ + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Database=membership;Username=postgres;Password=postgres" + }, + "Jwt": { + "Authority": "https://iam.goodgo.com", + "Audience": "membership-api" + } +} +``` + +## Database + +### Run Migrations + +```bash +cd services/membership-service-net + +# Create new migration +dotnet ef migrations add InitialCreate -p src/MembershipService.Infrastructure -s src/MembershipService.API + +# Apply migrations +dotnet ef database update -p src/MembershipService.Infrastructure -s src/MembershipService.API +``` + +### Database Schema + +```sql +-- Members table +CREATE TABLE members ( + id UUID PRIMARY KEY, + phone_number VARCHAR(20), + avatar_url VARCHAR(500), + address_line_1 VARCHAR(200), + address_line_2 VARCHAR(200), + city VARCHAR(100), + state VARCHAR(100), + postal_code VARCHAR(20), + country_code VARCHAR(2) NOT NULL DEFAULT 'VN', + date_of_birth DATE, + gender VARCHAR(10), + membership_level_id INT NOT NULL DEFAULT 1, + preferences JSONB, + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); + +-- Indexes +CREATE INDEX ix_members_country_code ON members(country_code); +CREATE INDEX ix_members_level ON members(membership_level_id); +CREATE INDEX ix_members_created ON members(created_at); +``` + +## Deployment + +### Docker Build + +```bash +# Build Docker image +docker build -t membership-service:latest . + +# Run container +docker run -p 5002:8080 --env-file .env membership-service:latest +``` + +### Kubernetes + +See [ARCHITECTURE.md](./ARCHITECTURE.md) for Kubernetes deployment manifests. + +## Related Services + +- **IAM Service** - Authentication and user identity (`iam-service-net`) +- **Storage Service** - File/avatar storage (`storage-service-net`) +- **Wallet Service** - Payment and points (`wallet-service-net`) + +## Resources + +- [.NET 10 Documentation](https://docs.microsoft.com/en-us/dotnet/core/whats-new/dotnet-10) +- [DDD with .NET](https://docs.microsoft.com/en-us/dotnet/architecture/microservices/) +- [MediatR](https://github.com/jbogard/MediatR) +- [FluentValidation](https://docs.fluentvalidation.net/) + +## License + +Proprietary - GoodGo Platform diff --git a/services/membership-service-net/docs/vi/ARCHITECTURE.md b/services/membership-service-net/docs/vi/ARCHITECTURE.md new file mode 100644 index 00000000..777a8ff5 --- /dev/null +++ b/services/membership-service-net/docs/vi/ARCHITECTURE.md @@ -0,0 +1,378 @@ +# Kiến Trúc Membership Service + +## Tổng Quan Hệ Thống + +Membership Service mở rộng hồ sơ người dùng từ IAM Service với thông tin bổ sung như chi tiết liên hệ, địa chỉ và cấp độ thành viên. + +```mermaid +graph TB + subgraph Client + WEB[Web App] + MOBILE[Mobile App] + end + + subgraph API_Gateway + TRAEFIK[Traefik] + end + + subgraph Services + IAM[IAM Service] + MEMBER[Membership Service] + STORAGE[Storage Service] + end + + subgraph Data + PG[(PostgreSQL)] + end + + WEB --> TRAEFIK + MOBILE --> TRAEFIK + TRAEFIK --> |/api/v1/auth| IAM + TRAEFIK --> |/api/v1/members| MEMBER + TRAEFIK --> |/api/v1/files| STORAGE + + IAM --> |JWT Token| MEMBER + MEMBER --> PG + MEMBER --> |Avatar URL| STORAGE +``` + +## Các Lớp Clean Architecture + +``` +┌────────────────────────────────────────────────────────────┐ +│ Lớp API (Presentation) │ +│ Controllers, Commands, Queries, Validators, DTOs │ +├────────────────────────────────────────────────────────────┤ +│ Lớp Domain (Core) │ +│ Entities, Aggregates, Value Objects, Domain Events │ +├────────────────────────────────────────────────────────────┤ +│ Lớp Infrastructure │ +│ DbContext, Repositories, Entity Configurations │ +└────────────────────────────────────────────────────────────┘ +``` + +## Domain Model + +### Cấu Trúc Aggregate + +```mermaid +classDiagram + class Member { + +Guid Id + +Guid UserId + +string? PhoneNumber + +string? AvatarUrl + +string? AddressLine1 + +string? AddressLine2 + +string? City + +string? State + +string? PostalCode + +string CountryCode + +DateOnly? DateOfBirth + +string? Gender + +MembershipLevel MembershipLevel + +string? Preferences + +bool IsDeleted + +DateTime CreatedAt + +DateTime UpdatedAt + +UpdateProfile() + +UpdateAddress() + +ChangeMembershipLevel() + +Delete() + } + + class MembershipLevel { + <> + +int Id + +string Name + +Free + +Basic + +Premium + } + + Member --> MembershipLevel +``` + +### Domain Events + +| Event | Trigger | Mục đích | +|-------|---------|----------| +| `MemberCreatedDomainEvent` | Tạo thành viên mới | Email chào mừng, analytics | +| `MemberUpdatedDomainEvent` | Cập nhật hồ sơ | Đồng bộ với services khác | +| `MembershipLevelChangedDomainEvent` | Thay đổi cấp độ | Thanh toán, cập nhật quyền lợi | + +## CQRS Pattern + +```mermaid +flowchart LR + subgraph Commands[Write Side] + CMD1[CreateMemberCommand] + CMD2[UpdateMemberProfileCommand] + CMD3[ChangeMembershipLevelCommand] + end + + subgraph Handlers[Command Handlers] + H1[CreateMemberHandler] + H2[UpdateProfileHandler] + H3[ChangeLevelHandler] + end + + subgraph Queries[Read Side] + Q1[GetMemberByIdQuery] + Q2[GetMembersQuery] + end + + subgraph QueryHandlers[Query Handlers] + QH1[GetMemberByIdHandler] + QH2[GetMembersHandler] + end + + CMD1 --> H1 --> DB[(Database)] + CMD2 --> H2 --> DB + CMD3 --> H3 --> DB + + Q1 --> QH1 --> DB + Q2 --> QH2 --> DB +``` + +## Luồng Request + +### Luồng Tạo Thành Viên + +```mermaid +sequenceDiagram + participant Client + participant Controller + participant MediatR + participant Handler + participant Repository + participant DB + + Client->>Controller: POST /api/v1/members + Controller->>MediatR: Send(CreateMemberCommand) + MediatR->>Handler: Handle(command) + Handler->>Repository: ExistsByUserIdAsync(userId) + Repository->>DB: SELECT EXISTS + DB-->>Repository: false + Handler->>Repository: Add(member) + Handler->>Repository: SaveEntitiesAsync() + Repository->>DB: INSERT + Domain Events + DB-->>Repository: OK + Repository-->>Handler: member + Handler-->>MediatR: CreateMemberResult + MediatR-->>Controller: result + Controller-->>Client: 201 Created +``` + +## Database Schema + +```mermaid +erDiagram + MEMBERS { + uuid id PK + varchar phone_number + varchar avatar_url + varchar address_line_1 + varchar address_line_2 + varchar city + varchar state + varchar postal_code + varchar country_code + date date_of_birth + varchar gender + int membership_level_id FK + jsonb preferences + boolean is_deleted + timestamp created_at + timestamp updated_at + } + + MEMBERSHIP_LEVELS { + int id PK + varchar name + } + + MEMBERS }o--|| MEMBERSHIP_LEVELS : has +``` + +## Thiết Kế API + +### RESTful Endpoints + +``` +GET /api/v1/members # Danh sách (phân trang) +GET /api/v1/members/{id} # Lấy theo ID +GET /api/v1/members/me # Lấy user hiện tại +POST /api/v1/members # Tạo mới +PUT /api/v1/members/{id} # Cập nhật hồ sơ +PUT /api/v1/members/{id}/level # Thay đổi cấp độ +``` + +### Định Dạng Response + +```json +{ + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "phoneNumber": "+84901234567", + "avatarUrl": "https://storage.goodgo.com/avatars/xxx.jpg", + "countryCode": "VN", + "dateOfBirth": "1990-01-15", + "gender": "male", + "membershipLevel": { + "id": 1, + "name": "Free" + }, + "createdAt": "2024-01-15T10:30:00Z", + "updatedAt": "2024-01-15T10:30:00Z" +} +``` + +## Bảo Mật + +### Luồng Xác Thực + +```mermaid +sequenceDiagram + participant Client + participant Traefik + participant Membership + participant IAM + + Client->>IAM: Login + IAM-->>Client: JWT Token + Client->>Traefik: Request + Bearer Token + Traefik->>Membership: Forward request + Membership->>Membership: Validate JWT + Membership-->>Client: Response +``` + +### Authorization + +- Tất cả endpoints yêu cầu xác thực JWT Bearer +- Token được xác thực với IAM Service authority +- UserId được trích xuất từ JWT claims + +## Hạ Tầng + +### Docker Compose Setup + +```yaml +membership-service-net: + build: + context: ../.. + dockerfile: services/membership-service-net/Dockerfile + environment: + - DATABASE_URL=postgresql://user:pass@db:5432/membership + - Jwt__Authority=http://iam-service-net:8080 + ports: + - "5002:8080" + labels: + - "traefik.http.routers.membership.rule=PathPrefix(`/api/v1/members`)" +``` + +### Kubernetes Deployment + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: membership-service +spec: + replicas: 3 + template: + spec: + containers: + - name: membership-service + image: goodgo/membership-service:latest + ports: + - containerPort: 8080 + resources: + requests: + memory: "256Mi" + cpu: "250m" + limits: + memory: "512Mi" + cpu: "500m" + livenessProbe: + httpGet: + path: /health/live + port: 8080 + readinessProbe: + httpGet: + path: /health/ready + port: 8080 +``` + +## Khả Năng Mở Rộng + +### Mở Rộng Theo Chiều Ngang + +- Thiết kế service stateless +- Connection pooling cho database +- Read replicas cho queries + +### Chiến Lược Caching (Tương Lai) + +``` +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ Client │────▶│ Redis │────▶│ PostgreSQL │ +└─────────────┘ └─────────────┘ └─────────────┘ + Cache Layer Persistence +``` + +## Giám Sát + +### Health Checks + +| Endpoint | Kiểm tra | Mục đích | +|----------|----------|----------| +| `/health` | Tất cả dependencies | Trạng thái đầy đủ | +| `/health/live` | App đang chạy | Kubernetes liveness | +| `/health/ready` | DB kết nối | Kubernetes readiness | + +### Metrics (Prometheus) + +- `membership_requests_total` - Số lượng request +- `membership_request_duration_seconds` - Độ trễ +- `membership_errors_total` - Số lỗi + +### Logging + +Structured Serilog logging với: +- Request/Response logging +- Domain event logging +- Error tracking với stack traces + +## Xử Lý Lỗi + +### Định Dạng Error Response + +```json +{ + "type": "https://tools.ietf.org/html/rfc7231#section-6.5.4", + "title": "Not Found", + "status": 404, + "detail": "Không tìm thấy thành viên với ID 'xxx'.", + "traceId": "00-1234567890abcdef-1234567890abcdef-00" +} +``` + +### Mã Lỗi + +| HTTP Status | Tình huống | +|-------------|------------| +| 400 | Lỗi validation | +| 401 | Thiếu/sai token | +| 404 | Không tìm thấy thành viên | +| 409 | Thành viên đã tồn tại | +| 500 | Lỗi server nội bộ | + +## Cải Tiến Tương Lai + +- [ ] Redis caching cho member lookups +- [ ] Event sourcing cho audit trail +- [ ] GraphQL endpoint +- [ ] Batch member import/export +- [ ] Member search với Elasticsearch +- [ ] Webhook notifications khi level thay đổi diff --git a/services/membership-service-net/docs/vi/README.md b/services/membership-service-net/docs/vi/README.md new file mode 100644 index 00000000..5c6ebb77 --- /dev/null +++ b/services/membership-service-net/docs/vi/README.md @@ -0,0 +1,322 @@ +# Membership Service + +> Dịch vụ quản lý hồ sơ người dùng mở rộng và thành viên cho nền tảng GoodGo. + +## Tổng Quan + +Membership Service quản lý hồ sơ người dùng mở rộng ngoài dữ liệu IAM cơ bản. Service xử lý: + +- **Hồ sơ thành viên** - Thông tin người dùng mở rộng (điện thoại, địa chỉ, avatar, preferences) +- **Cấp độ thành viên** - Quản lý các cấp Free, Basic, Premium +- **Quản lý địa chỉ** - Lưu trữ địa chỉ đa trường với mã quốc gia + +Service nhận UserId từ IAM Service và lưu trữ dữ liệu profile bổ sung sử dụng cùng ID để đảm bảo tính nhất quán. + +## Yêu Cầu + +| Yêu Cầu | Phiên Bản | +|---------|-----------| +| .NET SDK | 10.0.101+ | +| Docker | 24.0+ | +| PostgreSQL | 15+ (khuyến nghị Neon) | + +```bash +# Kiểm tra phiên bản .NET +dotnet --version +# Kết quả: 10.0.xxx +``` + +## Bắt Đầu Nhanh + +### 1. Cấu Hình Môi Trường + +```bash +# Copy template môi trường +cp .env.example .env + +# Chỉnh sửa cấu hình +nano .env +``` + +### 2. Chạy Với Docker Compose + +```bash +# Từ thư mục deployments/local +cd deployments/local +docker-compose up -d membership-service-net + +# Xem logs +docker-compose logs -f membership-service-net +``` + +### 3. Chạy Cục Bộ + +```bash +# Di chuyển đến thư mục service +cd services/membership-service-net + +# Restore dependencies +dotnet restore + +# Build tất cả projects +dotnet build + +# Chạy API +dotnet run --project src/MembershipService.API +``` + +## Cấu Trúc Dự Án + +``` +membership-service-net/ +├── src/ +│ ├── MembershipService.API/ # Lớp Presentation +│ │ ├── Controllers/ +│ │ │ └── MembersController.cs # Các endpoint CRUD +│ │ ├── Application/ +│ │ │ ├── Commands/ # CreateMember, UpdateProfile, ChangeLevel +│ │ │ ├── Queries/ # GetMemberById, GetMembers +│ │ │ ├── Behaviors/ # Transaction, Validation +│ │ │ └── Validations/ # FluentValidation validators +│ │ └── Program.cs # Điểm khởi động app +│ │ +│ ├── MembershipService.Domain/ # Lớp Domain +│ │ ├── AggregatesModel/ +│ │ │ └── MemberAggregate/ +│ │ │ ├── Member.cs # Aggregate root +│ │ │ ├── MembershipLevel.cs # Enumeration +│ │ │ └── IMemberRepository.cs # Interface repository +│ │ ├── Events/ # Domain events +│ │ ├── Exceptions/ # Domain exceptions +│ │ └── SeedWork/ # Base classes +│ │ +│ └── MembershipService.Infrastructure/ +│ ├── EntityConfigurations/ # EF Core configs +│ ├── Repositories/ # Repository implementations +│ └── MembershipServiceContext.cs # DbContext +│ +├── tests/ +│ ├── MembershipService.UnitTests/ +│ └── MembershipService.FunctionalTests/ +│ +├── docs/ +│ ├── en/ # Tài liệu tiếng Anh +│ └── vi/ # Tài liệu tiếng Việt +│ +├── Dockerfile +└── docker-compose.yml +``` + +## API Endpoints + +### Quản Lý Thành Viên + +| Phương thức | Endpoint | Mô tả | Yêu cầu Auth | +|-------------|----------|-------|--------------| +| `GET` | `/api/v1/members` | Lấy danh sách thành viên (phân trang) | Có | +| `GET` | `/api/v1/members/{id}` | Lấy thành viên theo ID | Có | +| `GET` | `/api/v1/members/me` | Lấy hồ sơ người dùng hiện tại | Có | +| `POST` | `/api/v1/members` | Tạo thành viên mới | Có | +| `PUT` | `/api/v1/members/{id}` | Cập nhật hồ sơ thành viên | Có | +| `PUT` | `/api/v1/members/{id}/level` | Thay đổi cấp thành viên | Có | + +### Health Endpoints + +| Endpoint | Mục đích | +|----------|----------| +| `/health` | Trạng thái health đầy đủ | +| `/health/live` | Liveness probe (Kubernetes) | +| `/health/ready` | Readiness probe (Kubernetes) | + +### Swagger Documentation + +Truy cập Swagger UI tại: `http://localhost:5002/swagger` + +## Domain Model + +### Member Aggregate + +```csharp +public class Member : Entity, IAggregateRoot +{ + public Guid UserId => Id; // Giống UserId từ IAM Service + public string? PhoneNumber { get; } + public string? AvatarUrl { get; } + public string? AddressLine1 { get; } + public string? City { get; } + public string CountryCode { get; } + public DateOnly? DateOfBirth { get; } + public string? Gender { get; } + public MembershipLevel MembershipLevel { get; } + public string? Preferences { get; } // JSON + public bool IsDeleted { get; } // Xóa mềm + + public void UpdateProfile(...); + public void UpdateAddress(...); + public void ChangeMembershipLevel(MembershipLevel newLevel); + public void Delete(); +} +``` + +### Cấp Độ Thành Viên + +| Cấp độ | ID | Mô tả | +|--------|-----|-------| +| Free | 1 | Cấp miễn phí mặc định | +| Basic | 2 | Thành viên cơ bản trả phí | +| Premium | 3 | Thành viên cao cấp | + +### Domain Events + +- `MemberCreatedDomainEvent` - Khi thành viên mới được tạo +- `MemberUpdatedDomainEvent` - Khi hồ sơ được cập nhật +- `MembershipLevelChangedDomainEvent` - Khi cấp độ thay đổi + +## CQRS Pattern + +### Commands + +```csharp +// Tạo thành viên +public record CreateMemberCommand(Guid UserId, string CountryCode, ...) + : IRequest; + +// Cập nhật hồ sơ +public record UpdateMemberProfileCommand(Guid MemberId, string? PhoneNumber, ...) + : IRequest; + +// Thay đổi cấp thành viên +public record ChangeMembershipLevelCommand(Guid MemberId, int NewLevelId) + : IRequest; +``` + +### Queries + +```csharp +// Lấy theo ID +public record GetMemberByIdQuery(Guid Id) : IRequest; + +// Lấy danh sách phân trang +public record GetMembersQuery(int Page, int PageSize) : IRequest; +``` + +## Testing + +```bash +# Chạy tất cả tests +dotnet test + +# Chạy unit tests +dotnet test tests/MembershipService.UnitTests + +# Chạy functional tests +dotnet test tests/MembershipService.FunctionalTests + +# Chạy với coverage +dotnet test /p:CollectCoverage=true +``` + +**Tóm tắt Test:** +- Unit Tests: 9 tests (Domain + MembershipLevel) +- Functional Tests: 3 tests (Controller authorization) + +## Cấu Hình + +### Biến Môi Trường + +| Biến | Mô tả | Mặc định | +|------|-------|----------| +| `ASPNETCORE_ENVIRONMENT` | Tên môi trường | `Development` | +| `DATABASE_URL` | Chuỗi kết nối PostgreSQL | - | +| `Jwt__Authority` | JWT Authority URL | - | +| `Jwt__Audience` | JWT Audience | - | + +### appsettings.json + +```json +{ + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Database=membership;Username=postgres;Password=postgres" + }, + "Jwt": { + "Authority": "https://iam.goodgo.com", + "Audience": "membership-api" + } +} +``` + +## Database + +### Chạy Migrations + +```bash +cd services/membership-service-net + +# Tạo migration mới +dotnet ef migrations add InitialCreate -p src/MembershipService.Infrastructure -s src/MembershipService.API + +# Áp dụng migrations +dotnet ef database update -p src/MembershipService.Infrastructure -s src/MembershipService.API +``` + +### Database Schema + +```sql +-- Bảng Members +CREATE TABLE members ( + id UUID PRIMARY KEY, + phone_number VARCHAR(20), + avatar_url VARCHAR(500), + address_line_1 VARCHAR(200), + address_line_2 VARCHAR(200), + city VARCHAR(100), + state VARCHAR(100), + postal_code VARCHAR(20), + country_code VARCHAR(2) NOT NULL DEFAULT 'VN', + date_of_birth DATE, + gender VARCHAR(10), + membership_level_id INT NOT NULL DEFAULT 1, + preferences JSONB, + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); + +-- Indexes +CREATE INDEX ix_members_country_code ON members(country_code); +CREATE INDEX ix_members_level ON members(membership_level_id); +CREATE INDEX ix_members_created ON members(created_at); +``` + +## Triển Khai + +### Docker Build + +```bash +# Build Docker image +docker build -t membership-service:latest . + +# Chạy container +docker run -p 5002:8080 --env-file .env membership-service:latest +``` + +### Kubernetes + +Xem [ARCHITECTURE.md](./ARCHITECTURE.md) để biết thêm về Kubernetes deployment manifests. + +## Services Liên Quan + +- **IAM Service** - Xác thực và danh tính người dùng (`iam-service-net`) +- **Storage Service** - Lưu trữ file/avatar (`storage-service-net`) +- **Wallet Service** - Thanh toán và điểm (`wallet-service-net`) + +## Tài Liệu Tham Khảo + +- [.NET 10 Documentation](https://docs.microsoft.com/en-us/dotnet/core/whats-new/dotnet-10) +- [DDD with .NET](https://docs.microsoft.com/en-us/dotnet/architecture/microservices/) +- [MediatR](https://github.com/jbogard/MediatR) +- [FluentValidation](https://docs.fluentvalidation.net/) + +## Giấy Phép + +Độc quyền - GoodGo Platform diff --git a/services/membership-service-net/global.json b/services/membership-service-net/global.json new file mode 100644 index 00000000..f78eeaf4 --- /dev/null +++ b/services/membership-service-net/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "10.0.101", + "rollForward": "latestMinor", + "allowPrerelease": false + } +} \ No newline at end of file diff --git a/services/membership-service-net/src/MembershipService.API/Application/Behaviors/LoggingBehavior.cs b/services/membership-service-net/src/MembershipService.API/Application/Behaviors/LoggingBehavior.cs new file mode 100644 index 00000000..89cf2e34 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.API/Application/Behaviors/LoggingBehavior.cs @@ -0,0 +1,58 @@ +using System.Diagnostics; +using MediatR; + +namespace MembershipService.API.Application.Behaviors; + +/// +/// EN: MediatR behavior for logging request handling. +/// VI: MediatR behavior để logging việc xử lý request. +/// +/// EN: Request type / VI: Loại request +/// EN: Response type / VI: Loại response +public class LoggingBehavior : IPipelineBehavior + where TRequest : IRequest +{ + private readonly ILogger> _logger; + + public LoggingBehavior(ILogger> logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + var requestName = typeof(TRequest).Name; + + _logger.LogInformation( + "Handling {RequestName} / Đang xử lý {RequestName}", + requestName); + + var stopwatch = Stopwatch.StartNew(); + + try + { + var response = await next(); + + stopwatch.Stop(); + + _logger.LogInformation( + "Handled {RequestName} in {ElapsedMs}ms / Đã xử lý {RequestName} trong {ElapsedMs}ms", + requestName, stopwatch.ElapsedMilliseconds); + + return response; + } + catch (Exception ex) + { + stopwatch.Stop(); + + _logger.LogError(ex, + "Error handling {RequestName} after {ElapsedMs}ms / Lỗi xử lý {RequestName} sau {ElapsedMs}ms", + requestName, stopwatch.ElapsedMilliseconds); + + throw; + } + } +} diff --git a/services/membership-service-net/src/MembershipService.API/Application/Behaviors/TransactionBehavior.cs b/services/membership-service-net/src/MembershipService.API/Application/Behaviors/TransactionBehavior.cs new file mode 100644 index 00000000..a9c46fbd --- /dev/null +++ b/services/membership-service-net/src/MembershipService.API/Application/Behaviors/TransactionBehavior.cs @@ -0,0 +1,84 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using MembershipService.Infrastructure; + +namespace MembershipService.API.Application.Behaviors; + +/// +/// EN: MediatR behavior for handling database transactions. +/// VI: MediatR behavior để xử lý database transactions. +/// +/// EN: Request type / VI: Loại request +/// EN: Response type / VI: Loại response +public class TransactionBehavior : IPipelineBehavior + where TRequest : IRequest +{ + private readonly MembershipServiceContext _dbContext; + private readonly ILogger> _logger; + + public TransactionBehavior( + MembershipServiceContext dbContext, + ILogger> logger) + { + _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + var requestName = typeof(TRequest).Name; + + // EN: Skip transaction for queries (read operations) + // VI: Bỏ qua transaction cho queries (các thao tác đọc) + if (requestName.EndsWith("Query")) + { + return await next(); + } + + // EN: Skip if already in a transaction + // VI: Bỏ qua nếu đã trong transaction + if (_dbContext.HasActiveTransaction) + { + return await next(); + } + + var strategy = _dbContext.Database.CreateExecutionStrategy(); + + return await strategy.ExecuteAsync(async () => + { + await using var transaction = await _dbContext.BeginTransactionAsync(); + + _logger.LogInformation( + "Begin transaction {TransactionId} for {RequestName} / Bắt đầu transaction {TransactionId} cho {RequestName}", + transaction?.TransactionId, requestName); + + try + { + var response = await next(); + + if (transaction != null) + { + await _dbContext.CommitTransactionAsync(transaction); + + _logger.LogInformation( + "Committed transaction {TransactionId} for {RequestName} / Đã commit transaction {TransactionId} cho {RequestName}", + transaction.TransactionId, requestName); + } + + return response; + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error during transaction {TransactionId} for {RequestName} / Lỗi trong transaction {TransactionId} cho {RequestName}", + transaction?.TransactionId, requestName); + + await _dbContext.RollbackTransactionAsync(); + throw; + } + }); + } +} diff --git a/services/membership-service-net/src/MembershipService.API/Application/Behaviors/ValidatorBehavior.cs b/services/membership-service-net/src/MembershipService.API/Application/Behaviors/ValidatorBehavior.cs new file mode 100644 index 00000000..30c1e88e --- /dev/null +++ b/services/membership-service-net/src/MembershipService.API/Application/Behaviors/ValidatorBehavior.cs @@ -0,0 +1,63 @@ +using FluentValidation; +using MediatR; + +namespace MembershipService.API.Application.Behaviors; + +/// +/// EN: MediatR behavior for FluentValidation integration. +/// VI: MediatR behavior để tích hợp FluentValidation. +/// +/// EN: Request type / VI: Loại request +/// EN: Response type / VI: Loại response +public class ValidatorBehavior : IPipelineBehavior + where TRequest : IRequest +{ + private readonly IEnumerable> _validators; + private readonly ILogger> _logger; + + public ValidatorBehavior( + IEnumerable> validators, + ILogger> logger) + { + _validators = validators ?? throw new ArgumentNullException(nameof(validators)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + var requestName = typeof(TRequest).Name; + + if (!_validators.Any()) + { + return await next(); + } + + _logger.LogDebug( + "Validating {RequestName} / Đang validate {RequestName}", + requestName); + + var context = new ValidationContext(request); + + var validationResults = await Task.WhenAll( + _validators.Select(v => v.ValidateAsync(context, cancellationToken))); + + var failures = validationResults + .SelectMany(r => r.Errors) + .Where(f => f != null) + .ToList(); + + if (failures.Count != 0) + { + _logger.LogWarning( + "Validation failed for {RequestName} with {ErrorCount} errors / Validation thất bại cho {RequestName} với {ErrorCount} lỗi", + requestName, failures.Count); + + throw new ValidationException(failures); + } + + return await next(); + } +} diff --git a/services/membership-service-net/src/MembershipService.API/Application/Commands/ChangeMembershipLevelCommand.cs b/services/membership-service-net/src/MembershipService.API/Application/Commands/ChangeMembershipLevelCommand.cs new file mode 100644 index 00000000..ad44513e --- /dev/null +++ b/services/membership-service-net/src/MembershipService.API/Application/Commands/ChangeMembershipLevelCommand.cs @@ -0,0 +1,34 @@ +using MediatR; + +namespace MembershipService.API.Application.Commands; + +/// +/// EN: Command to change membership level. +/// VI: Command để thay đổi cấp thành viên. +/// +public class ChangeMembershipLevelCommand : IRequest +{ + /// + /// EN: Member ID. + /// VI: ID member. + /// + public Guid MemberId { get; set; } + + /// + /// EN: New membership level ID. + /// VI: ID cấp thành viên mới. + /// + public int NewLevelId { get; set; } +} + +/// +/// EN: Result of change membership level command. +/// VI: Kết quả của change membership level command. +/// +public class ChangeMembershipLevelResult +{ + public Guid MemberId { get; set; } + public string OldLevel { get; set; } = null!; + public string NewLevel { get; set; } = null!; + public DateTime ChangedAt { get; set; } +} diff --git a/services/membership-service-net/src/MembershipService.API/Application/Commands/ChangeMembershipLevelCommandHandler.cs b/services/membership-service-net/src/MembershipService.API/Application/Commands/ChangeMembershipLevelCommandHandler.cs new file mode 100644 index 00000000..479ed368 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.API/Application/Commands/ChangeMembershipLevelCommandHandler.cs @@ -0,0 +1,54 @@ +using MediatR; +using MembershipService.Domain.AggregatesModel.MemberAggregate; + +namespace MembershipService.API.Application.Commands; + +/// +/// EN: Handler for changing membership level. +/// VI: Handler để thay đổi cấp thành viên. +/// +public class ChangeMembershipLevelCommandHandler : IRequestHandler +{ + private readonly IMemberRepository _memberRepository; + private readonly ILogger _logger; + + public ChangeMembershipLevelCommandHandler( + IMemberRepository memberRepository, + ILogger logger) + { + _memberRepository = memberRepository ?? throw new ArgumentNullException(nameof(memberRepository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle(ChangeMembershipLevelCommand request, CancellationToken cancellationToken) + { + var member = await _memberRepository.GetByIdAsync(request.MemberId, cancellationToken); + if (member == null) + { + throw new KeyNotFoundException($"Member {request.MemberId} not found"); + } + + var newLevel = MembershipLevel.FromValue(request.NewLevelId); + if (newLevel == null) + { + throw new ArgumentException($"Invalid membership level ID: {request.NewLevelId}"); + } + + var oldLevel = member.MembershipLevel; + member.ChangeMembershipLevel(newLevel); + + _memberRepository.Update(member); + await _memberRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation("Changed membership level for member {MemberId} from {OldLevel} to {NewLevel}", + request.MemberId, oldLevel.Name, newLevel.Name); + + return new ChangeMembershipLevelResult + { + MemberId = member.Id, + OldLevel = oldLevel.Name, + NewLevel = newLevel.Name, + ChangedAt = member.UpdatedAt + }; + } +} diff --git a/services/membership-service-net/src/MembershipService.API/Application/Commands/CreateMemberCommand.cs b/services/membership-service-net/src/MembershipService.API/Application/Commands/CreateMemberCommand.cs new file mode 100644 index 00000000..5c2e4b88 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.API/Application/Commands/CreateMemberCommand.cs @@ -0,0 +1,46 @@ +using MediatR; + +namespace MembershipService.API.Application.Commands; + +/// +/// EN: Command to create a new member profile. +/// VI: Command để tạo member profile mới. +/// +public class CreateMemberCommand : IRequest +{ + /// + /// EN: User ID from IAM Service. + /// VI: User ID từ IAM Service. + /// + public Guid UserId { get; set; } + + /// + /// EN: Country code (default: VN). + /// VI: Mã quốc gia (mặc định: VN). + /// + public string CountryCode { get; set; } = "VN"; + + /// + /// EN: Phone number (optional). + /// VI: Số điện thoại (tùy chọn). + /// + public string? PhoneNumber { get; set; } + + /// + /// EN: Avatar URL (optional). + /// VI: URL avatar (tùy chọn). + /// + public string? AvatarUrl { get; set; } +} + +/// +/// EN: Result of create member command. +/// VI: Kết quả của create member command. +/// +public class CreateMemberResult +{ + public Guid MemberId { get; set; } + public Guid UserId { get; set; } + public string MembershipLevel { get; set; } = null!; + public DateTime CreatedAt { get; set; } +} diff --git a/services/membership-service-net/src/MembershipService.API/Application/Commands/CreateMemberCommandHandler.cs b/services/membership-service-net/src/MembershipService.API/Application/Commands/CreateMemberCommandHandler.cs new file mode 100644 index 00000000..83b994f5 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.API/Application/Commands/CreateMemberCommandHandler.cs @@ -0,0 +1,57 @@ +using MediatR; +using MembershipService.Domain.AggregatesModel.MemberAggregate; + +namespace MembershipService.API.Application.Commands; + +/// +/// EN: Handler for creating a new member. +/// VI: Handler để tạo member mới. +/// +public class CreateMemberCommandHandler : IRequestHandler +{ + private readonly IMemberRepository _memberRepository; + private readonly ILogger _logger; + + public CreateMemberCommandHandler( + IMemberRepository memberRepository, + ILogger logger) + { + _memberRepository = memberRepository ?? throw new ArgumentNullException(nameof(memberRepository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle(CreateMemberCommand request, CancellationToken cancellationToken) + { + // EN: Check if member already exists for this user + // VI: Kiểm tra member đã tồn tại cho user này chưa + var exists = await _memberRepository.ExistsForUserAsync(request.UserId, cancellationToken); + if (exists) + { + throw new InvalidOperationException($"Member already exists for user {request.UserId}"); + } + + // EN: Create new member + // VI: Tạo member mới + var member = new Member(request.UserId, request.CountryCode); + + // EN: Update optional profile fields + // VI: Cập nhật các trường profile tùy chọn + if (!string.IsNullOrEmpty(request.PhoneNumber) || !string.IsNullOrEmpty(request.AvatarUrl)) + { + member.UpdateProfile(request.PhoneNumber, request.AvatarUrl, null, null); + } + + _memberRepository.Add(member); + await _memberRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation("Created member {MemberId} for user {UserId}", member.Id, request.UserId); + + return new CreateMemberResult + { + MemberId = member.Id, + UserId = member.UserId, + MembershipLevel = member.MembershipLevel.Name, + CreatedAt = member.CreatedAt + }; + } +} diff --git a/services/membership-service-net/src/MembershipService.API/Application/Commands/UpdateMemberProfileCommand.cs b/services/membership-service-net/src/MembershipService.API/Application/Commands/UpdateMemberProfileCommand.cs new file mode 100644 index 00000000..c1cc192b --- /dev/null +++ b/services/membership-service-net/src/MembershipService.API/Application/Commands/UpdateMemberProfileCommand.cs @@ -0,0 +1,51 @@ +using MediatR; + +namespace MembershipService.API.Application.Commands; + +/// +/// EN: Command to update member profile. +/// VI: Command để cập nhật profile member. +/// +public class UpdateMemberProfileCommand : IRequest +{ + /// + /// EN: Member ID. + /// VI: ID member. + /// + public Guid MemberId { get; set; } + + /// + /// EN: Phone number. + /// VI: Số điện thoại. + /// + public string? PhoneNumber { get; set; } + + /// + /// EN: Avatar URL. + /// VI: URL avatar. + /// + public string? AvatarUrl { get; set; } + + /// + /// EN: Date of birth. + /// VI: Ngày sinh. + /// + public DateOnly? DateOfBirth { get; set; } + + /// + /// EN: Gender. + /// VI: Giới tính. + /// + public string? Gender { get; set; } +} + +/// +/// EN: Result of update member profile command. +/// VI: Kết quả của update member profile command. +/// +public class UpdateMemberProfileResult +{ + public Guid MemberId { get; set; } + public bool Success { get; set; } + public DateTime UpdatedAt { get; set; } +} diff --git a/services/membership-service-net/src/MembershipService.API/Application/Commands/UpdateMemberProfileCommandHandler.cs b/services/membership-service-net/src/MembershipService.API/Application/Commands/UpdateMemberProfileCommandHandler.cs new file mode 100644 index 00000000..0214130a --- /dev/null +++ b/services/membership-service-net/src/MembershipService.API/Application/Commands/UpdateMemberProfileCommandHandler.cs @@ -0,0 +1,45 @@ +using MediatR; +using MembershipService.Domain.AggregatesModel.MemberAggregate; + +namespace MembershipService.API.Application.Commands; + +/// +/// EN: Handler for updating member profile. +/// VI: Handler để cập nhật profile member. +/// +public class UpdateMemberProfileCommandHandler : IRequestHandler +{ + private readonly IMemberRepository _memberRepository; + private readonly ILogger _logger; + + public UpdateMemberProfileCommandHandler( + IMemberRepository memberRepository, + ILogger logger) + { + _memberRepository = memberRepository ?? throw new ArgumentNullException(nameof(memberRepository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle(UpdateMemberProfileCommand request, CancellationToken cancellationToken) + { + var member = await _memberRepository.GetByIdAsync(request.MemberId, cancellationToken); + if (member == null) + { + throw new KeyNotFoundException($"Member {request.MemberId} not found"); + } + + member.UpdateProfile(request.PhoneNumber, request.AvatarUrl, request.DateOfBirth, request.Gender); + + _memberRepository.Update(member); + await _memberRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation("Updated profile for member {MemberId}", request.MemberId); + + return new UpdateMemberProfileResult + { + MemberId = member.Id, + Success = true, + UpdatedAt = member.UpdatedAt + }; + } +} diff --git a/services/membership-service-net/src/MembershipService.API/Application/Queries/GetMemberByIdQuery.cs b/services/membership-service-net/src/MembershipService.API/Application/Queries/GetMemberByIdQuery.cs new file mode 100644 index 00000000..b612d2e9 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.API/Application/Queries/GetMemberByIdQuery.cs @@ -0,0 +1,45 @@ +using MediatR; + +namespace MembershipService.API.Application.Queries; + +/// +/// EN: Query to get member by ID. +/// VI: Query để lấy member theo ID. +/// +public class GetMemberByIdQuery : IRequest +{ + public Guid MemberId { get; set; } + + public GetMemberByIdQuery(Guid memberId) + { + MemberId = memberId; + } +} + +/// +/// EN: Member DTO. +/// VI: DTO member. +/// +public class MemberDto +{ + public Guid Id { get; set; } + public Guid UserId { get; set; } + public string? PhoneNumber { get; set; } + public string? AvatarUrl { get; set; } + public DateOnly? DateOfBirth { get; set; } + public string? Gender { get; set; } + public string CountryCode { get; set; } = null!; + public MembershipLevelDto MembershipLevel { get; set; } = null!; + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} + +/// +/// EN: Membership level DTO. +/// VI: DTO cấp thành viên. +/// +public class MembershipLevelDto +{ + public int Id { get; set; } + public string Name { get; set; } = null!; +} diff --git a/services/membership-service-net/src/MembershipService.API/Application/Queries/GetMemberByIdQueryHandler.cs b/services/membership-service-net/src/MembershipService.API/Application/Queries/GetMemberByIdQueryHandler.cs new file mode 100644 index 00000000..7433a4f3 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.API/Application/Queries/GetMemberByIdQueryHandler.cs @@ -0,0 +1,50 @@ +using MediatR; +using MembershipService.Domain.AggregatesModel.MemberAggregate; + +namespace MembershipService.API.Application.Queries; + +/// +/// EN: Handler for getting member by ID. +/// VI: Handler để lấy member theo ID. +/// +public class GetMemberByIdQueryHandler : IRequestHandler +{ + private readonly IMemberRepository _memberRepository; + + public GetMemberByIdQueryHandler(IMemberRepository memberRepository) + { + _memberRepository = memberRepository ?? throw new ArgumentNullException(nameof(memberRepository)); + } + + public async Task Handle(GetMemberByIdQuery request, CancellationToken cancellationToken) + { + var member = await _memberRepository.GetByIdAsync(request.MemberId, cancellationToken); + if (member == null) + { + return null; + } + + return MapToDto(member); + } + + private static MemberDto MapToDto(Member member) + { + return new MemberDto + { + Id = member.Id, + UserId = member.UserId, + PhoneNumber = member.PhoneNumber, + AvatarUrl = member.AvatarUrl, + DateOfBirth = member.DateOfBirth, + Gender = member.Gender, + CountryCode = member.CountryCode, + MembershipLevel = new MembershipLevelDto + { + Id = member.MembershipLevel.Id, + Name = member.MembershipLevel.Name + }, + CreatedAt = member.CreatedAt, + UpdatedAt = member.UpdatedAt + }; + } +} diff --git a/services/membership-service-net/src/MembershipService.API/Application/Queries/GetMembersQuery.cs b/services/membership-service-net/src/MembershipService.API/Application/Queries/GetMembersQuery.cs new file mode 100644 index 00000000..7cdd192e --- /dev/null +++ b/services/membership-service-net/src/MembershipService.API/Application/Queries/GetMembersQuery.cs @@ -0,0 +1,27 @@ +using MediatR; + +namespace MembershipService.API.Application.Queries; + +/// +/// EN: Query to get paginated list of members. +/// VI: Query để lấy danh sách members phân trang. +/// +public class GetMembersQuery : IRequest +{ + public int PageIndex { get; set; } = 0; + public int PageSize { get; set; } = 10; + public string? SearchTerm { get; set; } +} + +/// +/// EN: Result of get members query. +/// VI: Kết quả của get members query. +/// +public class GetMembersResult +{ + public IEnumerable Members { get; set; } = Enumerable.Empty(); + public int TotalCount { get; set; } + public int PageIndex { get; set; } + public int PageSize { get; set; } + public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize); +} diff --git a/services/membership-service-net/src/MembershipService.API/Application/Queries/GetMembersQueryHandler.cs b/services/membership-service-net/src/MembershipService.API/Application/Queries/GetMembersQueryHandler.cs new file mode 100644 index 00000000..de0ef5a0 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.API/Application/Queries/GetMembersQueryHandler.cs @@ -0,0 +1,53 @@ +using MediatR; +using MembershipService.Domain.AggregatesModel.MemberAggregate; + +namespace MembershipService.API.Application.Queries; + +/// +/// EN: Handler for getting paginated list of members. +/// VI: Handler để lấy danh sách members phân trang. +/// +public class GetMembersQueryHandler : IRequestHandler +{ + private readonly IMemberRepository _memberRepository; + + public GetMembersQueryHandler(IMemberRepository memberRepository) + { + _memberRepository = memberRepository ?? throw new ArgumentNullException(nameof(memberRepository)); + } + + public async Task Handle(GetMembersQuery request, CancellationToken cancellationToken) + { + var (members, totalCount) = await _memberRepository.GetPaginatedAsync( + request.PageIndex, + request.PageSize, + request.SearchTerm, + cancellationToken); + + var memberDtos = members.Select(m => new MemberDto + { + Id = m.Id, + UserId = m.UserId, + PhoneNumber = m.PhoneNumber, + AvatarUrl = m.AvatarUrl, + DateOfBirth = m.DateOfBirth, + Gender = m.Gender, + CountryCode = m.CountryCode, + MembershipLevel = new MembershipLevelDto + { + Id = m.MembershipLevel.Id, + Name = m.MembershipLevel.Name + }, + CreatedAt = m.CreatedAt, + UpdatedAt = m.UpdatedAt + }); + + return new GetMembersResult + { + Members = memberDtos, + TotalCount = totalCount, + PageIndex = request.PageIndex, + PageSize = request.PageSize + }; + } +} diff --git a/services/membership-service-net/src/MembershipService.API/Application/Validations/CreateMemberCommandValidator.cs b/services/membership-service-net/src/MembershipService.API/Application/Validations/CreateMemberCommandValidator.cs new file mode 100644 index 00000000..cc507124 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.API/Application/Validations/CreateMemberCommandValidator.cs @@ -0,0 +1,42 @@ +using FluentValidation; +using MembershipService.API.Application.Commands; + +namespace MembershipService.API.Application.Validations; + +/// +/// EN: Validator for CreateMemberCommand. +/// VI: Validator cho CreateMemberCommand. +/// +public class CreateMemberCommandValidator : AbstractValidator +{ + public CreateMemberCommandValidator() + { + RuleFor(x => x.UserId) + .NotEmpty() + .WithMessage("UserId is required"); + + RuleFor(x => x.CountryCode) + .NotEmpty() + .Length(2) + .WithMessage("CountryCode must be 2 characters"); + + RuleFor(x => x.PhoneNumber) + .MaximumLength(50) + .Matches(@"^\+?[\d\s-]+$") + .When(x => !string.IsNullOrEmpty(x.PhoneNumber)) + .WithMessage("Invalid phone number format"); + + RuleFor(x => x.AvatarUrl) + .MaximumLength(500) + .Must(BeAValidUrl) + .When(x => !string.IsNullOrEmpty(x.AvatarUrl)) + .WithMessage("Invalid URL format"); + } + + private static bool BeAValidUrl(string? url) + { + if (string.IsNullOrEmpty(url)) return true; + return Uri.TryCreate(url, UriKind.Absolute, out var result) + && (result.Scheme == Uri.UriSchemeHttp || result.Scheme == Uri.UriSchemeHttps); + } +} diff --git a/services/membership-service-net/src/MembershipService.API/Application/Validations/UpdateMemberProfileCommandValidator.cs b/services/membership-service-net/src/MembershipService.API/Application/Validations/UpdateMemberProfileCommandValidator.cs new file mode 100644 index 00000000..9795ac6d --- /dev/null +++ b/services/membership-service-net/src/MembershipService.API/Application/Validations/UpdateMemberProfileCommandValidator.cs @@ -0,0 +1,50 @@ +using FluentValidation; +using MembershipService.API.Application.Commands; + +namespace MembershipService.API.Application.Validations; + +/// +/// EN: Validator for UpdateMemberProfileCommand. +/// VI: Validator cho UpdateMemberProfileCommand. +/// +public class UpdateMemberProfileCommandValidator : AbstractValidator +{ + public UpdateMemberProfileCommandValidator() + { + RuleFor(x => x.MemberId) + .NotEmpty() + .WithMessage("MemberId is required"); + + RuleFor(x => x.PhoneNumber) + .MaximumLength(50) + .Matches(@"^\+?[\d\s-]+$") + .When(x => !string.IsNullOrEmpty(x.PhoneNumber)) + .WithMessage("Invalid phone number format"); + + RuleFor(x => x.AvatarUrl) + .MaximumLength(500) + .Must(BeAValidUrl) + .When(x => !string.IsNullOrEmpty(x.AvatarUrl)) + .WithMessage("Invalid URL format"); + + RuleFor(x => x.Gender) + .MaximumLength(10) + .Must(BeAValidGender) + .When(x => !string.IsNullOrEmpty(x.Gender)) + .WithMessage("Gender must be Male, Female, or Other"); + } + + private static bool BeAValidUrl(string? url) + { + if (string.IsNullOrEmpty(url)) return true; + return Uri.TryCreate(url, UriKind.Absolute, out var result) + && (result.Scheme == Uri.UriSchemeHttp || result.Scheme == Uri.UriSchemeHttps); + } + + private static bool BeAValidGender(string? gender) + { + if (string.IsNullOrEmpty(gender)) return true; + var validGenders = new[] { "Male", "Female", "Other" }; + return validGenders.Contains(gender, StringComparer.OrdinalIgnoreCase); + } +} diff --git a/services/membership-service-net/src/MembershipService.API/Controllers/MembersController.cs b/services/membership-service-net/src/MembershipService.API/Controllers/MembersController.cs new file mode 100644 index 00000000..39c04ccc --- /dev/null +++ b/services/membership-service-net/src/MembershipService.API/Controllers/MembersController.cs @@ -0,0 +1,187 @@ +using Asp.Versioning; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using MembershipService.API.Application.Commands; +using MembershipService.API.Application.Queries; +using Swashbuckle.AspNetCore.Annotations; + +namespace MembershipService.API.Controllers; + +/// +/// EN: Controller for managing members. +/// VI: Controller để quản lý members. +/// +[ApiController] +[Route("api/v{version:apiVersion}/[controller]")] +[ApiVersion("1.0")] +[Authorize] +[SwaggerTag("Member management endpoints")] +public class MembersController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public MembersController(IMediator mediator, ILogger logger) + { + _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// EN: Get member by ID. + /// VI: Lấy member theo ID. + /// + [HttpGet("{id:guid}")] + [SwaggerOperation(Summary = "Get member by ID", Description = "Retrieves a member by their unique identifier")] + [SwaggerResponse(200, "Member found", typeof(MemberDto))] + [SwaggerResponse(404, "Member not found")] + [SwaggerResponse(401, "Unauthorized")] + public async Task> GetById(Guid id) + { + var member = await _mediator.Send(new GetMemberByIdQuery(id)); + if (member == null) + { + return NotFound(new { message = $"Member {id} not found" }); + } + return Ok(member); + } + + /// + /// EN: Get current user's member profile. + /// VI: Lấy profile member của user hiện tại. + /// + [HttpGet("me")] + [SwaggerOperation(Summary = "Get my member profile", Description = "Retrieves the member profile for the authenticated user")] + [SwaggerResponse(200, "Member found", typeof(MemberDto))] + [SwaggerResponse(404, "Member not found")] + [SwaggerResponse(401, "Unauthorized")] + public async Task> GetMe() + { + var userId = GetCurrentUserId(); + if (userId == null) + { + return Unauthorized(); + } + + var member = await _mediator.Send(new GetMemberByIdQuery(userId.Value)); + if (member == null) + { + return NotFound(new { message = "Member profile not found" }); + } + return Ok(member); + } + + /// + /// EN: Get paginated list of members. + /// VI: Lấy danh sách members phân trang. + /// + [HttpGet] + [SwaggerOperation(Summary = "Get members list", Description = "Retrieves a paginated list of members")] + [SwaggerResponse(200, "Members retrieved", typeof(GetMembersResult))] + [SwaggerResponse(401, "Unauthorized")] + public async Task> GetAll( + [FromQuery] int pageIndex = 0, + [FromQuery] int pageSize = 10, + [FromQuery] string? search = null) + { + var result = await _mediator.Send(new GetMembersQuery + { + PageIndex = pageIndex, + PageSize = pageSize, + SearchTerm = search + }); + return Ok(result); + } + + /// + /// EN: Create a new member. + /// VI: Tạo member mới. + /// + [HttpPost] + [SwaggerOperation(Summary = "Create member", Description = "Creates a new member profile")] + [SwaggerResponse(201, "Member created", typeof(CreateMemberResult))] + [SwaggerResponse(400, "Invalid request")] + [SwaggerResponse(409, "Member already exists")] + [SwaggerResponse(401, "Unauthorized")] + public async Task> Create([FromBody] CreateMemberCommand command) + { + try + { + var result = await _mediator.Send(command); + return CreatedAtAction(nameof(GetById), new { id = result.MemberId }, result); + } + catch (InvalidOperationException ex) when (ex.Message.Contains("already exists")) + { + return Conflict(new { message = ex.Message }); + } + } + + /// + /// EN: Update member profile. + /// VI: Cập nhật profile member. + /// + [HttpPut("{id:guid}")] + [SwaggerOperation(Summary = "Update member profile", Description = "Updates a member's profile information")] + [SwaggerResponse(200, "Member updated", typeof(UpdateMemberProfileResult))] + [SwaggerResponse(400, "Invalid request")] + [SwaggerResponse(404, "Member not found")] + [SwaggerResponse(401, "Unauthorized")] + public async Task> UpdateProfile( + Guid id, + [FromBody] UpdateMemberProfileCommand command) + { + command.MemberId = id; + + try + { + var result = await _mediator.Send(command); + return Ok(result); + } + catch (KeyNotFoundException ex) + { + return NotFound(new { message = ex.Message }); + } + } + + /// + /// EN: Change membership level. + /// VI: Thay đổi cấp thành viên. + /// + [HttpPut("{id:guid}/level")] + [SwaggerOperation(Summary = "Change membership level", Description = "Changes a member's membership level")] + [SwaggerResponse(200, "Level changed", typeof(ChangeMembershipLevelResult))] + [SwaggerResponse(400, "Invalid request")] + [SwaggerResponse(404, "Member not found")] + [SwaggerResponse(401, "Unauthorized")] + public async Task> ChangeLevel( + Guid id, + [FromBody] ChangeMembershipLevelCommand command) + { + command.MemberId = id; + + try + { + var result = await _mediator.Send(command); + return Ok(result); + } + catch (KeyNotFoundException ex) + { + return NotFound(new { message = ex.Message }); + } + catch (ArgumentException ex) + { + return BadRequest(new { message = ex.Message }); + } + } + + private Guid? GetCurrentUserId() + { + var userIdClaim = User.FindFirst("sub")?.Value ?? User.FindFirst("id")?.Value; + if (Guid.TryParse(userIdClaim, out var userId)) + { + return userId; + } + return null; + } +} diff --git a/services/membership-service-net/src/MembershipService.API/MembershipService.API.csproj b/services/membership-service-net/src/MembershipService.API/MembershipService.API.csproj new file mode 100644 index 00000000..5bcd32b9 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.API/MembershipService.API.csproj @@ -0,0 +1,47 @@ + + + + MembershipService.API + MembershipService.API + Web API layer for Member Profile and Membership Management + membership-service-api + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/services/membership-service-net/src/MembershipService.API/Program.cs b/services/membership-service-net/src/MembershipService.API/Program.cs new file mode 100644 index 00000000..9770441a --- /dev/null +++ b/services/membership-service-net/src/MembershipService.API/Program.cs @@ -0,0 +1,160 @@ +using Asp.Versioning; +using FluentValidation; +using Hellang.Middleware.ProblemDetails; +using MembershipService.API.Application.Behaviors; +using MembershipService.Infrastructure; +using Serilog; + +// EN: Configure Serilog early / VI: Cấu hình Serilog sớm +Log.Logger = new LoggerConfiguration() + .WriteTo.Console() + .CreateBootstrapLogger(); + +try +{ + Log.Information("Starting Membership Service API / Khởi động Membership Service API"); + + var builder = WebApplication.CreateBuilder(args); + + // EN: Configure Serilog / VI: Cấu hình Serilog + builder.Host.UseSerilog((context, services, configuration) => configuration + .ReadFrom.Configuration(context.Configuration) + .ReadFrom.Services(services) + .Enrich.FromLogContext() + .WriteTo.Console()); + + // EN: Add Infrastructure services / VI: Thêm Infrastructure services + builder.Services.AddInfrastructure(builder.Configuration); + + // EN: Add MediatR with behaviors / VI: Thêm MediatR với behaviors + builder.Services.AddMediatR(cfg => + { + cfg.RegisterServicesFromAssemblyContaining(); + cfg.AddOpenBehavior(typeof(LoggingBehavior<,>)); + cfg.AddOpenBehavior(typeof(ValidatorBehavior<,>)); + cfg.AddOpenBehavior(typeof(TransactionBehavior<,>)); + }); + + // EN: Add FluentValidation / VI: Thêm FluentValidation + builder.Services.AddValidatorsFromAssemblyContaining(); + + // EN: Add API versioning / VI: Thêm API versioning + builder.Services.AddApiVersioning(options => + { + options.DefaultApiVersion = new ApiVersion(1, 0); + options.AssumeDefaultVersionWhenUnspecified = true; + options.ReportApiVersions = true; + options.ApiVersionReader = ApiVersionReader.Combine( + new UrlSegmentApiVersionReader(), + new HeaderApiVersionReader("X-Api-Version")); + }) + .AddApiExplorer(options => + { + options.GroupNameFormat = "'v'VVV"; + options.SubstituteApiVersionInUrl = true; + }); + + // EN: Add controllers / VI: Thêm controllers + builder.Services.AddControllers(); + + // EN: Add ProblemDetails middleware (RFC 7807) / VI: Thêm ProblemDetails middleware + builder.Services.AddProblemDetails(options => + { + options.IncludeExceptionDetails = (ctx, ex) => + builder.Environment.IsDevelopment(); + }); + + // EN: Add Swagger / VI: Thêm Swagger + builder.Services.AddEndpointsApiExplorer(); + builder.Services.AddSwaggerGen(options => + { + options.SwaggerDoc("v1", new() + { + Title = "Membership Service API", + Version = "v1", + Description = "Membership Service API - Manage member profiles and membership levels" + }); + options.EnableAnnotations(); + }); + + // EN: Add Authentication / VI: Thêm Authentication + builder.Services.AddAuthentication("Bearer") + .AddJwtBearer("Bearer", options => + { + options.Authority = builder.Configuration["Jwt:Authority"] ?? "http://localhost:5001"; + options.Audience = builder.Configuration["Jwt:Audience"] ?? "membership-service"; + options.RequireHttpsMetadata = false; + }); + + builder.Services.AddAuthorization(); + + // EN: Add health checks / VI: Thêm health checks + builder.Services.AddHealthChecks() + .AddNpgSql( + builder.Configuration.GetConnectionString("DefaultConnection") + ?? builder.Configuration["DATABASE_URL"] + ?? "", + name: "postgresql", + tags: ["db", "postgresql"]); + + // EN: Add CORS / VI: Thêm CORS + builder.Services.AddCors(options => + { + options.AddDefaultPolicy(policy => + { + policy.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); + }); + + var app = builder.Build(); + + // EN: Configure middleware pipeline / VI: Cấu hình middleware pipeline + app.UseSerilogRequestLogging(); + app.UseProblemDetails(); + + if (app.Environment.IsDevelopment()) + { + app.UseSwagger(); + app.UseSwaggerUI(c => + { + c.SwaggerEndpoint("/swagger/v1/swagger.json", "Membership Service API v1"); + c.RoutePrefix = "swagger"; + }); + } + + app.UseCors(); + app.UseRouting(); + + // EN: Add Authentication & Authorization middleware / VI: Thêm Authentication & Authorization middleware + app.UseAuthentication(); + app.UseAuthorization(); + + // EN: Map health check endpoints / VI: Map health check endpoints + app.MapHealthChecks("/health"); + app.MapHealthChecks("/health/live", new() + { + Predicate = _ => false // EN: Just checks app is running / VI: Chỉ kiểm tra app đang chạy + }); + app.MapHealthChecks("/health/ready"); + + // EN: Map controllers / VI: Map controllers + app.MapControllers(); + + // EN: Run the application / VI: Chạy ứng dụng + app.Run(); +} +catch (Exception ex) +{ + Log.Fatal(ex, "Application terminated unexpectedly / Ứng dụng kết thúc bất ngờ"); + throw; +} +finally +{ + Log.CloseAndFlush(); +} + +// EN: Make Program class accessible for integration tests +// VI: Làm cho class Program có thể truy cập cho integration tests +public partial class Program { } diff --git a/services/membership-service-net/src/MembershipService.API/Properties/launchSettings.json b/services/membership-service-net/src/MembershipService.API/Properties/launchSettings.json new file mode 100644 index 00000000..6355d40b --- /dev/null +++ b/services/membership-service-net/src/MembershipService.API/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} \ No newline at end of file diff --git a/services/membership-service-net/src/MembershipService.API/appsettings.Development.json b/services/membership-service-net/src/MembershipService.API/appsettings.Development.json new file mode 100644 index 00000000..e407ac85 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.API/appsettings.Development.json @@ -0,0 +1,19 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft.AspNetCore": "Information", + "Microsoft.EntityFrameworkCore.Database.Command": "Information" + } + }, + "Serilog": { + "MinimumLevel": { + "Default": "Debug", + "Override": { + "Microsoft": "Information", + "Microsoft.EntityFrameworkCore.Database.Command": "Information", + "System": "Information" + } + } + } +} \ No newline at end of file diff --git a/services/membership-service-net/src/MembershipService.API/appsettings.json b/services/membership-service-net/src/MembershipService.API/appsettings.json new file mode 100644 index 00000000..523dc0fc --- /dev/null +++ b/services/membership-service-net/src/MembershipService.API/appsettings.json @@ -0,0 +1,46 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + }, + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "Microsoft.EntityFrameworkCore": "Warning", + "System": "Warning" + } + }, + "WriteTo": [ + { + "Name": "Console", + "Args": { + "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}" + } + } + ], + "Enrich": [ + "FromLogContext", + "WithMachineName", + "WithThreadId" + ] + }, + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Port=5432;Database=myservice_db;Username=postgres;Password=postgres" + }, + "Redis": { + "ConnectionString": "localhost:6379" + }, + "Jwt": { + "Secret": "your-super-secret-key-min-32-characters", + "Issuer": "goodgo-platform", + "Audience": "goodgo-services", + "AccessTokenExpiryMinutes": 15, + "RefreshTokenExpiryDays": 7 + }, + "AllowedHosts": "*" +} \ No newline at end of file diff --git a/services/membership-service-net/src/MembershipService.Domain/AggregatesModel/MemberAggregate/IMemberRepository.cs b/services/membership-service-net/src/MembershipService.Domain/AggregatesModel/MemberAggregate/IMemberRepository.cs new file mode 100644 index 00000000..ffc59402 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.Domain/AggregatesModel/MemberAggregate/IMemberRepository.cs @@ -0,0 +1,56 @@ +using MembershipService.Domain.SeedWork; + +namespace MembershipService.Domain.AggregatesModel.MemberAggregate; + +/// +/// EN: Repository interface for Member aggregate. +/// VI: Giao diện repository cho Member aggregate. +/// +public interface IMemberRepository : IRepository +{ + /// + /// EN: Get member by ID. + /// VI: Lấy member theo ID. + /// + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + + /// + /// EN: Get member by User ID (from IAM Service). + /// VI: Lấy member theo User ID (từ IAM Service). + /// + Task GetByUserIdAsync(Guid userId, CancellationToken cancellationToken = default); + + /// + /// EN: Get paginated list of members. + /// VI: Lấy danh sách members có phân trang. + /// + Task<(IEnumerable Members, int TotalCount)> GetPaginatedAsync( + int pageIndex, + int pageSize, + string? searchTerm = null, + CancellationToken cancellationToken = default); + + /// + /// EN: Check if member exists for given user ID. + /// VI: Kiểm tra xem member có tồn tại cho user ID cho trước không. + /// + Task ExistsForUserAsync(Guid userId, CancellationToken cancellationToken = default); + + /// + /// EN: Add new member. + /// VI: Thêm member mới. + /// + Member Add(Member member); + + /// + /// EN: Update existing member. + /// VI: Cập nhật member hiện tại. + /// + void Update(Member member); + + /// + /// EN: Delete member. + /// VI: Xóa member. + /// + void Delete(Member member); +} diff --git a/services/membership-service-net/src/MembershipService.Domain/AggregatesModel/MemberAggregate/Member.cs b/services/membership-service-net/src/MembershipService.Domain/AggregatesModel/MemberAggregate/Member.cs new file mode 100644 index 00000000..54c8aeb8 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.Domain/AggregatesModel/MemberAggregate/Member.cs @@ -0,0 +1,278 @@ +using MembershipService.Domain.Events; +using MembershipService.Domain.SeedWork; + +namespace MembershipService.Domain.AggregatesModel.MemberAggregate; + +/// +/// EN: Member aggregate root - extends user information from IAM Service. +/// VI: Member aggregate root - mở rộng thông tin user từ IAM Service. +/// +/// +/// EN: This entity stores extended profile information for users registered in IAM Service. +/// The ID is the same as UserId from IAM Service to maintain consistency. +/// VI: Entity này lưu trữ thông tin profile mở rộng cho users đã đăng ký trong IAM Service. +/// ID giống với UserId từ IAM Service để duy trì tính nhất quán. +/// +public class Member : Entity, IAggregateRoot +{ + // EN: Private fields for encapsulation + // VI: Fields private để đóng gói + private string? _phoneNumber; + private string? _avatarUrl; + private string? _addressLine1; + private string? _addressLine2; + private string? _city; + private string? _state; + private string? _postalCode; + private string _countryCode; + private DateOnly? _dateOfBirth; + private string? _gender; + private int _membershipLevelId; + private MembershipLevel _membershipLevel = null!; + private string? _preferences; // JSON string + private DateTime _createdAt; + private DateTime _updatedAt; + private bool _isDeleted; + + /// + /// EN: User ID from IAM Service (same as Member.Id). + /// VI: User ID từ IAM Service (giống Member.Id). + /// + public Guid UserId => Id; + + /// + /// EN: Phone number with country code. + /// VI: Số điện thoại kèm mã quốc gia. + /// + public string? PhoneNumber => _phoneNumber; + + /// + /// EN: URL to user's avatar image. + /// VI: URL đến ảnh avatar của user. + /// + public string? AvatarUrl => _avatarUrl; + + /// + /// EN: Address line 1. + /// VI: Địa chỉ dòng 1. + /// + public string? AddressLine1 => _addressLine1; + + /// + /// EN: Address line 2. + /// VI: Địa chỉ dòng 2. + /// + public string? AddressLine2 => _addressLine2; + + /// + /// EN: City name. + /// VI: Tên thành phố. + /// + public string? City => _city; + + /// + /// EN: State or province. + /// VI: Tỉnh hoặc bang. + /// + public string? State => _state; + + /// + /// EN: Postal code. + /// VI: Mã bưu chính. + /// + public string? PostalCode => _postalCode; + + /// + /// EN: Country code (ISO 3166-1 alpha-2). + /// VI: Mã quốc gia (ISO 3166-1 alpha-2). + /// + public string CountryCode => _countryCode; + + /// + /// EN: Date of birth. + /// VI: Ngày sinh. + /// + public DateOnly? DateOfBirth => _dateOfBirth; + + /// + /// EN: Gender (male, female, other). + /// VI: Giới tính (nam, nữ, khác). + /// + public string? Gender => _gender; + + /// + /// EN: Membership level ID for EF Core mapping. + /// VI: Membership level ID cho EF Core mapping. + /// + public int MembershipLevelId => _membershipLevelId; + + /// + /// EN: Current membership level. + /// VI: Cấp thành viên hiện tại. + /// + public MembershipLevel MembershipLevel => _membershipLevel; + + /// + /// EN: User preferences as JSON string. + /// VI: Preferences của user dạng chuỗi JSON. + /// + public string? Preferences => _preferences; + + /// + /// 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: Soft delete flag. + /// VI: Cờ xóa mềm. + /// + public bool IsDeleted => _isDeleted; + + /// + /// EN: Private constructor for EF Core. + /// VI: Constructor private cho EF Core. + /// + protected Member() + { + _countryCode = "VN"; // Default country code + } + + /// + /// EN: Create new member with user ID from IAM Service. + /// VI: Tạo member mới với user ID từ IAM Service. + /// + /// User ID from IAM Service / User ID từ IAM Service + /// Country code (ISO 3166-1 alpha-2) / Mã quốc gia + public Member(Guid userId, string countryCode = "VN") : this() + { + if (userId == Guid.Empty) + throw new ArgumentException("User ID cannot be empty", nameof(userId)); + + Id = userId; + _countryCode = countryCode; + _membershipLevelId = MembershipLevel.Free.Id; + _membershipLevel = MembershipLevel.Free; + _createdAt = DateTime.UtcNow; + _updatedAt = DateTime.UtcNow; + + // EN: Add domain event for member creation + // VI: Thêm domain event khi tạo member + AddDomainEvent(new MemberCreatedDomainEvent(this)); + } + + /// + /// EN: Update basic profile information. + /// VI: Cập nhật thông tin profile cơ bản. + /// + public void UpdateProfile( + string? phoneNumber, + string? avatarUrl, + DateOnly? dateOfBirth, + string? gender) + { + _phoneNumber = phoneNumber; + _avatarUrl = avatarUrl; + _dateOfBirth = dateOfBirth; + _gender = gender; + _updatedAt = DateTime.UtcNow; + + AddDomainEvent(new MemberUpdatedDomainEvent(this)); + } + + /// + /// EN: Update address information. + /// VI: Cập nhật thông tin địa chỉ. + /// + public void UpdateAddress( + string? addressLine1, + string? addressLine2, + string? city, + string? state, + string? postalCode, + string countryCode) + { + if (string.IsNullOrWhiteSpace(countryCode)) + throw new ArgumentException("Country code cannot be empty", nameof(countryCode)); + + _addressLine1 = addressLine1; + _addressLine2 = addressLine2; + _city = city; + _state = state; + _postalCode = postalCode; + _countryCode = countryCode; + _updatedAt = DateTime.UtcNow; + + AddDomainEvent(new MemberUpdatedDomainEvent(this)); + } + + /// + /// EN: Update user preferences. + /// VI: Cập nhật preferences của user. + /// + public void UpdatePreferences(string? preferencesJson) + { + _preferences = preferencesJson; + _updatedAt = DateTime.UtcNow; + + AddDomainEvent(new MemberUpdatedDomainEvent(this)); + } + + /// + /// EN: Change membership level. + /// VI: Thay đổi cấp thành viên. + /// + public void ChangeMembershipLevel(MembershipLevel newLevel) + { + if (newLevel == null) + throw new ArgumentNullException(nameof(newLevel)); + + // EN: Skip if level is the same + // VI: Bỏ qua nếu level giống nhau + if (_membershipLevelId == newLevel.Id) + return; + + var oldLevel = _membershipLevel; + _membershipLevelId = newLevel.Id; + _membershipLevel = newLevel; + _updatedAt = DateTime.UtcNow; + + AddDomainEvent(new MembershipLevelChangedDomainEvent(this, oldLevel, newLevel)); + } + + /// + /// EN: Mark member as deleted (soft delete). + /// VI: Đánh dấu member đã xóa (xóa mềm). + /// + public void Delete() + { + _isDeleted = true; + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Restore soft-deleted member. + /// VI: Khôi phục member đã xóa mềm. + /// + public void Restore() + { + _isDeleted = false; + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Set membership level (for EF Core loading). + /// VI: Set membership level (cho EF Core loading). + /// + internal void SetMembershipLevel(MembershipLevel level) + { + _membershipLevel = level; + } +} diff --git a/services/membership-service-net/src/MembershipService.Domain/AggregatesModel/MemberAggregate/MembershipLevel.cs b/services/membership-service-net/src/MembershipService.Domain/AggregatesModel/MemberAggregate/MembershipLevel.cs new file mode 100644 index 00000000..a48baafb --- /dev/null +++ b/services/membership-service-net/src/MembershipService.Domain/AggregatesModel/MemberAggregate/MembershipLevel.cs @@ -0,0 +1,38 @@ +using MembershipService.Domain.SeedWork; + +namespace MembershipService.Domain.AggregatesModel.MemberAggregate; + +/// +/// EN: Membership level enumeration (Free, Basic, Premium). +/// VI: Enumeration cấp thành viên (Miễn phí, Cơ bản, Cao cấp). +/// +public class MembershipLevel : Enumeration +{ + /// + /// EN: Free membership level - basic features. + /// VI: Cấp thành viên miễn phí - tính năng cơ bản. + /// + public static readonly MembershipLevel Free = new(1, "Free"); + + /// + /// EN: Basic membership level - standard features. + /// VI: Cấp thành viên cơ bản - tính năng tiêu chuẩn. + /// + public static readonly MembershipLevel Basic = new(2, "Basic"); + + /// + /// EN: Premium membership level - all features. + /// VI: Cấp thành viên cao cấp - tất cả tính năng. + /// + public static readonly MembershipLevel Premium = new(3, "Premium"); + + public MembershipLevel(int id, string name) : base(id, name) + { + } + + /// + /// EN: Get all membership levels. + /// VI: Lấy tất cả các cấp thành viên. + /// + public static IEnumerable List() => GetAll(); +} diff --git a/services/membership-service-net/src/MembershipService.Domain/Events/MemberCreatedDomainEvent.cs b/services/membership-service-net/src/MembershipService.Domain/Events/MemberCreatedDomainEvent.cs new file mode 100644 index 00000000..c14c904d --- /dev/null +++ b/services/membership-service-net/src/MembershipService.Domain/Events/MemberCreatedDomainEvent.cs @@ -0,0 +1,18 @@ +using MediatR; +using MembershipService.Domain.AggregatesModel.MemberAggregate; + +namespace MembershipService.Domain.Events; + +/// +/// EN: Domain event raised when a new member is created. +/// VI: Domain event được phát ra khi member mới được tạo. +/// +public class MemberCreatedDomainEvent : INotification +{ + public Member Member { get; } + + public MemberCreatedDomainEvent(Member member) + { + Member = member; + } +} diff --git a/services/membership-service-net/src/MembershipService.Domain/Events/MemberUpdatedDomainEvent.cs b/services/membership-service-net/src/MembershipService.Domain/Events/MemberUpdatedDomainEvent.cs new file mode 100644 index 00000000..d049f399 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.Domain/Events/MemberUpdatedDomainEvent.cs @@ -0,0 +1,18 @@ +using MediatR; +using MembershipService.Domain.AggregatesModel.MemberAggregate; + +namespace MembershipService.Domain.Events; + +/// +/// EN: Domain event raised when member profile is updated. +/// VI: Domain event được phát ra khi profile member được cập nhật. +/// +public class MemberUpdatedDomainEvent : INotification +{ + public Member Member { get; } + + public MemberUpdatedDomainEvent(Member member) + { + Member = member; + } +} diff --git a/services/membership-service-net/src/MembershipService.Domain/Events/MembershipLevelChangedDomainEvent.cs b/services/membership-service-net/src/MembershipService.Domain/Events/MembershipLevelChangedDomainEvent.cs new file mode 100644 index 00000000..c4f37f92 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.Domain/Events/MembershipLevelChangedDomainEvent.cs @@ -0,0 +1,25 @@ +using MediatR; +using MembershipService.Domain.AggregatesModel.MemberAggregate; + +namespace MembershipService.Domain.Events; + +/// +/// EN: Domain event raised when membership level is changed. +/// VI: Domain event được phát ra khi cấp thành viên thay đổi. +/// +public class MembershipLevelChangedDomainEvent : INotification +{ + public Member Member { get; } + public MembershipLevel OldLevel { get; } + public MembershipLevel NewLevel { get; } + + public MembershipLevelChangedDomainEvent( + Member member, + MembershipLevel oldLevel, + MembershipLevel newLevel) + { + Member = member; + OldLevel = oldLevel; + NewLevel = newLevel; + } +} diff --git a/services/membership-service-net/src/MembershipService.Domain/Exceptions/DomainException.cs b/services/membership-service-net/src/MembershipService.Domain/Exceptions/DomainException.cs new file mode 100644 index 00000000..14a4cee0 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.Domain/Exceptions/DomainException.cs @@ -0,0 +1,21 @@ +namespace MembershipService.Domain.Exceptions; + +/// +/// EN: Base exception for domain errors. +/// VI: Exception cơ sở cho các lỗi domain. +/// +public class DomainException : Exception +{ + public DomainException() + { + } + + public DomainException(string message) : base(message) + { + } + + public DomainException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/services/membership-service-net/src/MembershipService.Domain/Exceptions/MemberDomainException.cs b/services/membership-service-net/src/MembershipService.Domain/Exceptions/MemberDomainException.cs new file mode 100644 index 00000000..b28f268d --- /dev/null +++ b/services/membership-service-net/src/MembershipService.Domain/Exceptions/MemberDomainException.cs @@ -0,0 +1,17 @@ +namespace MembershipService.Domain.Exceptions; + +/// +/// EN: Exception thrown when member-related domain rules are violated. +/// VI: Exception được ném khi các quy tắc domain liên quan đến member bị vi phạm. +/// +public class MemberDomainException : DomainException +{ + public MemberDomainException(string message) : base(message) + { + } + + public MemberDomainException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/services/membership-service-net/src/MembershipService.Domain/MembershipService.Domain.csproj b/services/membership-service-net/src/MembershipService.Domain/MembershipService.Domain.csproj new file mode 100644 index 00000000..882c1fdf --- /dev/null +++ b/services/membership-service-net/src/MembershipService.Domain/MembershipService.Domain.csproj @@ -0,0 +1,14 @@ + + + + MembershipService.Domain + MembershipService.Domain + Domain layer containing Member entities, events, and business logic + + + + + + + + diff --git a/services/membership-service-net/src/MembershipService.Domain/SeedWork/Entity.cs b/services/membership-service-net/src/MembershipService.Domain/SeedWork/Entity.cs new file mode 100644 index 00000000..23dfbb18 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.Domain/SeedWork/Entity.cs @@ -0,0 +1,102 @@ +using MediatR; + +namespace MembershipService.Domain.SeedWork; + +/// +/// EN: Base class for all domain entities. +/// VI: Lớp cơ sở cho tất cả các entity trong domain. +/// +public abstract class Entity +{ + private int? _requestedHashCode; + private Guid _id; + private List _domainEvents = new(); + + /// + /// EN: Unique identifier for the entity. + /// VI: Định danh duy nhất cho entity. + /// + public virtual Guid Id + { + get => _id; + protected set => _id = value; + } + + /// + /// EN: Domain events raised by this entity. + /// VI: Các domain event được phát ra bởi entity này. + /// + public IReadOnlyCollection DomainEvents => _domainEvents.AsReadOnly(); + + /// + /// EN: Add a domain event to be dispatched. + /// VI: Thêm một domain event để dispatch. + /// + public void AddDomainEvent(INotification eventItem) + { + _domainEvents.Add(eventItem); + } + + /// + /// EN: Remove a domain event. + /// VI: Xóa một domain event. + /// + public void RemoveDomainEvent(INotification eventItem) + { + _domainEvents.Remove(eventItem); + } + + /// + /// EN: Clear all domain events. + /// VI: Xóa tất cả domain events. + /// + public void ClearDomainEvents() + { + _domainEvents.Clear(); + } + + /// + /// EN: Check if entity is transient (not persisted yet). + /// VI: Kiểm tra xem entity có phải là transient (chưa lưu) không. + /// + public bool IsTransient() + { + return Id == default; + } + + public override bool Equals(object? obj) + { + if (obj is not Entity item) + return false; + + if (ReferenceEquals(this, item)) + return true; + + if (GetType() != item.GetType()) + return false; + + if (item.IsTransient() || IsTransient()) + return false; + + return item.Id == Id; + } + + public override int GetHashCode() + { + if (IsTransient()) + return base.GetHashCode(); + + _requestedHashCode ??= Id.GetHashCode() ^ 31; + return _requestedHashCode.Value; + } + + public static bool operator ==(Entity? left, Entity? right) + { + return left?.Equals(right) ?? right is null; + } + + public static bool operator !=(Entity? left, Entity? right) + { + return !(left == right); + } +} diff --git a/services/membership-service-net/src/MembershipService.Domain/SeedWork/Enumeration.cs b/services/membership-service-net/src/MembershipService.Domain/SeedWork/Enumeration.cs new file mode 100644 index 00000000..7467a50c --- /dev/null +++ b/services/membership-service-net/src/MembershipService.Domain/SeedWork/Enumeration.cs @@ -0,0 +1,95 @@ +using System.Reflection; + +namespace MembershipService.Domain.SeedWork; + +/// +/// EN: Base class for enumeration classes (type-safe enum pattern). +/// VI: Lớp cơ sở cho các lớp enumeration (pattern enum an toàn kiểu). +/// +/// +/// EN: This provides a type-safe alternative to enums with additional functionality +/// like validation, parsing, and rich behavior. +/// VI: Cung cấp một thay thế an toàn kiểu cho enums với các chức năng bổ sung +/// như validation, parsing, và hành vi phong phú. +/// +public abstract class Enumeration : IComparable +{ + /// + /// EN: The name of the enumeration value. + /// VI: Tên của giá trị enumeration. + /// + public string Name { get; private set; } + + /// + /// EN: The unique identifier of the enumeration value. + /// VI: Định danh duy nhất của giá trị enumeration. + /// + public int Id { get; private set; } + + protected Enumeration(int id, string name) => (Id, Name) = (id, name); + + public override string ToString() => Name; + + /// + /// EN: Get all enumeration values of a given type. + /// VI: Lấy tất cả các giá trị enumeration của một kiểu cho trước. + /// + public static IEnumerable GetAll() where T : Enumeration => + typeof(T).GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly) + .Select(f => f.GetValue(null)) + .Cast(); + + public override bool Equals(object? obj) + { + if (obj is not Enumeration otherValue) + return false; + + var typeMatches = GetType() == obj.GetType(); + var valueMatches = Id.Equals(otherValue.Id); + + return typeMatches && valueMatches; + } + + public override int GetHashCode() => Id.GetHashCode(); + + /// + /// EN: Get absolute difference between two enumeration values. + /// VI: Lấy sự khác biệt tuyệt đối giữa hai giá trị enumeration. + /// + public static int AbsoluteDifference(Enumeration firstValue, Enumeration secondValue) + { + return Math.Abs(firstValue.Id - secondValue.Id); + } + + /// + /// EN: Parse an integer ID to the corresponding enumeration value. + /// VI: Parse một ID integer thành giá trị enumeration tương ứng. + /// + public static T FromValue(int value) where T : Enumeration + { + var matchingItem = Parse(value, "value", item => item.Id == value); + return matchingItem; + } + + /// + /// EN: Parse a display name to the corresponding enumeration value. + /// VI: Parse một tên hiển thị thành giá trị enumeration tương ứng. + /// + public static T FromDisplayName(string displayName) where T : Enumeration + { + var matchingItem = Parse(displayName, "display name", item => item.Name == displayName); + return matchingItem; + } + + private static T Parse(TValue value, string description, Func predicate) where T : Enumeration + { + var matchingItem = GetAll().FirstOrDefault(predicate); + + if (matchingItem is null) + throw new InvalidOperationException($"'{value}' is not a valid {description} in {typeof(T)}"); + + return matchingItem; + } + + public int CompareTo(object? other) => Id.CompareTo(((Enumeration)other!).Id); +} diff --git a/services/membership-service-net/src/MembershipService.Domain/SeedWork/IAggregateRoot.cs b/services/membership-service-net/src/MembershipService.Domain/SeedWork/IAggregateRoot.cs new file mode 100644 index 00000000..a2f16652 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.Domain/SeedWork/IAggregateRoot.cs @@ -0,0 +1,15 @@ +namespace MembershipService.Domain.SeedWork; + +/// +/// EN: Marker interface for aggregate roots. +/// VI: Interface đánh dấu cho aggregate roots. +/// +/// +/// EN: Aggregate roots are the entry points to aggregates and are the only objects +/// that outside code should hold references to. +/// VI: Aggregate roots là điểm vào của aggregates và là đối tượng duy nhất +/// mà code bên ngoài nên giữ tham chiếu đến. +/// +public interface IAggregateRoot +{ +} diff --git a/services/membership-service-net/src/MembershipService.Domain/SeedWork/IRepository.cs b/services/membership-service-net/src/MembershipService.Domain/SeedWork/IRepository.cs new file mode 100644 index 00000000..ffa2a285 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.Domain/SeedWork/IRepository.cs @@ -0,0 +1,15 @@ +namespace MembershipService.Domain.SeedWork; + +/// +/// EN: Generic repository interface for aggregate roots. +/// VI: Interface repository generic cho aggregate roots. +/// +/// EN: The aggregate root type / VI: Kiểu aggregate root +public interface IRepository where T : IAggregateRoot +{ + /// + /// EN: The unit of work for this repository. + /// VI: Unit of work cho repository này. + /// + IUnitOfWork UnitOfWork { get; } +} diff --git a/services/membership-service-net/src/MembershipService.Domain/SeedWork/IUnitOfWork.cs b/services/membership-service-net/src/MembershipService.Domain/SeedWork/IUnitOfWork.cs new file mode 100644 index 00000000..479610e7 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.Domain/SeedWork/IUnitOfWork.cs @@ -0,0 +1,30 @@ +namespace MembershipService.Domain.SeedWork; + +/// +/// EN: Unit of Work pattern interface. +/// VI: Interface cho Unit of Work pattern. +/// +/// +/// EN: Maintains a list of objects affected by a business transaction +/// and coordinates the writing out of changes. +/// VI: Duy trì danh sách các đối tượng bị ảnh hưởng bởi một transaction nghiệp vụ +/// và điều phối việc ghi các thay đổi. +/// +public interface IUnitOfWork : IDisposable +{ + /// + /// EN: Save all changes made in this unit of work. + /// VI: Lưu tất cả các thay đổi được thực hiện trong unit of work này. + /// + /// EN: Cancellation token / VI: Token hủy + /// EN: Number of entities written / VI: Số entity đã ghi + Task SaveChangesAsync(CancellationToken cancellationToken = default); + + /// + /// EN: Save all changes and dispatch domain events. + /// VI: Lưu tất cả thay đổi và dispatch domain events. + /// + /// EN: Cancellation token / VI: Token hủy + /// EN: True if successful / VI: True nếu thành công + Task SaveEntitiesAsync(CancellationToken cancellationToken = default); +} diff --git a/services/membership-service-net/src/MembershipService.Domain/SeedWork/ValueObject.cs b/services/membership-service-net/src/MembershipService.Domain/SeedWork/ValueObject.cs new file mode 100644 index 00000000..0419a930 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.Domain/SeedWork/ValueObject.cs @@ -0,0 +1,53 @@ +namespace MembershipService.Domain.SeedWork; + +/// +/// EN: Base class for Value Objects following DDD patterns. +/// VI: Lớp cơ sở cho Value Objects theo mẫu DDD. +/// +/// +/// EN: Value objects are immutable and compared by their values, not identity. +/// VI: Value objects là bất biến và được so sánh theo giá trị, không phải định danh. +/// +public abstract class ValueObject +{ + /// + /// EN: Get the atomic values that make up this value object. + /// VI: Lấy các giá trị nguyên tử tạo nên value object này. + /// + protected abstract IEnumerable GetEqualityComponents(); + + public override bool Equals(object? obj) + { + if (obj is null || obj.GetType() != GetType()) + return false; + + var other = (ValueObject)obj; + return GetEqualityComponents().SequenceEqual(other.GetEqualityComponents()); + } + + public override int GetHashCode() + { + return GetEqualityComponents() + .Select(x => x?.GetHashCode() ?? 0) + .Aggregate((x, y) => x ^ y); + } + + public static bool operator ==(ValueObject? left, ValueObject? right) + { + return left?.Equals(right) ?? right is null; + } + + public static bool operator !=(ValueObject? left, ValueObject? right) + { + return !(left == right); + } + + /// + /// EN: Create a copy of this value object with modifications. + /// VI: Tạo bản sao của value object này với các thay đổi. + /// + protected ValueObject GetCopy() + { + return (ValueObject)MemberwiseClone(); + } +} diff --git a/services/membership-service-net/src/MembershipService.Infrastructure/DependencyInjection.cs b/services/membership-service-net/src/MembershipService.Infrastructure/DependencyInjection.cs new file mode 100644 index 00000000..0d72623e --- /dev/null +++ b/services/membership-service-net/src/MembershipService.Infrastructure/DependencyInjection.cs @@ -0,0 +1,57 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using MembershipService.Domain.AggregatesModel.MemberAggregate; +using MembershipService.Infrastructure.Idempotency; +using MembershipService.Infrastructure.Repositories; + +namespace MembershipService.Infrastructure; + +/// +/// EN: Dependency injection extensions for Infrastructure layer. +/// VI: Extensions dependency injection cho lớp Infrastructure. +/// +public static class DependencyInjection +{ + /// + /// EN: Add infrastructure services to the DI container. + /// VI: Thêm các services infrastructure vào DI container. + /// + public static IServiceCollection AddInfrastructure( + this IServiceCollection services, + IConfiguration configuration) + { + // EN: Add DbContext with PostgreSQL / VI: Thêm DbContext với PostgreSQL + services.AddDbContext(options => + { + var connectionString = configuration.GetConnectionString("DefaultConnection") + ?? configuration["DATABASE_URL"] + ?? throw new InvalidOperationException("Connection string not configured"); + + options.UseNpgsql(connectionString, npgsqlOptions => + { + npgsqlOptions.MigrationsAssembly(typeof(MembershipServiceContext).Assembly.FullName); + npgsqlOptions.EnableRetryOnFailure( + maxRetryCount: 5, + maxRetryDelay: TimeSpan.FromSeconds(30), + errorCodesToAdd: null); + }); + + // EN: Enable sensitive data logging in development only + // VI: Chỉ bật sensitive data logging trong development + if (Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == "Development") + { + options.EnableSensitiveDataLogging(); + options.EnableDetailedErrors(); + } + }); + + // EN: Register repositories / VI: Đăng ký repositories + services.AddScoped(); + + // EN: Register idempotency services / VI: Đăng ký idempotency services + services.AddScoped(); + + return services; + } +} diff --git a/services/membership-service-net/src/MembershipService.Infrastructure/EntityConfigurations/MemberEntityTypeConfiguration.cs b/services/membership-service-net/src/MembershipService.Infrastructure/EntityConfigurations/MemberEntityTypeConfiguration.cs new file mode 100644 index 00000000..0531da43 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.Infrastructure/EntityConfigurations/MemberEntityTypeConfiguration.cs @@ -0,0 +1,125 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using MembershipService.Domain.AggregatesModel.MemberAggregate; + +namespace MembershipService.Infrastructure.EntityConfigurations; + +/// +/// EN: Entity configuration for Member aggregate. +/// VI: Cấu hình entity cho Member aggregate. +/// +public class MemberEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("members"); + + // EN: Primary key (same as UserId from IAM Service) + // VI: Primary key (giống UserId từ IAM Service) + builder.HasKey(m => m.Id); + + builder.Property(m => m.Id) + .HasColumnName("id") + .ValueGeneratedNever(); // EN: ID is set from IAM Service / VI: ID được set từ IAM Service + + // EN: Phone number + // VI: Số điện thoại + builder.Property("_phoneNumber") + .HasColumnName("phone_number") + .HasMaxLength(50); + + // EN: Avatar URL + // VI: URL avatar + builder.Property("_avatarUrl") + .HasColumnName("avatar_url") + .HasMaxLength(500); + + // EN: Address fields + // VI: Các trường địa chỉ + builder.Property("_addressLine1") + .HasColumnName("address_line_1") + .HasMaxLength(255); + + builder.Property("_addressLine2") + .HasColumnName("address_line_2") + .HasMaxLength(255); + + builder.Property("_city") + .HasColumnName("city") + .HasMaxLength(100); + + builder.Property("_state") + .HasColumnName("state") + .HasMaxLength(100); + + builder.Property("_postalCode") + .HasColumnName("postal_code") + .HasMaxLength(20); + + builder.Property("_countryCode") + .HasColumnName("country_code") + .HasMaxLength(2) + .IsRequired(); + + // EN: Personal info + // VI: Thông tin cá nhân + builder.Property("_dateOfBirth") + .HasColumnName("date_of_birth"); + + builder.Property("_gender") + .HasColumnName("gender") + .HasMaxLength(10); + + // EN: Membership level + // VI: Cấp thành viên + builder.Property("_membershipLevelId") + .HasColumnName("membership_level_id") + .IsRequired(); + + builder.HasOne(m => m.MembershipLevel) + .WithMany() + .HasForeignKey("_membershipLevelId") + .OnDelete(DeleteBehavior.Restrict); + + // EN: Preferences (JSON) + // VI: Preferences (JSON) + builder.Property("_preferences") + .HasColumnName("preferences") + .HasColumnType("jsonb"); + + // EN: Timestamps + // VI: Timestamps + builder.Property("_createdAt") + .HasColumnName("created_at") + .IsRequired(); + + builder.Property("_updatedAt") + .HasColumnName("updated_at") + .IsRequired(); + + // EN: Soft delete + // VI: Xóa mềm + builder.Property("_isDeleted") + .HasColumnName("is_deleted") + .HasDefaultValue(false); + + // EN: Query filter for soft delete + // VI: Query filter cho xóa mềm + builder.HasQueryFilter(m => !m.IsDeleted); + + // EN: Indexes + // VI: Indexes + builder.HasIndex("_createdAt") + .HasDatabaseName("ix_members_created_at"); + + builder.HasIndex("_membershipLevelId") + .HasDatabaseName("ix_members_membership_level"); + + builder.HasIndex("_isDeleted") + .HasDatabaseName("ix_members_is_deleted"); + + // EN: Ignore domain events (not persisted) + // VI: Bỏ qua domain events (không lưu) + builder.Ignore(m => m.DomainEvents); + } +} diff --git a/services/membership-service-net/src/MembershipService.Infrastructure/EntityConfigurations/MembershipLevelEntityTypeConfiguration.cs b/services/membership-service-net/src/MembershipService.Infrastructure/EntityConfigurations/MembershipLevelEntityTypeConfiguration.cs new file mode 100644 index 00000000..6d1b8890 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.Infrastructure/EntityConfigurations/MembershipLevelEntityTypeConfiguration.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using MembershipService.Domain.AggregatesModel.MemberAggregate; + +namespace MembershipService.Infrastructure.EntityConfigurations; + +/// +/// EN: Entity configuration for MembershipLevel enumeration. +/// VI: Cấu hình entity cho MembershipLevel enumeration. +/// +public class MembershipLevelEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("membership_levels"); + + builder.HasKey(ml => ml.Id); + + builder.Property(ml => ml.Id) + .HasColumnName("id") + .ValueGeneratedNever(); + + builder.Property(ml => ml.Name) + .HasColumnName("name") + .HasMaxLength(50) + .IsRequired(); + } +} diff --git a/services/membership-service-net/src/MembershipService.Infrastructure/Idempotency/ClientRequest.cs b/services/membership-service-net/src/MembershipService.Infrastructure/Idempotency/ClientRequest.cs new file mode 100644 index 00000000..dc5e2171 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.Infrastructure/Idempotency/ClientRequest.cs @@ -0,0 +1,26 @@ +namespace MembershipService.Infrastructure.Idempotency; + +/// +/// EN: Entity for tracking client requests to ensure idempotency. +/// VI: Entity để theo dõi các requests từ client đảm bảo idempotency. +/// +public class ClientRequest +{ + /// + /// EN: Unique request identifier. + /// VI: Định danh request duy nhất. + /// + public Guid Id { get; set; } + + /// + /// EN: Name of the command/request type. + /// VI: Tên của loại command/request. + /// + public string Name { get; set; } = null!; + + /// + /// EN: Timestamp when the request was received. + /// VI: Thời điểm request được nhận. + /// + public DateTime Time { get; set; } +} diff --git a/services/membership-service-net/src/MembershipService.Infrastructure/Idempotency/IRequestManager.cs b/services/membership-service-net/src/MembershipService.Infrastructure/Idempotency/IRequestManager.cs new file mode 100644 index 00000000..7efc44b6 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.Infrastructure/Idempotency/IRequestManager.cs @@ -0,0 +1,24 @@ +namespace MembershipService.Infrastructure.Idempotency; + +/// +/// EN: Interface for managing client request idempotency. +/// VI: Interface để quản lý idempotency của client requests. +/// +public interface IRequestManager +{ + /// + /// EN: Check if a request with the given ID exists. + /// VI: Kiểm tra xem request với ID cho trước có tồn tại không. + /// + /// EN: Request ID / VI: ID của request + /// EN: True if exists / VI: True nếu tồn tại + Task ExistAsync(Guid id); + + /// + /// EN: Create a new request record for tracking. + /// VI: Tạo bản ghi request mới để theo dõi. + /// + /// EN: Command type / VI: Loại command + /// EN: Request ID / VI: ID của request + Task CreateRequestForCommandAsync(Guid id); +} diff --git a/services/membership-service-net/src/MembershipService.Infrastructure/Idempotency/RequestManager.cs b/services/membership-service-net/src/MembershipService.Infrastructure/Idempotency/RequestManager.cs new file mode 100644 index 00000000..1b5990fc --- /dev/null +++ b/services/membership-service-net/src/MembershipService.Infrastructure/Idempotency/RequestManager.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore; + +namespace MembershipService.Infrastructure.Idempotency; + +/// +/// EN: Implementation of request manager for idempotency. +/// VI: Triển khai request manager cho idempotency. +/// +public class RequestManager : IRequestManager +{ + private readonly MembershipServiceContext _context; + + public RequestManager(MembershipServiceContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + /// + public async Task ExistAsync(Guid id) + { + var request = await _context + .FindAsync(id); + + return request != null; + } + + /// + public async Task CreateRequestForCommandAsync(Guid id) + { + var exists = await ExistAsync(id); + + var request = exists + ? throw new InvalidOperationException($"Request with {id} already exists") + : new ClientRequest + { + Id = id, + Name = typeof(T).Name, + Time = DateTime.UtcNow + }; + + _context.Add(request); + + await _context.SaveChangesAsync(); + } +} diff --git a/services/membership-service-net/src/MembershipService.Infrastructure/MembershipService.Infrastructure.csproj b/services/membership-service-net/src/MembershipService.Infrastructure/MembershipService.Infrastructure.csproj new file mode 100644 index 00000000..a5ad924d --- /dev/null +++ b/services/membership-service-net/src/MembershipService.Infrastructure/MembershipService.Infrastructure.csproj @@ -0,0 +1,39 @@ + + + + MembershipService.Infrastructure + MembershipService.Infrastructure + Infrastructure layer for data access, IAM integration, and external services + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/services/membership-service-net/src/MembershipService.Infrastructure/MembershipServiceContext.cs b/services/membership-service-net/src/MembershipService.Infrastructure/MembershipServiceContext.cs new file mode 100644 index 00000000..0c6f6ade --- /dev/null +++ b/services/membership-service-net/src/MembershipService.Infrastructure/MembershipServiceContext.cs @@ -0,0 +1,166 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; +using MembershipService.Domain.AggregatesModel.MemberAggregate; +using MembershipService.Domain.SeedWork; + +namespace MembershipService.Infrastructure; + +/// +/// EN: Database context for Membership Service. +/// VI: Database context cho Membership Service. +/// +public class MembershipServiceContext : DbContext, IUnitOfWork +{ + private readonly IMediator _mediator; + private IDbContextTransaction? _currentTransaction; + + public MembershipServiceContext(DbContextOptions options) + : base(options) + { + _mediator = null!; + } + + public MembershipServiceContext(DbContextOptions options, IMediator mediator) + : base(options) + { + _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); + } + + /// + /// EN: Members table. + /// VI: Bảng members. + /// + public DbSet Members { get; set; } = null!; + + /// + /// EN: Membership levels table. + /// VI: Bảng cấp thành viên. + /// + public DbSet MembershipLevels { get; set; } = null!; + + /// + /// EN: Check if there's an active transaction. + /// VI: Kiểm tra xem có transaction đang hoạt động không. + /// + public bool HasActiveTransaction => _currentTransaction != null; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // EN: Apply entity configurations + // VI: Áp dụng entity configurations + modelBuilder.ApplyConfigurationsFromAssembly(typeof(MembershipServiceContext).Assembly); + + // EN: Seed MembershipLevel enumeration + // VI: Seed MembershipLevel enumeration + modelBuilder.Entity().ToTable("membership_levels"); + modelBuilder.Entity().HasData( + MembershipLevel.Free, + MembershipLevel.Basic, + MembershipLevel.Premium + ); + } + + /// + /// EN: Save changes and dispatch domain events. + /// VI: Lưu thay đổi và dispatch domain events. + /// + public async Task SaveEntitiesAsync(CancellationToken cancellationToken = default) + { + // EN: Dispatch domain events before saving + // VI: Dispatch domain events trước khi lưu + await DispatchDomainEventsAsync(cancellationToken); + + await base.SaveChangesAsync(cancellationToken); + + return true; + } + + /// + /// EN: Begin a new transaction. + /// VI: Bắt đầu transaction mới. + /// + public async Task BeginTransactionAsync() + { + if (_currentTransaction != null) return null; + + _currentTransaction = await Database.BeginTransactionAsync(System.Data.IsolationLevel.ReadCommitted); + + return _currentTransaction; + } + + /// + /// EN: Commit the current transaction. + /// VI: Commit transaction hiện tại. + /// + public async Task CommitTransactionAsync(IDbContextTransaction transaction) + { + if (transaction == null) throw new ArgumentNullException(nameof(transaction)); + if (transaction != _currentTransaction) throw new InvalidOperationException($"Transaction {transaction.TransactionId} is not current"); + + try + { + await SaveChangesAsync(); + await transaction.CommitAsync(); + } + catch + { + await RollbackTransactionAsync(); + throw; + } + finally + { + if (_currentTransaction != null) + { + _currentTransaction.Dispose(); + _currentTransaction = null; + } + } + } + + /// + /// EN: Rollback the current transaction. + /// VI: Rollback transaction hiện tại. + /// + public async Task RollbackTransactionAsync() + { + try + { + if (_currentTransaction != null) + { + await _currentTransaction.RollbackAsync(); + } + } + finally + { + if (_currentTransaction != null) + { + _currentTransaction.Dispose(); + _currentTransaction = null; + } + } + } + + private async Task DispatchDomainEventsAsync(CancellationToken cancellationToken) + { + if (_mediator == null) return; + + var domainEntities = ChangeTracker + .Entries() + .Where(x => x.Entity.DomainEvents.Any()) + .ToList(); + + var domainEvents = domainEntities + .SelectMany(x => x.Entity.DomainEvents) + .ToList(); + + domainEntities.ForEach(entity => entity.Entity.ClearDomainEvents()); + + foreach (var domainEvent in domainEvents) + { + await _mediator.Publish(domainEvent, cancellationToken); + } + } +} diff --git a/services/membership-service-net/src/MembershipService.Infrastructure/Repositories/MemberRepository.cs b/services/membership-service-net/src/MembershipService.Infrastructure/Repositories/MemberRepository.cs new file mode 100644 index 00000000..b64823c0 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.Infrastructure/Repositories/MemberRepository.cs @@ -0,0 +1,115 @@ +using Microsoft.EntityFrameworkCore; +using MembershipService.Domain.AggregatesModel.MemberAggregate; +using MembershipService.Domain.SeedWork; + +namespace MembershipService.Infrastructure.Repositories; + +/// +/// EN: Repository implementation for Member aggregate. +/// VI: Repository implementation cho Member aggregate. +/// +public class MemberRepository : IMemberRepository +{ + private readonly MembershipServiceContext _context; + + public MemberRepository(MembershipServiceContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + public IUnitOfWork UnitOfWork => _context; + + /// + /// EN: Get member by ID. + /// VI: Lấy member theo ID. + /// + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _context.Members + .Include(m => m.MembershipLevel) + .FirstOrDefaultAsync(m => m.Id == id, cancellationToken); + } + + /// + /// EN: Get member by User ID (same as member ID). + /// VI: Lấy member theo User ID (giống member ID). + /// + public async Task GetByUserIdAsync(Guid userId, CancellationToken cancellationToken = default) + { + // EN: UserId is same as Member.Id + // VI: UserId giống với Member.Id + return await GetByIdAsync(userId, cancellationToken); + } + + /// + /// EN: Get paginated list of members. + /// VI: Lấy danh sách members phân trang. + /// + public async Task<(IEnumerable Members, int TotalCount)> GetPaginatedAsync( + int pageIndex, + int pageSize, + string? searchTerm = null, + CancellationToken cancellationToken = default) + { + var query = _context.Members + .Include(m => m.MembershipLevel) + .AsQueryable(); + + // EN: Apply search filter if provided + // VI: Áp dụng filter tìm kiếm nếu có + if (!string.IsNullOrWhiteSpace(searchTerm)) + { + query = query.Where(m => + EF.Property(m, "_phoneNumber") != null && + EF.Property(m, "_phoneNumber").Contains(searchTerm)); + } + + var totalCount = await query.CountAsync(cancellationToken); + + var members = await query + .OrderByDescending(m => EF.Property(m, "_createdAt")) + .Skip(pageIndex * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + + return (members, totalCount); + } + + /// + /// EN: Check if member exists for user. + /// VI: Kiểm tra member có tồn tại cho user không. + /// + public async Task ExistsForUserAsync(Guid userId, CancellationToken cancellationToken = default) + { + return await _context.Members + .AnyAsync(m => m.Id == userId, cancellationToken); + } + + /// + /// EN: Add new member. + /// VI: Thêm member mới. + /// + public Member Add(Member member) + { + return _context.Members.Add(member).Entity; + } + + /// + /// EN: Update member. + /// VI: Cập nhật member. + /// + public void Update(Member member) + { + _context.Entry(member).State = EntityState.Modified; + } + + /// + /// EN: Delete member (soft delete). + /// VI: Xóa member (xóa mềm). + /// + public void Delete(Member member) + { + member.Delete(); + Update(member); + } +} diff --git a/services/membership-service-net/tests/MembershipService.FunctionalTests/Controllers/MembersControllerTests.cs b/services/membership-service-net/tests/MembershipService.FunctionalTests/Controllers/MembersControllerTests.cs new file mode 100644 index 00000000..408fe4fa --- /dev/null +++ b/services/membership-service-net/tests/MembershipService.FunctionalTests/Controllers/MembersControllerTests.cs @@ -0,0 +1,59 @@ +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using MembershipService.API.Application.Commands; +using MembershipService.API.Application.Queries; +using Xunit; + +namespace MembershipService.FunctionalTests.Controllers; + +/// +/// EN: Functional tests for MembersController. +/// VI: Functional tests cho MembersController. +/// +public class MembersControllerTests : IClassFixture +{ + private readonly HttpClient _client; + + public MembersControllerTests(CustomWebApplicationFactory factory) + { + _client = factory.CreateClient(); + } + + [Fact] + public async Task GetMembers_ShouldReturnEmptyList() + { + // Act + var response = await _client.GetAsync("/api/v1/members?page=1&pageSize=10"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task GetMemberById_WithInvalidId_ShouldReturnUnauthorized() + { + // Act + var response = await _client.GetAsync($"/api/v1/members/{Guid.NewGuid()}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task CreateMember_WithoutAuth_ShouldReturnUnauthorized() + { + // Arrange + var command = new CreateMemberCommand + { + UserId = Guid.NewGuid(), + CountryCode = "VN" + }; + + // Act + var response = await _client.PostAsJsonAsync("/api/v1/members", command); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } +} diff --git a/services/membership-service-net/tests/MembershipService.FunctionalTests/CustomWebApplicationFactory.cs b/services/membership-service-net/tests/MembershipService.FunctionalTests/CustomWebApplicationFactory.cs new file mode 100644 index 00000000..abce7a6d --- /dev/null +++ b/services/membership-service-net/tests/MembershipService.FunctionalTests/CustomWebApplicationFactory.cs @@ -0,0 +1,43 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using MembershipService.Infrastructure; + +namespace MembershipService.FunctionalTests; + +/// +/// EN: Custom WebApplicationFactory for functional tests. +/// VI: WebApplicationFactory tùy chỉnh cho functional tests. +/// +public class CustomWebApplicationFactory : WebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Testing"); + + builder.ConfigureServices(services => + { + // EN: Remove ALL DbContext registrations (including Npgsql) + // VI: Xóa TẤT CẢ các đăng ký DbContext (bao gồm Npgsql) + var descriptorsToRemove = services.Where( + d => d.ServiceType == typeof(DbContextOptions) || + d.ServiceType == typeof(MembershipServiceContext) || + d.ServiceType.FullName?.Contains("EntityFrameworkCore") == true || + d.ServiceType.FullName?.Contains("Npgsql") == true) + .ToList(); + + foreach (var descriptor in descriptorsToRemove) + { + services.Remove(descriptor); + } + + // EN: Add in-memory database for testing + // VI: Thêm in-memory database để test + services.AddDbContext(options => + { + options.UseInMemoryDatabase("TestDatabase_" + Guid.NewGuid().ToString()); + }); + }); + } +} diff --git a/services/membership-service-net/tests/MembershipService.FunctionalTests/MembershipService.FunctionalTests.csproj b/services/membership-service-net/tests/MembershipService.FunctionalTests/MembershipService.FunctionalTests.csproj new file mode 100644 index 00000000..d6a27c31 --- /dev/null +++ b/services/membership-service-net/tests/MembershipService.FunctionalTests/MembershipService.FunctionalTests.csproj @@ -0,0 +1,38 @@ + + + + MembershipService.FunctionalTests + MembershipService.FunctionalTests + false + true + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/services/membership-service-net/tests/MembershipService.UnitTests/Domain/MemberAggregateTests.cs b/services/membership-service-net/tests/MembershipService.UnitTests/Domain/MemberAggregateTests.cs new file mode 100644 index 00000000..04fcad2d --- /dev/null +++ b/services/membership-service-net/tests/MembershipService.UnitTests/Domain/MemberAggregateTests.cs @@ -0,0 +1,107 @@ +using FluentAssertions; +using MembershipService.Domain.AggregatesModel.MemberAggregate; +using Xunit; + +namespace MembershipService.UnitTests.Domain; + +/// +/// EN: Unit tests for Member aggregate. +/// VI: Unit tests cho Member aggregate. +/// +public class MemberAggregateTests +{ + [Fact] + public void CreateMember_WithValidUserId_ShouldCreateMemberWithFreeLevel() + { + // Arrange + var userId = Guid.NewGuid(); + + // Act + var member = new Member(userId); + + // Assert + member.Id.Should().Be(userId); + member.UserId.Should().Be(userId); + member.MembershipLevel.Should().Be(MembershipLevel.Free); + member.CountryCode.Should().Be("VN"); + member.IsDeleted.Should().BeFalse(); + member.DomainEvents.Should().ContainSingle(); + } + + [Fact] + public void CreateMember_WithCustomCountryCode_ShouldSetCountryCode() + { + // Arrange + var userId = Guid.NewGuid(); + + // Act + var member = new Member(userId, "US"); + + // Assert + member.CountryCode.Should().Be("US"); + } + + [Fact] + public void UpdateProfile_ShouldUpdateFieldsAndRaiseEvent() + { + // Arrange + var member = new Member(Guid.NewGuid()); + var phone = "+84901234567"; + var avatar = "https://example.com/avatar.jpg"; + var dob = new DateOnly(1990, 1, 1); + var gender = "Male"; + + // Act + member.UpdateProfile(phone, avatar, dob, gender); + + // Assert + member.PhoneNumber.Should().Be(phone); + member.AvatarUrl.Should().Be(avatar); + member.DateOfBirth.Should().Be(dob); + member.Gender.Should().Be(gender); + member.DomainEvents.Should().HaveCount(2); // Created + Updated + } + + [Fact] + public void ChangeMembershipLevel_ShouldChangeLevel() + { + // Arrange + var member = new Member(Guid.NewGuid()); + member.ClearDomainEvents(); + + // Act + member.ChangeMembershipLevel(MembershipLevel.Premium); + + // Assert + member.MembershipLevel.Should().Be(MembershipLevel.Premium); + member.DomainEvents.Should().ContainSingle(); + } + + [Fact] + public void ChangeMembershipLevel_ToSameLevel_ShouldNotRaiseEvent() + { + // Arrange + var member = new Member(Guid.NewGuid()); + member.ClearDomainEvents(); + + // Act + member.ChangeMembershipLevel(MembershipLevel.Free); + + // Assert + member.MembershipLevel.Should().Be(MembershipLevel.Free); + member.DomainEvents.Should().BeEmpty(); + } + + [Fact] + public void Delete_ShouldMarkAsDeleted() + { + // Arrange + var member = new Member(Guid.NewGuid()); + + // Act + member.Delete(); + + // Assert + member.IsDeleted.Should().BeTrue(); + } +} diff --git a/services/membership-service-net/tests/MembershipService.UnitTests/Domain/MembershipLevelTests.cs b/services/membership-service-net/tests/MembershipService.UnitTests/Domain/MembershipLevelTests.cs new file mode 100644 index 00000000..d3c90278 --- /dev/null +++ b/services/membership-service-net/tests/MembershipService.UnitTests/Domain/MembershipLevelTests.cs @@ -0,0 +1,50 @@ +using FluentAssertions; +using MembershipService.Domain.AggregatesModel.MemberAggregate; +using Xunit; + +namespace MembershipService.UnitTests.Domain; + +/// +/// EN: Unit tests for MembershipLevel enumeration. +/// VI: Unit tests cho MembershipLevel enumeration. +/// +public class MembershipLevelTests +{ + [Fact] + public void MembershipLevel_ShouldHaveThreeLevels() + { + // Arrange & Act + var levels = MembershipLevel.List(); + + // Assert + levels.Should().HaveCount(3); + } + + [Fact] + public void FromValue_ShouldReturnCorrectLevel() + { + // Act + var free = MembershipLevel.FromValue(1); + var basic = MembershipLevel.FromValue(2); + var premium = MembershipLevel.FromValue(3); + + // Assert + free.Should().Be(MembershipLevel.Free); + basic.Should().Be(MembershipLevel.Basic); + premium.Should().Be(MembershipLevel.Premium); + } + + [Fact] + public void FromDisplayName_ShouldReturnCorrectLevel() + { + // Act + var free = MembershipLevel.FromDisplayName("Free"); + var basic = MembershipLevel.FromDisplayName("Basic"); + var premium = MembershipLevel.FromDisplayName("Premium"); + + // Assert + free.Should().Be(MembershipLevel.Free); + basic.Should().Be(MembershipLevel.Basic); + premium.Should().Be(MembershipLevel.Premium); + } +} diff --git a/services/membership-service-net/tests/MembershipService.UnitTests/MembershipService.UnitTests.csproj b/services/membership-service-net/tests/MembershipService.UnitTests/MembershipService.UnitTests.csproj new file mode 100644 index 00000000..ca2bdf6f --- /dev/null +++ b/services/membership-service-net/tests/MembershipService.UnitTests/MembershipService.UnitTests.csproj @@ -0,0 +1,35 @@ + + + + MembershipService.UnitTests + MembershipService.UnitTests + false + true + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/services/organization-service-net/.env.example b/services/organization-service-net/.env.example new file mode 100644 index 00000000..f9053bc3 --- /dev/null +++ b/services/organization-service-net/.env.example @@ -0,0 +1,40 @@ +# Environment / Môi Trường +ASPNETCORE_ENVIRONMENT=Development + +# Database / Cơ Sở Dữ Liệu +# PostgreSQL connection string (Neon or local) +DATABASE_URL=Host=localhost;Port=5432;Database=myservice_db;Username=postgres;Password=postgres + +# Redis Cache +REDIS_URL=localhost:6379 +REDIS_PASSWORD= + +# JWT Authentication / Xác Thực JWT +JWT_SECRET=your-secret-key-min-32-characters-long-here +JWT_ISSUER=goodgo-platform +JWT_AUDIENCE=goodgo-services +JWT_ACCESS_TOKEN_EXPIRY_MINUTES=15 +JWT_REFRESH_TOKEN_EXPIRY_DAYS=7 + +# API Configuration / Cấu Hình API +API_PORT=5000 +API_BASE_PATH=/api/v1/myservice + +# Observability / Quan Sát +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 +OTEL_SERVICE_NAME=myservice + +# Logging +LOG_LEVEL=Information +SEQ_URL=http://localhost:5341 + +# Feature Flags +FEATURE_SWAGGER_ENABLED=true +FEATURE_DETAILED_ERRORS=true + +# Rate Limiting +RATE_LIMIT_PERMITS_PER_MINUTE=100 +RATE_LIMIT_QUEUE_LIMIT=10 + +# Health Checks +HEALTHCHECK_TIMEOUT_SECONDS=5 diff --git a/services/organization-service-net/.gitignore b/services/organization-service-net/.gitignore new file mode 100644 index 00000000..84b02a53 --- /dev/null +++ b/services/organization-service-net/.gitignore @@ -0,0 +1,75 @@ +# Build results +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio +.vs/ +*.user +*.userosscache +*.suo +*.userprefs +*.sln.docstates + +# Rider +.idea/ +*.sln.iml + +# Visual Studio Code +.vscode/ + +# NuGet +*.nupkg +*.snupkg +.nuget/ +packages/ +project.lock.json +project.fragment.lock.json + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# Coverage +TestResults/ +*.coverage +*.coveragexml +coverage*.json +coverage*.xml + +# Publish output +publish/ +out/ + +# Environment files +.env +.env.local +.env.*.local +*.env + +# Secrets +appsettings.*.json +!appsettings.json +!appsettings.Development.json + +# macOS +.DS_Store + +# Windows +Thumbs.db +ehthumbs.db + +# JetBrains +*.resharper + +# dotnet tools +.config/dotnet-tools.json + +# Migration scripts (only keep structure) +Migrations/ + +# Temp files +*.tmp +*.temp +~$* diff --git a/services/organization-service-net/Directory.Build.props b/services/organization-service-net/Directory.Build.props new file mode 100644 index 00000000..c3b74373 --- /dev/null +++ b/services/organization-service-net/Directory.Build.props @@ -0,0 +1,22 @@ + + + net10.0 + 14.0 + enable + enable + true + true + $(NoWarn);1591;CA2017 + + + + GoodGo Team + GoodGo + © 2026 GoodGo. All rights reserved. + git + + + + + + diff --git a/services/organization-service-net/Dockerfile b/services/organization-service-net/Dockerfile new file mode 100644 index 00000000..3b9453ca --- /dev/null +++ b/services/organization-service-net/Dockerfile @@ -0,0 +1,66 @@ +# Build stage / Giai đoạn build +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +# EN: Copy project files for layer caching +# VI: Sao chép các file project để tận dụng layer caching +COPY ["src/OrganizationService.API/OrganizationService.API.csproj", "src/OrganizationService.API/"] +COPY ["src/OrganizationService.Domain/OrganizationService.Domain.csproj", "src/OrganizationService.Domain/"] +COPY ["src/OrganizationService.Infrastructure/OrganizationService.Infrastructure.csproj", "src/OrganizationService.Infrastructure/"] +COPY ["Directory.Build.props", "./"] + +# EN: Restore dependencies +# VI: Khôi phục dependencies +RUN dotnet restore "src/OrganizationService.API/OrganizationService.API.csproj" + +# EN: Copy all source code +# VI: Sao chép toàn bộ source code +COPY src/ ./src/ + +# EN: Build the application +# VI: Build ứng dụng +WORKDIR "/src/src/OrganizationService.API" +RUN dotnet build "OrganizationService.API.csproj" -c Release -o /app/build --no-restore + +# Publish stage / Giai đoạn publish +FROM build AS publish +RUN dotnet publish "OrganizationService.API.csproj" -c Release -o /app/publish /p:UseAppHost=false --no-restore + +# Runtime stage / Giai đoạn runtime +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final +WORKDIR /app + +# EN: Create non-root user for security +# VI: Tạo user non-root cho bảo mật +RUN groupadd -g 1001 dotnetuser && \ + useradd -u 1001 -g dotnetuser -s /bin/sh dotnetuser + +# EN: Copy published application +# VI: Sao chép ứng dụng đã publish +COPY --from=publish /app/publish . + +# EN: Change ownership to non-root user +# VI: Thay đổi quyền sở hữu sang user non-root +RUN chown -R dotnetuser:dotnetuser /app + +# EN: Switch to non-root user +# VI: Chuyển sang user non-root +USER dotnetuser + +# EN: Expose port +# VI: Mở cổng +EXPOSE 8080 + +# EN: Set environment variables +# VI: Thiết lập biến môi trường +ENV ASPNETCORE_URLS=http://+:8080 +ENV ASPNETCORE_ENVIRONMENT=Production + +# EN: Health check +# VI: Kiểm tra health +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD curl -f http://localhost:8080/health/live || exit 1 + +# EN: Start the application +# VI: Khởi động ứng dụng +ENTRYPOINT ["dotnet", "OrganizationService.API.dll"] diff --git a/services/organization-service-net/OrganizationService.slnx b/services/organization-service-net/OrganizationService.slnx new file mode 100644 index 00000000..2d9e2e4e --- /dev/null +++ b/services/organization-service-net/OrganizationService.slnx @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/services/organization-service-net/docker-compose.yml b/services/organization-service-net/docker-compose.yml new file mode 100644 index 00000000..85dd44c2 --- /dev/null +++ b/services/organization-service-net/docker-compose.yml @@ -0,0 +1,72 @@ +version: '3.8' + +# EN: Docker Compose for local development +# VI: Docker Compose cho phát triển local + +services: + organization-service-api: + build: + context: . + dockerfile: Dockerfile + container_name: organization-service-api + ports: + - "5003:8080" + environment: + - ASPNETCORE_ENVIRONMENT=Development + - DATABASE_URL=Host=postgres;Port=5432;Database=organization_db;Username=postgres;Password=postgres + - REDIS_URL=redis:6379 + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + networks: + - organization-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health/live"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + + postgres: + image: postgres:16-alpine + container_name: organization-postgres + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: organization_db + ports: + - "5435:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - organization-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + container_name: organization-redis + ports: + - "6381:6379" + volumes: + - redis_data:/data + networks: + - organization-network + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + postgres_data: + redis_data: + +networks: + organization-network: + driver: bridge diff --git a/services/organization-service-net/docs/en/ARCHITECTURE.md b/services/organization-service-net/docs/en/ARCHITECTURE.md new file mode 100644 index 00000000..9d80ba57 --- /dev/null +++ b/services/organization-service-net/docs/en/ARCHITECTURE.md @@ -0,0 +1,271 @@ +# Architecture Documentation + +> Detailed architecture documentation for the .NET 10 Microservice Template. + +## Architecture Overview + +```mermaid +graph TB + subgraph "API Layer" + C[Controllers] + CMD[Commands] + Q[Queries] + B[Behaviors] + V[Validations] + end + + subgraph "Domain Layer" + AR[Aggregate Roots] + E[Entities] + VO[Value Objects] + DE[Domain Events] + DX[Domain Exceptions] + end + + subgraph "Infrastructure Layer" + DB[(PostgreSQL)] + R[Repositories] + CTX[DbContext] + ID[Idempotency] + end + + C --> CMD + C --> Q + CMD --> B --> V + CMD --> AR + Q --> R + R --> CTX --> DB + AR --> DE + R --> AR + + style C fill:#4a90d9,stroke:#2d5986,color:#fff + style AR fill:#50c878,stroke:#2d8659,color:#fff + style DB fill:#ff6b6b,stroke:#c0392b,color:#fff +``` + +## Layer Responsibilities + +### 1. Domain Layer (MyService.Domain) + +The heart of the application containing pure business logic. This layer: +- Has **ZERO** external dependencies (except MediatR.Contracts for events) +- Contains only POCO classes +- Implements DDD tactical patterns + +#### Components + +| Component | Purpose | +|-----------|---------| +| **SeedWork** | Base classes: Entity, ValueObject, Enumeration, IAggregateRoot | +| **AggregatesModel** | Aggregate roots with their entities and value objects | +| **Events** | Domain events for cross-aggregate communication | +| **Exceptions** | Domain-specific exceptions for business rule violations | + +### 2. Infrastructure Layer (MyService.Infrastructure) + +Technical implementations and external concerns: +- Database access (EF Core) +- Repository implementations +- External service integrations + +### 3. API Layer (MyService.API) + +Application entry point and CQRS implementation: +- Controllers for HTTP handling +- Commands for write operations +- Queries for read operations +- MediatR behaviors for cross-cutting concerns + +## CQRS Flow + +```mermaid +sequenceDiagram + participant Client + participant Controller + participant MediatR + participant LoggingBehavior + participant ValidatorBehavior + participant TransactionBehavior + participant CommandHandler + participant Repository + participant DbContext + + Client->>Controller: HTTP Request + Controller->>MediatR: Send(Command) + MediatR->>LoggingBehavior: Handle + LoggingBehavior->>ValidatorBehavior: Next() + ValidatorBehavior->>TransactionBehavior: Next() + TransactionBehavior->>CommandHandler: Next() + CommandHandler->>Repository: Add/Update/Delete + Repository->>DbContext: SaveEntitiesAsync() + DbContext-->>Repository: Success + Repository-->>CommandHandler: Result + CommandHandler-->>Controller: Response + Controller-->>Client: HTTP Response +``` + +## Domain Events + +```mermaid +graph LR + AR[Aggregate Root] -->|Raises| DE[Domain Event] + DE -->|Dispatched by| CTX[DbContext] + CTX -->|Publishes to| M[MediatR] + M -->|Handled by| H1[Handler 1] + M -->|Handled by| H2[Handler 2] + + style AR fill:#50c878,stroke:#2d8659,color:#fff + style DE fill:#f39c12,stroke:#d68910,color:#fff + style M fill:#9b59b6,stroke:#7d3c98,color:#fff +``` + +## Database Schema + +### Sample Aggregate + +```mermaid +erDiagram + samples { + uuid id PK + varchar(200) name + varchar(1000) description + int status_id FK + timestamp created_at + timestamp updated_at + } + + sample_statuses { + int id PK + varchar(50) name + } + + samples ||--o{ sample_statuses : has +``` + +## MediatR Pipeline + +``` +Request → LoggingBehavior → ValidatorBehavior → TransactionBehavior → Handler → Response + │ │ │ + ▼ ▼ ▼ + Log start/end Validate Begin/Commit + + timing with Transaction + FluentValidation +``` + +### Behavior Order + +1. **LoggingBehavior** - Logs request handling with timing +2. **ValidatorBehavior** - Validates request using FluentValidation +3. **TransactionBehavior** - Wraps command handlers in database transactions + +## Error Handling + +### Exception Hierarchy + +``` +Exception +└── DomainException + └── SampleDomainException +``` + +### Problem Details (RFC 7807) + +All errors are returned in Problem Details format: + +```json +{ + "type": "https://tools.ietf.org/html/rfc7807", + "title": "Validation Error", + "status": 400, + "detail": "One or more validation errors occurred.", + "errors": { + "Name": ["Name is required"] + } +} +``` + +## Health Checks + +```mermaid +graph TD + HC[Health Check Endpoint] + HC --> |/health/live| L[Liveness] + HC --> |/health/ready| R[Readiness] + HC --> |/health| F[Full Status] + + R --> PG[(PostgreSQL)] + R --> RD[(Redis)] + + style HC fill:#3498db,stroke:#2980b9,color:#fff + style L fill:#2ecc71,stroke:#27ae60,color:#fff + style R fill:#f39c12,stroke:#d68910,color:#fff +``` + +## Deployment Architecture + +### Docker Compose (Local) + +```yaml +services: + myservice-api: + build: . + ports: ["5000:8080"] + depends_on: + - postgres + - redis + + postgres: + image: postgres:16-alpine + + redis: + image: redis:7-alpine +``` + +### Kubernetes (Production) + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: myservice-api +spec: + replicas: 3 + template: + spec: + containers: + - name: api + image: myservice:latest + ports: + - containerPort: 8080 + livenessProbe: + httpGet: + path: /health/live + port: 8080 + readinessProbe: + httpGet: + path: /health/ready + port: 8080 +``` + +## Security Considerations + +1. **Authentication**: JWT Bearer token (configure in production) +2. **Authorization**: Role-based access control +3. **Input Validation**: FluentValidation on all requests +4. **SQL Injection**: EF Core parameterized queries +5. **Secrets**: Environment variables, never in code + +## Performance Optimization + +1. **Connection Pooling**: EF Core with Npgsql connection resilience +2. **Async/Await**: All I/O operations are async +3. **Response Caching**: Add caching headers for queries +4. **Database Indexes**: Configure in EntityConfigurations + +## References + +- [eShopOnContainers](https://github.com/dotnet-architecture/eShopOnContainers) +- [.NET Microservices Architecture Guide](https://docs.microsoft.com/en-us/dotnet/architecture/microservices/) +- [Domain-Driven Design](https://martinfowler.com/bliki/DomainDrivenDesign.html) +- [CQRS Pattern](https://docs.microsoft.com/en-us/azure/architecture/patterns/cqrs) diff --git a/services/organization-service-net/docs/en/README.md b/services/organization-service-net/docs/en/README.md new file mode 100644 index 00000000..4cb53d44 --- /dev/null +++ b/services/organization-service-net/docs/en/README.md @@ -0,0 +1,265 @@ +# .NET 10 Microservice Template + +> Enterprise-grade .NET 10 microservice template following DDD, CQRS, and Clean Architecture patterns. + +## Overview + +This template provides a production-ready structure for .NET microservices based on the eShopOnContainers reference architecture with: + +- **Domain-Driven Design (DDD)** - Aggregates, Entities, Value Objects, Domain Events +- **CQRS Pattern** - Separate Commands (write) and Queries (read) with MediatR +- **Clean Architecture** - Domain, Infrastructure, API layered separation +- **EF Core 10** - PostgreSQL with connection resilience +- **FluentValidation** - Request validation +- **API Versioning** - URL segment versioning +- **Health Checks** - Kubernetes-ready probes +- **Structured Logging** - Serilog with console and Seq + +## Prerequisites + +| Requirement | Version | +|-------------|---------| +| .NET SDK | 10.0.101+ | +| Docker | 24.0+ | +| PostgreSQL | 15+ (or use Docker) | + +```bash +# Check .NET version +dotnet --version +# Should output: 10.0.xxx +``` + +## Quick Start + +### 1. Create New Service + +```bash +# Copy template to new service +cp -r services/_template_dot_net services/your-service-name + +# Navigate to service directory +cd services/your-service-name + +# Rename all occurrences of "MyService" to "YourService" +find . -type f -name "*.cs" -exec sed -i '' 's/MyService/YourService/g' {} + +find . -type f -name "*.csproj" -exec sed -i '' 's/MyService/YourService/g' {} + +``` + +### 2. Configure Environment + +```bash +# Copy environment template +cp .env.example .env + +# Edit with your configuration +nano .env +``` + +### 3. Run with Docker + +```bash +# Start all services (API + PostgreSQL + Redis) +docker-compose up -d + +# View logs +docker-compose logs -f myservice-api +``` + +### 4. Run Locally + +```bash +# Restore dependencies +dotnet restore + +# Build all projects +dotnet build + +# Run the API +dotnet run --project src/MyService.API +``` + +## Project Structure + +``` +_template_dot_net/ +├── src/ +│ ├── MyService.API/ # Presentation Layer (Controllers, CQRS) +│ │ ├── Controllers/ # API endpoints +│ │ ├── Application/ # CQRS Implementation +│ │ │ ├── Commands/ # Write operations (MediatR) +│ │ │ ├── Queries/ # Read operations +│ │ │ ├── Behaviors/ # MediatR pipeline behaviors +│ │ │ └── Validations/ # FluentValidation validators +│ │ ├── Middleware/ # Custom middleware +│ │ └── Program.cs # Application entry point +│ │ +│ ├── MyService.Domain/ # Domain Layer (Pure business logic) +│ │ ├── AggregatesModel/ # Aggregate roots and entities +│ │ ├── Events/ # Domain events +│ │ ├── Exceptions/ # Domain exceptions +│ │ └── SeedWork/ # Base classes (Entity, ValueObject, etc.) +│ │ +│ └── MyService.Infrastructure/ # Infrastructure Layer (Data access) +│ ├── EntityConfigurations/ # EF Core Fluent API configurations +│ ├── Repositories/ # Repository implementations +│ ├── Idempotency/ # Request idempotency handling +│ └── MyServiceContext.cs # DbContext with Unit of Work +│ +├── tests/ +│ ├── MyService.UnitTests/ # Unit tests (Domain, Application) +│ └── MyService.FunctionalTests/ # Integration tests (API endpoints) +│ +├── Dockerfile # Multi-stage Docker build +├── docker-compose.yml # Local development setup +├── global.json # .NET SDK version pinning +└── Directory.Build.props # Common MSBuild properties +``` + +## API Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/api/v1/samples` | Get all samples | +| `GET` | `/api/v1/samples/{id}` | Get sample by ID | +| `POST` | `/api/v1/samples` | Create new sample | +| `PUT` | `/api/v1/samples/{id}` | Update sample | +| `DELETE` | `/api/v1/samples/{id}` | Delete sample | +| `PATCH` | `/api/v1/samples/{id}/status` | Change status | + +### Health Endpoints + +| Endpoint | Purpose | +|----------|---------| +| `/health` | Full health status | +| `/health/live` | Liveness probe | +| `/health/ready` | Readiness probe | + +## CQRS Pattern + +### Commands (Write Operations) + +```csharp +// Define command +public record CreateSampleCommand(string Name, string? Description) + : IRequest; + +// Handle command +public class CreateSampleCommandHandler : IRequestHandler +{ + public async Task Handle(CreateSampleCommand request, CancellationToken ct) + { + var sample = new Sample(request.Name, request.Description); + _repository.Add(sample); + await _repository.UnitOfWork.SaveEntitiesAsync(ct); + return new CreateSampleCommandResult(sample.Id); + } +} +``` + +### Queries (Read Operations) + +```csharp +// Define query +public record GetSampleQuery(Guid SampleId) : IRequest; +``` + +## Domain Model + +### Aggregate Root + +```csharp +public class Sample : Entity, IAggregateRoot +{ + public string Name => _name; + public SampleStatus Status => _status; + + public Sample(string name, string? description) { + // Business logic validation + if (string.IsNullOrWhiteSpace(name)) + throw new SampleDomainException("Sample name cannot be empty"); + + // Domain event + AddDomainEvent(new SampleCreatedDomainEvent(this)); + } + + public void Activate() { + if (_status != SampleStatus.Draft) + throw new SampleDomainException("Only draft samples can be activated"); + // State transition + } +} +``` + +## Testing + +```bash +# Run all tests +dotnet test + +# Run with coverage +dotnet test /p:CollectCoverage=true /p:CoverageReportFormat=cobertura + +# Run specific test project +dotnet test tests/MyService.UnitTests +``` + +## Configuration + +### Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `ASPNETCORE_ENVIRONMENT` | Environment name | `Development` | +| `DATABASE_URL` | PostgreSQL connection string | - | +| `REDIS_URL` | Redis connection string | - | +| `JWT_SECRET` | JWT signing secret (min 32 chars) | - | + +### appsettings.json + +```json +{ + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Database=myservice;Username=postgres;Password=postgres" + }, + "Serilog": { + "MinimumLevel": "Information" + } +} +``` + +## Deployment + +### Docker Build + +```bash +# Build Docker image +docker build -t myservice:latest . + +# Run container +docker run -p 5000:8080 --env-file .env myservice:latest +``` + +### Kubernetes + +See [ARCHITECTURE.md](./ARCHITECTURE.md) for Kubernetes deployment manifests. + +## What's New in .NET 10 + +- **C# 14** language features +- Improved **Native AOT** support +- Better **async/await** performance +- Enhanced **JSON serialization** +- Performance improvements across the board +- 3-year **LTS** support (until November 2028) + +## Resources + +- [eShopOnContainers](https://github.com/dotnet-architecture/eShopOnContainers) - Reference architecture +- [.NET 10 Documentation](https://docs.microsoft.com/en-us/dotnet/core/whats-new/dotnet-10) +- [DDD with .NET](https://docs.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/) +- [MediatR](https://github.com/jbogard/MediatR) - CQRS library +- [FluentValidation](https://docs.fluentvalidation.net/) - Validation library + +## License + +Proprietary - GoodGo Platform diff --git a/services/organization-service-net/docs/vi/ARCHITECTURE.md b/services/organization-service-net/docs/vi/ARCHITECTURE.md new file mode 100644 index 00000000..55a5d13b --- /dev/null +++ b/services/organization-service-net/docs/vi/ARCHITECTURE.md @@ -0,0 +1,271 @@ +# Tài Liệu Kiến Trúc + +> Tài liệu kiến trúc chi tiết cho Template Microservice .NET 10. + +## Tổng Quan Kiến Trúc + +```mermaid +graph TB + subgraph "Lớp API" + C[Controllers] + CMD[Commands] + Q[Queries] + B[Behaviors] + V[Validations] + end + + subgraph "Lớp Domain" + AR[Aggregate Roots] + E[Entities] + VO[Value Objects] + DE[Domain Events] + DX[Domain Exceptions] + end + + subgraph "Lớp Infrastructure" + DB[(PostgreSQL)] + R[Repositories] + CTX[DbContext] + ID[Idempotency] + end + + C --> CMD + C --> Q + CMD --> B --> V + CMD --> AR + Q --> R + R --> CTX --> DB + AR --> DE + R --> AR + + style C fill:#4a90d9,stroke:#2d5986,color:#fff + style AR fill:#50c878,stroke:#2d8659,color:#fff + style DB fill:#ff6b6b,stroke:#c0392b,color:#fff +``` + +## Trách Nhiệm Các Lớp + +### 1. Lớp Domain (MyService.Domain) + +Trái tim của ứng dụng chứa business logic thuần túy. Lớp này: +- Có **ZERO** phụ thuộc bên ngoài (ngoại trừ MediatR.Contracts cho events) +- Chỉ chứa các class POCO +- Triển khai các tactical patterns của DDD + +#### Thành Phần + +| Thành phần | Mục Đích | +|------------|----------| +| **SeedWork** | Base classes: Entity, ValueObject, Enumeration, IAggregateRoot | +| **AggregatesModel** | Aggregate roots với entities và value objects | +| **Events** | Domain events cho giao tiếp cross-aggregate | +| **Exceptions** | Domain exceptions cho vi phạm business rules | + +### 2. Lớp Infrastructure (MyService.Infrastructure) + +Triển khai kỹ thuật và các mối quan tâm bên ngoài: +- Truy cập database (EF Core) +- Triển khai repositories +- Tích hợp external services + +### 3. Lớp API (MyService.API) + +Điểm vào ứng dụng và triển khai CQRS: +- Controllers để xử lý HTTP +- Commands cho các thao tác ghi +- Queries cho các thao tác đọc +- MediatR behaviors cho cross-cutting concerns + +## Luồng CQRS + +```mermaid +sequenceDiagram + participant Client + participant Controller + participant MediatR + participant LoggingBehavior + participant ValidatorBehavior + participant TransactionBehavior + participant CommandHandler + participant Repository + participant DbContext + + Client->>Controller: HTTP Request + Controller->>MediatR: Send(Command) + MediatR->>LoggingBehavior: Handle + LoggingBehavior->>ValidatorBehavior: Next() + ValidatorBehavior->>TransactionBehavior: Next() + TransactionBehavior->>CommandHandler: Next() + CommandHandler->>Repository: Add/Update/Delete + Repository->>DbContext: SaveEntitiesAsync() + DbContext-->>Repository: Success + Repository-->>CommandHandler: Result + CommandHandler-->>Controller: Response + Controller-->>Client: HTTP Response +``` + +## Domain Events + +```mermaid +graph LR + AR[Aggregate Root] -->|Phát sinh| DE[Domain Event] + DE -->|Dispatch bởi| CTX[DbContext] + CTX -->|Publish tới| M[MediatR] + M -->|Xử lý bởi| H1[Handler 1] + M -->|Xử lý bởi| H2[Handler 2] + + style AR fill:#50c878,stroke:#2d8659,color:#fff + style DE fill:#f39c12,stroke:#d68910,color:#fff + style M fill:#9b59b6,stroke:#7d3c98,color:#fff +``` + +## Schema Database + +### Sample Aggregate + +```mermaid +erDiagram + samples { + uuid id PK + varchar(200) name + varchar(1000) description + int status_id FK + timestamp created_at + timestamp updated_at + } + + sample_statuses { + int id PK + varchar(50) name + } + + samples ||--o{ sample_statuses : has +``` + +## Pipeline MediatR + +``` +Request → LoggingBehavior → ValidatorBehavior → TransactionBehavior → Handler → Response + │ │ │ + ▼ ▼ ▼ + Log start/end Validate Begin/Commit + + timing với Transaction + FluentValidation +``` + +### Thứ Tự Behaviors + +1. **LoggingBehavior** - Ghi log xử lý request với timing +2. **ValidatorBehavior** - Validate request sử dụng FluentValidation +3. **TransactionBehavior** - Bao bọc command handlers trong database transactions + +## Xử Lý Lỗi + +### Phân Cấp Exceptions + +``` +Exception +└── DomainException + └── SampleDomainException +``` + +### Problem Details (RFC 7807) + +Tất cả lỗi được trả về theo định dạng Problem Details: + +```json +{ + "type": "https://tools.ietf.org/html/rfc7807", + "title": "Lỗi Validation", + "status": 400, + "detail": "Một hoặc nhiều lỗi validation đã xảy ra.", + "errors": { + "Name": ["Tên là bắt buộc"] + } +} +``` + +## Health Checks + +```mermaid +graph TD + HC[Health Check Endpoint] + HC --> |/health/live| L[Liveness] + HC --> |/health/ready| R[Readiness] + HC --> |/health| F[Full Status] + + R --> PG[(PostgreSQL)] + R --> RD[(Redis)] + + style HC fill:#3498db,stroke:#2980b9,color:#fff + style L fill:#2ecc71,stroke:#27ae60,color:#fff + style R fill:#f39c12,stroke:#d68910,color:#fff +``` + +## Kiến Trúc Deployment + +### Docker Compose (Local) + +```yaml +services: + myservice-api: + build: . + ports: ["5000:8080"] + depends_on: + - postgres + - redis + + postgres: + image: postgres:16-alpine + + redis: + image: redis:7-alpine +``` + +### Kubernetes (Production) + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: myservice-api +spec: + replicas: 3 + template: + spec: + containers: + - name: api + image: myservice:latest + ports: + - containerPort: 8080 + livenessProbe: + httpGet: + path: /health/live + port: 8080 + readinessProbe: + httpGet: + path: /health/ready + port: 8080 +``` + +## Cân Nhắc Bảo Mật + +1. **Authentication**: JWT Bearer token (cấu hình trong production) +2. **Authorization**: Role-based access control +3. **Input Validation**: FluentValidation trên tất cả requests +4. **SQL Injection**: EF Core parameterized queries +5. **Secrets**: Biến môi trường, không bao giờ trong code + +## Tối Ưu Hiệu Năng + +1. **Connection Pooling**: EF Core với Npgsql connection resilience +2. **Async/Await**: Tất cả I/O operations đều async +3. **Response Caching**: Thêm caching headers cho queries +4. **Database Indexes**: Cấu hình trong EntityConfigurations + +## Tài Liệu Tham Khảo + +- [eShopOnContainers](https://github.com/dotnet-architecture/eShopOnContainers) +- [Hướng dẫn Kiến trúc .NET Microservices](https://docs.microsoft.com/en-us/dotnet/architecture/microservices/) +- [Domain-Driven Design](https://martinfowler.com/bliki/DomainDrivenDesign.html) +- [CQRS Pattern](https://docs.microsoft.com/en-us/azure/architecture/patterns/cqrs) diff --git a/services/organization-service-net/docs/vi/README.md b/services/organization-service-net/docs/vi/README.md new file mode 100644 index 00000000..7d7e48b6 --- /dev/null +++ b/services/organization-service-net/docs/vi/README.md @@ -0,0 +1,265 @@ +# Template Microservice .NET 10 + +> Template microservice .NET 10 cấp doanh nghiệp theo các pattern DDD, CQRS và Clean Architecture. + +## Tổng Quan + +Template này cung cấp cấu trúc sẵn sàng production cho microservices .NET dựa trên kiến trúc tham chiếu eShopOnContainers với: + +- **Domain-Driven Design (DDD)** - Aggregates, Entities, Value Objects, Domain Events +- **CQRS Pattern** - Tách biệt Commands (ghi) và Queries (đọc) với MediatR +- **Clean Architecture** - Phân tầng Domain, Infrastructure, API +- **EF Core 10** - PostgreSQL với connection resilience +- **FluentValidation** - Validation request +- **API Versioning** - Versioning theo URL segment +- **Health Checks** - Probes sẵn sàng cho Kubernetes +- **Structured Logging** - Serilog với console và Seq + +## Yêu Cầu + +| Yêu cầu | Phiên bản | +|---------|-----------| +| .NET SDK | 10.0.101+ | +| Docker | 24.0+ | +| PostgreSQL | 15+ (hoặc dùng Docker) | + +```bash +# Kiểm tra phiên bản .NET +dotnet --version +# Kết quả nên là: 10.0.xxx +``` + +## Bắt Đầu Nhanh + +### 1. Tạo Service Mới + +```bash +# Sao chép template sang service mới +cp -r services/_template_dot_net services/your-service-name + +# Di chuyển đến thư mục service +cd services/your-service-name + +# Đổi tên tất cả "MyService" thành "YourService" +find . -type f -name "*.cs" -exec sed -i '' 's/MyService/YourService/g' {} + +find . -type f -name "*.csproj" -exec sed -i '' 's/MyService/YourService/g' {} + +``` + +### 2. Cấu Hình Môi Trường + +```bash +# Sao chép template môi trường +cp .env.example .env + +# Chỉnh sửa với cấu hình của bạn +nano .env +``` + +### 3. Chạy với Docker + +```bash +# Khởi động tất cả services (API + PostgreSQL + Redis) +docker-compose up -d + +# Xem logs +docker-compose logs -f myservice-api +``` + +### 4. Chạy Local + +```bash +# Khôi phục dependencies +dotnet restore + +# Build tất cả projects +dotnet build + +# Chạy API +dotnet run --project src/MyService.API +``` + +## Cấu Trúc Dự Án + +``` +_template_dot_net/ +├── src/ +│ ├── MyService.API/ # Lớp Presentation (Controllers, CQRS) +│ │ ├── Controllers/ # Các API endpoints +│ │ ├── Application/ # Triển khai CQRS +│ │ │ ├── Commands/ # Thao tác ghi (MediatR) +│ │ │ ├── Queries/ # Thao tác đọc +│ │ │ ├── Behaviors/ # MediatR pipeline behaviors +│ │ │ └── Validations/ # FluentValidation validators +│ │ ├── Middleware/ # Custom middleware +│ │ └── Program.cs # Điểm vào ứng dụng +│ │ +│ ├── MyService.Domain/ # Lớp Domain (Business logic thuần túy) +│ │ ├── AggregatesModel/ # Aggregate roots và entities +│ │ ├── Events/ # Domain events +│ │ ├── Exceptions/ # Domain exceptions +│ │ └── SeedWork/ # Base classes (Entity, ValueObject, etc.) +│ │ +│ └── MyService.Infrastructure/ # Lớp Infrastructure (Truy cập dữ liệu) +│ ├── EntityConfigurations/ # Cấu hình EF Core Fluent API +│ ├── Repositories/ # Triển khai repositories +│ ├── Idempotency/ # Xử lý idempotency request +│ └── MyServiceContext.cs # DbContext với Unit of Work +│ +├── tests/ +│ ├── MyService.UnitTests/ # Unit tests (Domain, Application) +│ └── MyService.FunctionalTests/ # Integration tests (API endpoints) +│ +├── Dockerfile # Multi-stage Docker build +├── docker-compose.yml # Thiết lập phát triển local +├── global.json # Pin phiên bản .NET SDK +└── Directory.Build.props # Thuộc tính MSBuild chung +``` + +## Các Endpoint API + +| Method | Endpoint | Mô Tả | +|--------|----------|-------| +| `GET` | `/api/v1/samples` | Lấy tất cả samples | +| `GET` | `/api/v1/samples/{id}` | Lấy sample theo ID | +| `POST` | `/api/v1/samples` | Tạo sample mới | +| `PUT` | `/api/v1/samples/{id}` | Cập nhật sample | +| `DELETE` | `/api/v1/samples/{id}` | Xóa sample | +| `PATCH` | `/api/v1/samples/{id}/status` | Thay đổi trạng thái | + +### Health Endpoints + +| Endpoint | Mục Đích | +|----------|----------| +| `/health` | Trạng thái health đầy đủ | +| `/health/live` | Kiểm tra sống | +| `/health/ready` | Kiểm tra sẵn sàng | + +## Pattern CQRS + +### Commands (Thao Tác Ghi) + +```csharp +// Định nghĩa command +public record CreateSampleCommand(string Name, string? Description) + : IRequest; + +// Xử lý command +public class CreateSampleCommandHandler : IRequestHandler +{ + public async Task Handle(CreateSampleCommand request, CancellationToken ct) + { + var sample = new Sample(request.Name, request.Description); + _repository.Add(sample); + await _repository.UnitOfWork.SaveEntitiesAsync(ct); + return new CreateSampleCommandResult(sample.Id); + } +} +``` + +### Queries (Thao Tác Đọc) + +```csharp +// Định nghĩa query +public record GetSampleQuery(Guid SampleId) : IRequest; +``` + +## Domain Model + +### Aggregate Root + +```csharp +public class Sample : Entity, IAggregateRoot +{ + public string Name => _name; + public SampleStatus Status => _status; + + public Sample(string name, string? description) { + // Validation business logic + if (string.IsNullOrWhiteSpace(name)) + throw new SampleDomainException("Tên sample không được để trống"); + + // Domain event + AddDomainEvent(new SampleCreatedDomainEvent(this)); + } + + public void Activate() { + if (_status != SampleStatus.Draft) + throw new SampleDomainException("Chỉ sample draft mới có thể kích hoạt"); + // Chuyển đổi trạng thái + } +} +``` + +## Kiểm Thử + +```bash +# Chạy tất cả tests +dotnet test + +# Chạy với coverage +dotnet test /p:CollectCoverage=true /p:CoverageReportFormat=cobertura + +# Chạy project test cụ thể +dotnet test tests/MyService.UnitTests +``` + +## Cấu Hình + +### Biến Môi Trường + +| Biến | Mô Tả | Mặc định | +|------|-------|----------| +| `ASPNETCORE_ENVIRONMENT` | Tên môi trường | `Development` | +| `DATABASE_URL` | Connection string PostgreSQL | - | +| `REDIS_URL` | Connection string Redis | - | +| `JWT_SECRET` | Secret ký JWT (tối thiểu 32 ký tự) | - | + +### appsettings.json + +```json +{ + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Database=myservice;Username=postgres;Password=postgres" + }, + "Serilog": { + "MinimumLevel": "Information" + } +} +``` + +## Triển Khai + +### Docker Build + +```bash +# Build Docker image +docker build -t myservice:latest . + +# Chạy container +docker run -p 5000:8080 --env-file .env myservice:latest +``` + +### Kubernetes + +Xem [ARCHITECTURE.md](./ARCHITECTURE.md) để biết manifests triển khai Kubernetes. + +## Có Gì Mới Trong .NET 10 + +- Tính năng ngôn ngữ **C# 14** +- Hỗ trợ **Native AOT** được cải thiện +- Hiệu suất **async/await** tốt hơn +- **JSON serialization** được nâng cao +- Cải thiện hiệu suất toàn diện +- Hỗ trợ **LTS** 3 năm (đến tháng 11/2028) + +## Tài Nguyên + +- [eShopOnContainers](https://github.com/dotnet-architecture/eShopOnContainers) - Kiến trúc tham chiếu +- [Tài liệu .NET 10](https://docs.microsoft.com/en-us/dotnet/core/whats-new/dotnet-10) +- [DDD với .NET](https://docs.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/) +- [MediatR](https://github.com/jbogard/MediatR) - Thư viện CQRS +- [FluentValidation](https://docs.fluentvalidation.net/) - Thư viện validation + +## Giấy Phép + +Độc quyền - GoodGo Platform diff --git a/services/organization-service-net/global.json b/services/organization-service-net/global.json new file mode 100644 index 00000000..f78eeaf4 --- /dev/null +++ b/services/organization-service-net/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "10.0.101", + "rollForward": "latestMinor", + "allowPrerelease": false + } +} \ No newline at end of file diff --git a/services/organization-service-net/src/OrganizationService.API/Application/Behaviors/LoggingBehavior.cs b/services/organization-service-net/src/OrganizationService.API/Application/Behaviors/LoggingBehavior.cs new file mode 100644 index 00000000..03926954 --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.API/Application/Behaviors/LoggingBehavior.cs @@ -0,0 +1,58 @@ +using System.Diagnostics; +using MediatR; + +namespace OrganizationService.API.Application.Behaviors; + +/// +/// EN: MediatR behavior for logging request handling. +/// VI: MediatR behavior để logging việc xử lý request. +/// +/// EN: Request type / VI: Loại request +/// EN: Response type / VI: Loại response +public class LoggingBehavior : IPipelineBehavior + where TRequest : IRequest +{ + private readonly ILogger> _logger; + + public LoggingBehavior(ILogger> logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + var requestName = typeof(TRequest).Name; + + _logger.LogInformation( + "Handling {RequestName} / Đang xử lý {RequestName}", + requestName); + + var stopwatch = Stopwatch.StartNew(); + + try + { + var response = await next(); + + stopwatch.Stop(); + + _logger.LogInformation( + "Handled {RequestName} in {ElapsedMs}ms / Đã xử lý {RequestName} trong {ElapsedMs}ms", + requestName, stopwatch.ElapsedMilliseconds); + + return response; + } + catch (Exception ex) + { + stopwatch.Stop(); + + _logger.LogError(ex, + "Error handling {RequestName} after {ElapsedMs}ms / Lỗi xử lý {RequestName} sau {ElapsedMs}ms", + requestName, stopwatch.ElapsedMilliseconds); + + throw; + } + } +} diff --git a/services/organization-service-net/src/OrganizationService.API/Application/Behaviors/TransactionBehavior.cs b/services/organization-service-net/src/OrganizationService.API/Application/Behaviors/TransactionBehavior.cs new file mode 100644 index 00000000..f0f09327 --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.API/Application/Behaviors/TransactionBehavior.cs @@ -0,0 +1,84 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using OrganizationService.Infrastructure; + +namespace OrganizationService.API.Application.Behaviors; + +/// +/// EN: MediatR behavior for handling database transactions. +/// VI: MediatR behavior để xử lý database transactions. +/// +/// EN: Request type / VI: Loại request +/// EN: Response type / VI: Loại response +public class TransactionBehavior : IPipelineBehavior + where TRequest : IRequest +{ + private readonly OrganizationServiceContext _dbContext; + private readonly ILogger> _logger; + + public TransactionBehavior( + OrganizationServiceContext dbContext, + ILogger> logger) + { + _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + var requestName = typeof(TRequest).Name; + + // EN: Skip transaction for queries (read operations) + // VI: Bỏ qua transaction cho queries (các thao tác đọc) + if (requestName.EndsWith("Query")) + { + return await next(); + } + + // EN: Skip if already in a transaction + // VI: Bỏ qua nếu đã trong transaction + if (_dbContext.HasActiveTransaction) + { + return await next(); + } + + var strategy = _dbContext.Database.CreateExecutionStrategy(); + + return await strategy.ExecuteAsync(async () => + { + await using var transaction = await _dbContext.BeginTransactionAsync(); + + _logger.LogInformation( + "Begin transaction {TransactionId} for {RequestName} / Bắt đầu transaction {TransactionId} cho {RequestName}", + transaction?.TransactionId, requestName); + + try + { + var response = await next(); + + if (transaction != null) + { + await _dbContext.CommitTransactionAsync(transaction); + + _logger.LogInformation( + "Committed transaction {TransactionId} for {RequestName} / Đã commit transaction {TransactionId} cho {RequestName}", + transaction.TransactionId, requestName); + } + + return response; + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error during transaction {TransactionId} for {RequestName} / Lỗi trong transaction {TransactionId} cho {RequestName}", + transaction?.TransactionId, requestName); + + _dbContext.RollbackTransaction(); + throw; + } + }); + } +} diff --git a/services/organization-service-net/src/OrganizationService.API/Application/Behaviors/ValidatorBehavior.cs b/services/organization-service-net/src/OrganizationService.API/Application/Behaviors/ValidatorBehavior.cs new file mode 100644 index 00000000..09f94854 --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.API/Application/Behaviors/ValidatorBehavior.cs @@ -0,0 +1,63 @@ +using FluentValidation; +using MediatR; + +namespace OrganizationService.API.Application.Behaviors; + +/// +/// EN: MediatR behavior for FluentValidation integration. +/// VI: MediatR behavior để tích hợp FluentValidation. +/// +/// EN: Request type / VI: Loại request +/// EN: Response type / VI: Loại response +public class ValidatorBehavior : IPipelineBehavior + where TRequest : IRequest +{ + private readonly IEnumerable> _validators; + private readonly ILogger> _logger; + + public ValidatorBehavior( + IEnumerable> validators, + ILogger> logger) + { + _validators = validators ?? throw new ArgumentNullException(nameof(validators)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + var requestName = typeof(TRequest).Name; + + if (!_validators.Any()) + { + return await next(); + } + + _logger.LogDebug( + "Validating {RequestName} / Đang validate {RequestName}", + requestName); + + var context = new ValidationContext(request); + + var validationResults = await Task.WhenAll( + _validators.Select(v => v.ValidateAsync(context, cancellationToken))); + + var failures = validationResults + .SelectMany(r => r.Errors) + .Where(f => f != null) + .ToList(); + + if (failures.Count != 0) + { + _logger.LogWarning( + "Validation failed for {RequestName} with {ErrorCount} errors / Validation thất bại cho {RequestName} với {ErrorCount} lỗi", + requestName, failures.Count); + + throw new ValidationException(failures); + } + + return await next(); + } +} diff --git a/services/organization-service-net/src/OrganizationService.API/Application/Commands/CreateOrganizationCommand.cs b/services/organization-service-net/src/OrganizationService.API/Application/Commands/CreateOrganizationCommand.cs new file mode 100644 index 00000000..8834c8e8 --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.API/Application/Commands/CreateOrganizationCommand.cs @@ -0,0 +1,28 @@ +using MediatR; + +namespace OrganizationService.API.Application.Commands; + +/// +/// EN: Command to create a new organization. +/// VI: Command để tạo tổ chức mới. +/// +public record CreateOrganizationCommand( + string Name, + string Slug, + Guid OwnerId, + int TypeId, + string Email, + string? Phone, + string? Website, + string? Description +) : IRequest; + +/// +/// EN: Result of CreateOrganizationCommand. +/// VI: Kết quả của CreateOrganizationCommand. +/// +public record CreateOrganizationResult( + Guid Id, + string Name, + string Slug +); diff --git a/services/organization-service-net/src/OrganizationService.API/Application/Commands/CreateOrganizationCommandHandler.cs b/services/organization-service-net/src/OrganizationService.API/Application/Commands/CreateOrganizationCommandHandler.cs new file mode 100644 index 00000000..f4d2dfed --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.API/Application/Commands/CreateOrganizationCommandHandler.cs @@ -0,0 +1,56 @@ +using MediatR; +using OrganizationService.Domain.AggregatesModel.OrganizationAggregate; +using OrganizationService.Domain.AggregatesModel.OrganizationAggregate.ValueObjects; + +namespace OrganizationService.API.Application.Commands; + +/// +/// EN: Handler for CreateOrganizationCommand. +/// VI: Handler cho CreateOrganizationCommand. +/// +public class CreateOrganizationCommandHandler : IRequestHandler +{ + private readonly IOrganizationRepository _repository; + private readonly ILogger _logger; + + public CreateOrganizationCommandHandler( + IOrganizationRepository repository, + ILogger logger) + { + _repository = repository; + _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 already exists + // VI: Kiểm tra xem slug đã tồn tại chưa + if (await _repository.SlugExistsAsync(request.Slug, cancellationToken)) + { + throw new InvalidOperationException($"Organization with slug '{request.Slug}' already exists"); + } + + var type = OrganizationType.FromValue(request.TypeId); + var contactInfo = new ContactInfo(request.Email, request.Phone, request.Website); + + var organization = Organization.Create( + request.Name, + request.Slug, + request.OwnerId, + type, + contactInfo, + request.Description); + + _repository.Add(organization); + + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation("Organization {Id} created successfully", organization.Id); + + return new CreateOrganizationResult(organization.Id, organization.Name, organization.Slug); + } +} diff --git a/services/organization-service-net/src/OrganizationService.API/Application/Queries/OrganizationQueries.cs b/services/organization-service-net/src/OrganizationService.API/Application/Queries/OrganizationQueries.cs new file mode 100644 index 00000000..51d8b5ec --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.API/Application/Queries/OrganizationQueries.cs @@ -0,0 +1,42 @@ +using MediatR; + +namespace OrganizationService.API.Application.Queries; + +/// +/// 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 organizations by owner. +/// VI: Query để lấy các tổ chức theo chủ sở hữu. +/// +public record GetOrganizationsByOwnerQuery(Guid OwnerId) : IRequest>; + +/// +/// EN: Organization DTO for queries. +/// VI: Organization DTO cho queries. +/// +public record OrganizationDto( + Guid Id, + string Name, + string Slug, + string? Description, + string? LogoUrl, + string Type, + string Status, + string ContactEmail, + string? ContactPhone, + string? Website, + Guid OwnerId, + bool IsVerified, + DateTime CreatedAt, + DateTime UpdatedAt +); diff --git a/services/organization-service-net/src/OrganizationService.API/Application/Queries/OrganizationQueryHandlers.cs b/services/organization-service-net/src/OrganizationService.API/Application/Queries/OrganizationQueryHandlers.cs new file mode 100644 index 00000000..f915d6cf --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.API/Application/Queries/OrganizationQueryHandlers.cs @@ -0,0 +1,111 @@ +using MediatR; +using OrganizationService.Domain.AggregatesModel.OrganizationAggregate; + +namespace OrganizationService.API.Application.Queries; + +/// +/// EN: Handler for GetOrganizationByIdQuery. +/// VI: Handler cho GetOrganizationByIdQuery. +/// +public class GetOrganizationByIdQueryHandler : IRequestHandler +{ + private readonly IOrganizationRepository _repository; + + public GetOrganizationByIdQueryHandler(IOrganizationRepository repository) + { + _repository = repository; + } + + public async Task Handle(GetOrganizationByIdQuery request, CancellationToken cancellationToken) + { + var organization = await _repository.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.LogoUrl, + org.Type.Name, + org.Status.Name, + org.ContactInfo.Email, + org.ContactInfo.Phone, + org.ContactInfo.Website, + org.OwnerId, + org.IsVerified, + org.CreatedAt, + org.UpdatedAt + ); +} + +/// +/// EN: Handler for GetOrganizationBySlugQuery. +/// VI: Handler cho GetOrganizationBySlugQuery. +/// +public class GetOrganizationBySlugQueryHandler : IRequestHandler +{ + private readonly IOrganizationRepository _repository; + + public GetOrganizationBySlugQueryHandler(IOrganizationRepository repository) + { + _repository = repository; + } + + public async Task Handle(GetOrganizationBySlugQuery request, CancellationToken cancellationToken) + { + var organization = await _repository.GetBySlugAsync(request.Slug, cancellationToken); + return organization == null ? null : new OrganizationDto( + organization.Id, + organization.Name, + organization.Slug, + organization.Description, + organization.LogoUrl, + organization.Type.Name, + organization.Status.Name, + organization.ContactInfo.Email, + organization.ContactInfo.Phone, + organization.ContactInfo.Website, + organization.OwnerId, + organization.IsVerified, + organization.CreatedAt, + organization.UpdatedAt + ); + } +} + +/// +/// EN: Handler for GetOrganizationsByOwnerQuery. +/// VI: Handler cho GetOrganizationsByOwnerQuery. +/// +public class GetOrganizationsByOwnerQueryHandler : IRequestHandler> +{ + private readonly IOrganizationRepository _repository; + + public GetOrganizationsByOwnerQueryHandler(IOrganizationRepository repository) + { + _repository = repository; + } + + public async Task> Handle(GetOrganizationsByOwnerQuery request, CancellationToken cancellationToken) + { + var organizations = await _repository.GetByOwnerIdAsync(request.OwnerId, cancellationToken); + return organizations.Select(org => new OrganizationDto( + org.Id, + org.Name, + org.Slug, + org.Description, + org.LogoUrl, + org.Type.Name, + org.Status.Name, + org.ContactInfo.Email, + org.ContactInfo.Phone, + org.ContactInfo.Website, + org.OwnerId, + org.IsVerified, + org.CreatedAt, + org.UpdatedAt + )); + } +} diff --git a/services/organization-service-net/src/OrganizationService.API/Application/Validations/CreateOrganizationCommandValidator.cs b/services/organization-service-net/src/OrganizationService.API/Application/Validations/CreateOrganizationCommandValidator.cs new file mode 100644 index 00000000..e410fc23 --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.API/Application/Validations/CreateOrganizationCommandValidator.cs @@ -0,0 +1,45 @@ +using FluentValidation; +using OrganizationService.API.Application.Commands; + +namespace OrganizationService.API.Application.Validations; + +/// +/// EN: Validator for CreateOrganizationCommand. +/// VI: Validator cho CreateOrganizationCommand. +/// +public class CreateOrganizationCommandValidator : AbstractValidator +{ + public CreateOrganizationCommandValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Name is required") + .MaximumLength(200).WithMessage("Name must not exceed 200 characters"); + + RuleFor(x => x.Slug) + .NotEmpty().WithMessage("Slug is required") + .MaximumLength(200).WithMessage("Slug must not exceed 200 characters") + .Matches("^[a-z0-9-]+$").WithMessage("Slug must contain only lowercase letters, numbers, and hyphens"); + + RuleFor(x => x.OwnerId) + .NotEmpty().WithMessage("OwnerId is required"); + + RuleFor(x => x.TypeId) + .GreaterThan(0).WithMessage("TypeId must be a valid organization type"); + + RuleFor(x => x.Email) + .NotEmpty().WithMessage("Email is required") + .EmailAddress().WithMessage("Email must be a valid email address"); + + RuleFor(x => x.Phone) + .MaximumLength(50).WithMessage("Phone must not exceed 50 characters") + .When(x => !string.IsNullOrEmpty(x.Phone)); + + RuleFor(x => x.Website) + .MaximumLength(500).WithMessage("Website must not exceed 500 characters") + .When(x => !string.IsNullOrEmpty(x.Website)); + + RuleFor(x => x.Description) + .MaximumLength(2000).WithMessage("Description must not exceed 2000 characters") + .When(x => !string.IsNullOrEmpty(x.Description)); + } +} diff --git a/services/organization-service-net/src/OrganizationService.API/Controllers/OrganizationsController.cs b/services/organization-service-net/src/OrganizationService.API/Controllers/OrganizationsController.cs new file mode 100644 index 00000000..baf20e3d --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.API/Controllers/OrganizationsController.cs @@ -0,0 +1,84 @@ +using MediatR; +using Microsoft.AspNetCore.Mvc; +using OrganizationService.API.Application.Commands; +using OrganizationService.API.Application.Queries; + +namespace OrganizationService.API.Controllers; + +/// +/// EN: Organizations API controller. +/// VI: Controller API Organizations. +/// +[ApiController] +[Route("api/v1/[controller]")] +[Produces("application/json")] +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}")] + [ProducesResponseType(typeof(OrganizationDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetById(Guid id, CancellationToken cancellationToken) + { + var result = await _mediator.Send(new GetOrganizationByIdQuery(id), cancellationToken); + return result == null ? NotFound() : Ok(result); + } + + /// + /// EN: Get organization by slug. + /// VI: Lấy tổ chức theo slug. + /// + [HttpGet("slug/{slug}")] + [ProducesResponseType(typeof(OrganizationDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetBySlug(string slug, CancellationToken cancellationToken) + { + var result = await _mediator.Send(new GetOrganizationBySlugQuery(slug), cancellationToken); + return result == null ? NotFound() : Ok(result); + } + + /// + /// EN: Get organizations by owner. + /// VI: Lấy các tổ chức theo chủ sở hữu. + /// + [HttpGet("owner/{ownerId:guid}")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public async Task GetByOwner(Guid ownerId, CancellationToken cancellationToken) + { + var result = await _mediator.Send(new GetOrganizationsByOwnerQuery(ownerId), cancellationToken); + return Ok(result); + } + + /// + /// EN: Create a new organization. + /// VI: Tạo tổ chức mới. + /// + [HttpPost] + [ProducesResponseType(typeof(CreateOrganizationResult), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task Create([FromBody] CreateOrganizationCommand command, CancellationToken cancellationToken) + { + try + { + var result = await _mediator.Send(command, cancellationToken); + return CreatedAtAction(nameof(GetById), new { id = result.Id }, result); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, "Failed to create organization"); + return BadRequest(new { error = ex.Message }); + } + } +} diff --git a/services/organization-service-net/src/OrganizationService.API/OrganizationService.API.csproj b/services/organization-service-net/src/OrganizationService.API/OrganizationService.API.csproj new file mode 100644 index 00000000..e789cf8c --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.API/OrganizationService.API.csproj @@ -0,0 +1,43 @@ + + + + OrganizationService.API + OrganizationService.API + Web API layer with CQRS pattern + myservice-api + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/services/organization-service-net/src/OrganizationService.API/Program.cs b/services/organization-service-net/src/OrganizationService.API/Program.cs new file mode 100644 index 00000000..bcc413c6 --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.API/Program.cs @@ -0,0 +1,144 @@ +using Asp.Versioning; +using FluentValidation; +using Hellang.Middleware.ProblemDetails; +using OrganizationService.API.Application.Behaviors; +using OrganizationService.Infrastructure; +using Serilog; + +// EN: Configure Serilog early / VI: Cấu hình Serilog sớm +Log.Logger = new LoggerConfiguration() + .WriteTo.Console() + .CreateBootstrapLogger(); + +try +{ + Log.Information("Starting OrganizationService API / Khởi động OrganizationService API"); + + var builder = WebApplication.CreateBuilder(args); + + // EN: Configure Serilog / VI: Cấu hình Serilog + builder.Host.UseSerilog((context, services, configuration) => configuration + .ReadFrom.Configuration(context.Configuration) + .ReadFrom.Services(services) + .Enrich.FromLogContext() + .WriteTo.Console()); + + // EN: Add Infrastructure services / VI: Thêm Infrastructure services + builder.Services.AddInfrastructure(builder.Configuration); + + // EN: Add MediatR with behaviors / VI: Thêm MediatR với behaviors + builder.Services.AddMediatR(cfg => + { + cfg.RegisterServicesFromAssemblyContaining(); + cfg.AddOpenBehavior(typeof(LoggingBehavior<,>)); + cfg.AddOpenBehavior(typeof(ValidatorBehavior<,>)); + cfg.AddOpenBehavior(typeof(TransactionBehavior<,>)); + }); + + // EN: Add FluentValidation / VI: Thêm FluentValidation + builder.Services.AddValidatorsFromAssemblyContaining(); + + // EN: Add API versioning / VI: Thêm API versioning + builder.Services.AddApiVersioning(options => + { + options.DefaultApiVersion = new ApiVersion(1, 0); + options.AssumeDefaultVersionWhenUnspecified = true; + options.ReportApiVersions = true; + options.ApiVersionReader = ApiVersionReader.Combine( + new UrlSegmentApiVersionReader(), + new HeaderApiVersionReader("X-Api-Version")); + }) + .AddApiExplorer(options => + { + options.GroupNameFormat = "'v'VVV"; + options.SubstituteApiVersionInUrl = true; + }); + + // EN: Add controllers / VI: Thêm controllers + builder.Services.AddControllers(); + + // EN: Add ProblemDetails middleware (RFC 7807) / VI: Thêm ProblemDetails middleware + builder.Services.AddProblemDetails(options => + { + options.IncludeExceptionDetails = (ctx, ex) => + builder.Environment.IsDevelopment(); + }); + + // EN: Add Swagger / VI: Thêm Swagger + builder.Services.AddEndpointsApiExplorer(); + builder.Services.AddSwaggerGen(options => + { + options.SwaggerDoc("v1", new() + { + Title = "OrganizationService API", + Version = "v1", + Description = "OrganizationService microservice API / API microservice OrganizationService" + }); + }); + + // EN: Add health checks / VI: Thêm health checks + builder.Services.AddHealthChecks() + .AddNpgSql( + builder.Configuration.GetConnectionString("DefaultConnection") + ?? builder.Configuration["DATABASE_URL"] + ?? "", + name: "postgresql", + tags: ["db", "postgresql"]); + + // EN: Add CORS / VI: Thêm CORS + builder.Services.AddCors(options => + { + options.AddDefaultPolicy(policy => + { + policy.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); + }); + + var app = builder.Build(); + + // EN: Configure middleware pipeline / VI: Cấu hình middleware pipeline + app.UseSerilogRequestLogging(); + app.UseProblemDetails(); + + if (app.Environment.IsDevelopment()) + { + app.UseSwagger(); + app.UseSwaggerUI(c => + { + c.SwaggerEndpoint("/swagger/v1/swagger.json", "OrganizationService API v1"); + c.RoutePrefix = "swagger"; + }); + } + + app.UseCors(); + app.UseRouting(); + + // EN: Map health check endpoints / VI: Map health check endpoints + app.MapHealthChecks("/health"); + app.MapHealthChecks("/health/live", new() + { + Predicate = _ => false // EN: Just checks app is running / VI: Chỉ kiểm tra app đang chạy + }); + app.MapHealthChecks("/health/ready"); + + // EN: Map controllers / VI: Map controllers + app.MapControllers(); + + // EN: Run the application / VI: Chạy ứng dụng + app.Run(); +} +catch (Exception ex) +{ + Log.Fatal(ex, "Application terminated unexpectedly / Ứng dụng kết thúc bất ngờ"); + throw; +} +finally +{ + Log.CloseAndFlush(); +} + +// EN: Make Program class accessible for integration tests +// VI: Làm cho class Program có thể truy cập cho integration tests +public partial class Program { } diff --git a/services/organization-service-net/src/OrganizationService.API/Properties/launchSettings.json b/services/organization-service-net/src/OrganizationService.API/Properties/launchSettings.json new file mode 100644 index 00000000..6355d40b --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.API/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} \ No newline at end of file diff --git a/services/organization-service-net/src/OrganizationService.API/appsettings.Development.json b/services/organization-service-net/src/OrganizationService.API/appsettings.Development.json new file mode 100644 index 00000000..e407ac85 --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.API/appsettings.Development.json @@ -0,0 +1,19 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft.AspNetCore": "Information", + "Microsoft.EntityFrameworkCore.Database.Command": "Information" + } + }, + "Serilog": { + "MinimumLevel": { + "Default": "Debug", + "Override": { + "Microsoft": "Information", + "Microsoft.EntityFrameworkCore.Database.Command": "Information", + "System": "Information" + } + } + } +} \ No newline at end of file diff --git a/services/organization-service-net/src/OrganizationService.API/appsettings.json b/services/organization-service-net/src/OrganizationService.API/appsettings.json new file mode 100644 index 00000000..523dc0fc --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.API/appsettings.json @@ -0,0 +1,46 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + }, + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "Microsoft.EntityFrameworkCore": "Warning", + "System": "Warning" + } + }, + "WriteTo": [ + { + "Name": "Console", + "Args": { + "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}" + } + } + ], + "Enrich": [ + "FromLogContext", + "WithMachineName", + "WithThreadId" + ] + }, + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Port=5432;Database=myservice_db;Username=postgres;Password=postgres" + }, + "Redis": { + "ConnectionString": "localhost:6379" + }, + "Jwt": { + "Secret": "your-super-secret-key-min-32-characters", + "Issuer": "goodgo-platform", + "Audience": "goodgo-services", + "AccessTokenExpiryMinutes": 15, + "RefreshTokenExpiryDays": 7 + }, + "AllowedHosts": "*" +} \ No newline at end of file diff --git a/services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/MemberAggregate/MemberRole.cs b/services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/MemberAggregate/MemberRole.cs new file mode 100644 index 00000000..016f052a --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/MemberAggregate/MemberRole.cs @@ -0,0 +1,38 @@ +using OrganizationService.Domain.SeedWork; + +namespace OrganizationService.Domain.AggregatesModel.MemberAggregate; + +/// +/// EN: Role of a member in an organization (Owner, Admin, Manager, Member, Viewer). +/// VI: Vai trò của thành viên trong tổ chức (Chủ sở hữu, Quản trị, Quản lý, Thành viên, Người xem). +/// +public class MemberRole : Enumeration +{ + public static MemberRole Owner = new(1, nameof(Owner)); + public static MemberRole Admin = new(2, nameof(Admin)); + public static MemberRole Manager = new(3, nameof(Manager)); + public static MemberRole Member = new(4, nameof(Member)); + public static MemberRole Viewer = new(5, nameof(Viewer)); + + public MemberRole(int id, string name) : base(id, name) { } + + /// + /// EN: Check if this role has higher or equal privilege than another role. + /// VI: Kiểm tra xem vai trò này có quyền cao hơn hoặc bằng vai trò khác không. + /// + public bool HasAtLeastPrivilegeOf(MemberRole other) + { + // Lower ID = higher privilege + return Id <= other.Id; + } + + /// + /// EN: Check if this role can manage another role. + /// VI: Kiểm tra xem vai trò này có thể quản lý vai trò khác không. + /// + public bool CanManage(MemberRole other) + { + // Can manage roles with lower privilege (higher ID) + return Id < other.Id; + } +} diff --git a/services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/MemberAggregate/MemberStatus.cs b/services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/MemberAggregate/MemberStatus.cs new file mode 100644 index 00000000..9730e778 --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/MemberAggregate/MemberStatus.cs @@ -0,0 +1,30 @@ +using OrganizationService.Domain.SeedWork; + +namespace OrganizationService.Domain.AggregatesModel.MemberAggregate; + +/// +/// EN: Status of a member in an organization (Active, Invited, Suspended, Left). +/// VI: Trạng thái của thành viên trong tổ chức (Hoạt động, Được mời, Tạm ngưng, Đã rời). +/// +public class MemberStatus : Enumeration +{ + public static MemberStatus Active = new(1, nameof(Active)); + public static MemberStatus Invited = new(2, nameof(Invited)); + public static MemberStatus Suspended = new(3, nameof(Suspended)); + public static MemberStatus Left = new(4, nameof(Left)); + public static MemberStatus Removed = new(5, nameof(Removed)); + + public MemberStatus(int id, string name) : base(id, name) { } + + /// + /// EN: Check if member is in a joinable state. + /// VI: Kiểm tra xem thành viên có thể tham gia không. + /// + public bool CanJoin() => this == Invited; + + /// + /// EN: Check if member is in an active state. + /// VI: Kiểm tra xem thành viên có đang hoạt động không. + /// + public bool IsActive() => this == Active; +} diff --git a/services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/MemberAggregate/OrganizationMember.cs b/services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/MemberAggregate/OrganizationMember.cs new file mode 100644 index 00000000..40a347d9 --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/MemberAggregate/OrganizationMember.cs @@ -0,0 +1,168 @@ +using OrganizationService.Domain.AggregatesModel.OrganizationAggregate; +using OrganizationService.Domain.Exceptions; +using OrganizationService.Domain.SeedWork; + +namespace OrganizationService.Domain.AggregatesModel.MemberAggregate; + +/// +/// EN: Organization member entity representing a user's membership in an organization. +/// VI: Entity thành viên tổ chức đại diện cho tư cách thành viên của người dùng trong tổ chức. +/// +public class OrganizationMember : Entity +{ + public Guid OrganizationId { get; private set; } + public Guid UserId { get; private set; } + + private int _roleId; + public MemberRole Role { get; private set; } + + private int _statusId; + public MemberStatus Status { get; private set; } + + public DateTime? JoinedAt { get; private set; } + public DateTime InvitedAt { get; private set; } + public Guid InvitedBy { get; private set; } + + // Navigation property + public Organization? Organization { get; private set; } + + // Constructor for EF Core + protected OrganizationMember() + { + Role = null!; + Status = null!; + } + + private OrganizationMember( + Guid organizationId, + Guid userId, + MemberRole role, + MemberStatus status, + Guid invitedBy) : this() + { + Id = Guid.NewGuid(); + OrganizationId = organizationId; + UserId = userId; + _roleId = role.Id; + Role = role; + _statusId = status.Id; + Status = status; + InvitedAt = DateTime.UtcNow; + InvitedBy = invitedBy; + + if (status == MemberStatus.Active) + { + JoinedAt = DateTime.UtcNow; + } + } + + /// + /// EN: Create owner member (automatically active). + /// VI: Tạo thành viên chủ sở hữu (tự động active). + /// + public static OrganizationMember CreateOwner(Guid organizationId, Guid userId) + { + return new OrganizationMember(organizationId, userId, MemberRole.Owner, MemberStatus.Active, userId); + } + + /// + /// EN: Invite a new member. + /// VI: Mời thành viên mới. + /// + public static OrganizationMember Invite(Guid organizationId, Guid userId, MemberRole role, Guid invitedBy) + { + if (role == MemberRole.Owner) + throw new OrganizationDomainException("Cannot invite as owner"); + + return new OrganizationMember(organizationId, userId, role, MemberStatus.Invited, invitedBy); + } + + /// + /// EN: Accept invitation and become active member. + /// VI: Chấp nhận lời mời và trở thành thành viên active. + /// + public void AcceptInvitation() + { + if (!Status.CanJoin()) + throw new OrganizationDomainException("Cannot accept invitation in current status"); + + _statusId = MemberStatus.Active.Id; + Status = MemberStatus.Active; + JoinedAt = DateTime.UtcNow; + } + + /// + /// EN: Decline invitation. + /// VI: Từ chối lời mời. + /// + public void DeclineInvitation() + { + if (!Status.CanJoin()) + throw new OrganizationDomainException("Cannot decline invitation in current status"); + + _statusId = MemberStatus.Left.Id; + Status = MemberStatus.Left; + } + + /// + /// EN: Update member role. + /// VI: Cập nhật vai trò thành viên. + /// + public void UpdateRole(MemberRole newRole) + { + _roleId = newRole.Id; + Role = newRole; + } + + /// + /// EN: Suspend member. + /// VI: Tạm ngưng thành viên. + /// + public void Suspend() + { + if (Role == MemberRole.Owner) + throw new OrganizationDomainException("Cannot suspend owner"); + + _statusId = MemberStatus.Suspended.Id; + Status = MemberStatus.Suspended; + } + + /// + /// EN: Reactivate suspended member. + /// VI: Kích hoạt lại thành viên bị tạm ngưng. + /// + public void Reactivate() + { + if (Status != MemberStatus.Suspended) + throw new OrganizationDomainException("Only suspended members can be reactivated"); + + _statusId = MemberStatus.Active.Id; + Status = MemberStatus.Active; + } + + /// + /// EN: Member leaves organization. + /// VI: Thành viên rời khỏi tổ chức. + /// + public void Leave() + { + if (Role == MemberRole.Owner) + throw new OrganizationDomainException("Owner cannot leave. Transfer ownership first."); + + _statusId = MemberStatus.Left.Id; + Status = MemberStatus.Left; + } + + /// + /// EN: Remove member from organization. + /// VI: Xóa thành viên khỏi tổ chức. + /// + public void Remove() + { + if (Role == MemberRole.Owner) + throw new OrganizationDomainException("Cannot remove owner"); + + _statusId = MemberStatus.Removed.Id; + Status = MemberStatus.Removed; + } +} diff --git a/services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/OrganizationAggregate/IOrganizationRepository.cs b/services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/OrganizationAggregate/IOrganizationRepository.cs new file mode 100644 index 00000000..f73b1909 --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/OrganizationAggregate/IOrganizationRepository.cs @@ -0,0 +1,46 @@ +using OrganizationService.Domain.SeedWork; + +namespace OrganizationService.Domain.AggregatesModel.OrganizationAggregate; + +/// +/// EN: Repository interface for Organization aggregate. +/// VI: Interface repository cho Organization aggregate. +/// +public interface IOrganizationRepository : IRepository +{ + /// + /// EN: Get organization by ID with all related data. + /// VI: Lấy tổ chức theo ID với tất cả dữ liệu liên quan. + /// + 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 organizations owned by a user. + /// VI: Lấy các tổ chức do người dùng sở hữu. + /// + Task> GetByOwnerIdAsync(Guid ownerId, CancellationToken cancellationToken = default); + + /// + /// EN: Check if slug already exists. + /// VI: Kiểm tra xem slug đã tồn tại chưa. + /// + Task SlugExistsAsync(string slug, 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); +} diff --git a/services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/OrganizationAggregate/Organization.cs b/services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/OrganizationAggregate/Organization.cs new file mode 100644 index 00000000..e8fb3d36 --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/OrganizationAggregate/Organization.cs @@ -0,0 +1,313 @@ +using OrganizationService.Domain.AggregatesModel.MemberAggregate; +using OrganizationService.Domain.AggregatesModel.OrganizationAggregate.ValueObjects; +using OrganizationService.Domain.AggregatesModel.VerificationAggregate; +using OrganizationService.Domain.Events; +using OrganizationService.Domain.Exceptions; +using OrganizationService.Domain.SeedWork; + +namespace OrganizationService.Domain.AggregatesModel.OrganizationAggregate; + +/// +/// EN: Organization aggregate root representing a company, team, or group. +/// VI: Aggregate root Tổ chức đại diện cho công ty, nhóm hoặc tổ chức. +/// +public class Organization : Entity, IAggregateRoot +{ + // Properties + public string Name { get; private set; } + public string Slug { get; private set; } + public string? Description { get; private set; } + public string? LogoUrl { get; private set; } + + private int _typeId; + public OrganizationType Type { get; private set; } + + private int _statusId; + public OrganizationStatus Status { get; private set; } + + public ContactInfo ContactInfo { get; private set; } + public Address? Address { get; private set; } + + public Guid OwnerId { get; private set; } + public bool IsVerified { get; private set; } + + public DateTime CreatedAt { get; private set; } + public DateTime UpdatedAt { get; private set; } + + // Navigation properties + private readonly List _members; + public IReadOnlyCollection Members => _members.AsReadOnly(); + + private readonly List _verificationRequests; + public IReadOnlyCollection VerificationRequests => _verificationRequests.AsReadOnly(); + + // Constructor for EF Core + protected Organization() + { + Name = string.Empty; + Slug = string.Empty; + ContactInfo = null!; + Type = null!; + Status = null!; + _members = new List(); + _verificationRequests = new List(); + } + + // Private constructor for factory method + private Organization( + string name, + string slug, + Guid ownerId, + OrganizationType type, + ContactInfo contactInfo, + string? description = null) : this() + { + Id = Guid.NewGuid(); + Name = name; + Slug = slug; + Description = description; + _typeId = type.Id; + Type = type; + _statusId = OrganizationStatus.Active.Id; + Status = OrganizationStatus.Active; + ContactInfo = contactInfo; + OwnerId = ownerId; + IsVerified = false; + CreatedAt = DateTime.UtcNow; + UpdatedAt = DateTime.UtcNow; + } + + /// + /// EN: Factory method to create a new organization. + /// VI: Factory method để tạo tổ chức mới. + /// + public static Organization Create( + string name, + string slug, + Guid ownerId, + OrganizationType type, + ContactInfo contactInfo, + string? description = null) + { + if (string.IsNullOrWhiteSpace(name)) + throw new OrganizationDomainException("Organization name is required"); + if (string.IsNullOrWhiteSpace(slug)) + throw new OrganizationDomainException("Organization slug is required"); + if (ownerId == Guid.Empty) + throw new OrganizationDomainException("Owner ID is required"); + + var organization = new Organization(name, slug, ownerId, type, contactInfo, description); + + // Add owner as first member + organization._members.Add(OrganizationMember.CreateOwner(organization.Id, ownerId)); + + // Raise domain event + organization.AddDomainEvent(new OrganizationCreatedDomainEvent( + organization.Id, + organization.Name, + organization.Slug, + organization.OwnerId)); + + return organization; + } + + /// + /// EN: Update organization basic info. + /// VI: Cập nhật thông tin cơ bản của tổ chức. + /// + public void UpdateInfo(string name, string? description, string? logoUrl) + { + if (string.IsNullOrWhiteSpace(name)) + throw new OrganizationDomainException("Organization name is required"); + + Name = name; + Description = description; + LogoUrl = logoUrl; + UpdatedAt = DateTime.UtcNow; + } + + /// + /// EN: Update organization contact info. + /// VI: Cập nhật thông tin liên hệ của tổ chức. + /// + public void UpdateContactInfo(ContactInfo contactInfo) + { + ContactInfo = contactInfo ?? throw new OrganizationDomainException("Contact info is required"); + UpdatedAt = DateTime.UtcNow; + } + + /// + /// EN: Update organization address. + /// VI: Cập nhật địa chỉ của tổ chức. + /// + public void UpdateAddress(Address? address) + { + Address = address; + UpdatedAt = DateTime.UtcNow; + } + + /// + /// EN: Add a new member to the organization. + /// VI: Thêm thành viên mới vào tổ chức. + /// + public OrganizationMember InviteMember(Guid userId, MemberRole role, Guid invitedBy) + { + if (userId == Guid.Empty) + throw new OrganizationDomainException("User ID is required"); + if (_members.Any(m => m.UserId == userId && m.Status.IsActive())) + throw new OrganizationDomainException("User is already a member of this organization"); + if (role == MemberRole.Owner) + throw new OrganizationDomainException("Cannot invite as owner. Use TransferOwnership instead."); + + var member = OrganizationMember.Invite(Id, userId, role, invitedBy); + _members.Add(member); + UpdatedAt = DateTime.UtcNow; + + AddDomainEvent(new MemberInvitedDomainEvent(Id, userId, role.Name, invitedBy)); + + return member; + } + + /// + /// EN: Remove a member from the organization. + /// VI: Xóa thành viên khỏi tổ chức. + /// + public void RemoveMember(Guid userId, Guid removedBy) + { + var member = _members.FirstOrDefault(m => m.UserId == userId && m.Status.IsActive()); + if (member == null) + throw new OrganizationDomainException("Member not found"); + if (member.Role == MemberRole.Owner) + throw new OrganizationDomainException("Cannot remove owner. Transfer ownership first."); + + member.Remove(); + UpdatedAt = DateTime.UtcNow; + + AddDomainEvent(new MemberRemovedDomainEvent(Id, userId, removedBy)); + } + + /// + /// EN: Update a member's role. + /// VI: Cập nhật vai trò của thành viên. + /// + public void UpdateMemberRole(Guid userId, MemberRole newRole, Guid updatedBy) + { + var member = _members.FirstOrDefault(m => m.UserId == userId && m.Status.IsActive()); + if (member == null) + throw new OrganizationDomainException("Member not found"); + if (member.Role == MemberRole.Owner) + throw new OrganizationDomainException("Cannot change owner role. Transfer ownership first."); + if (newRole == MemberRole.Owner) + throw new OrganizationDomainException("Cannot assign owner role. Use TransferOwnership instead."); + + var oldRole = member.Role; + member.UpdateRole(newRole); + UpdatedAt = DateTime.UtcNow; + + AddDomainEvent(new MemberRoleChangedDomainEvent(Id, userId, oldRole.Name, newRole.Name, updatedBy)); + } + + /// + /// EN: Transfer ownership to another member. + /// VI: Chuyển quyền sở hữu cho thành viên khác. + /// + public void TransferOwnership(Guid newOwnerId, Guid currentOwnerId) + { + if (OwnerId != currentOwnerId) + throw new OrganizationDomainException("Only the current owner can transfer ownership"); + + var newOwnerMember = _members.FirstOrDefault(m => m.UserId == newOwnerId && m.Status.IsActive()); + if (newOwnerMember == null) + throw new OrganizationDomainException("New owner must be an existing member"); + + var currentOwnerMember = _members.First(m => m.UserId == currentOwnerId); + + // Demote current owner to Admin + currentOwnerMember.UpdateRole(MemberRole.Admin); + + // Promote new owner + newOwnerMember.UpdateRole(MemberRole.Owner); + OwnerId = newOwnerId; + UpdatedAt = DateTime.UtcNow; + + AddDomainEvent(new OwnershipTransferredDomainEvent(Id, currentOwnerId, newOwnerId)); + } + + /// + /// EN: Submit verification request. + /// VI: Gửi yêu cầu xác thực. + /// + public VerificationRequest SubmitVerification(VerificationType type, List documents, Guid submittedBy) + { + if (IsVerified) + throw new OrganizationDomainException("Organization is already verified"); + if (_verificationRequests.Any(v => v.Type == type && v.Status.CanBeReviewed())) + throw new OrganizationDomainException($"A {type.Name} verification is already pending"); + + var request = VerificationRequest.Create(Id, type, documents, submittedBy); + _verificationRequests.Add(request); + + _statusId = OrganizationStatus.PendingVerification.Id; + Status = OrganizationStatus.PendingVerification; + UpdatedAt = DateTime.UtcNow; + + AddDomainEvent(new VerificationSubmittedDomainEvent(Id, request.Id, type.Name, submittedBy)); + + return request; + } + + /// + /// EN: Mark organization as verified. + /// VI: Đánh dấu tổ chức đã được xác thực. + /// + public void MarkAsVerified(Guid reviewerId) + { + IsVerified = true; + _statusId = OrganizationStatus.Active.Id; + Status = OrganizationStatus.Active; + UpdatedAt = DateTime.UtcNow; + + AddDomainEvent(new OrganizationVerifiedDomainEvent(Id, reviewerId, DateTime.UtcNow)); + } + + /// + /// EN: Deactivate the organization. + /// VI: Vô hiệu hóa tổ chức. + /// + public void Deactivate() + { + if (Status == OrganizationStatus.Deleted) + throw new OrganizationDomainException("Organization is already deleted"); + + _statusId = OrganizationStatus.Inactive.Id; + Status = OrganizationStatus.Inactive; + UpdatedAt = DateTime.UtcNow; + } + + /// + /// EN: Reactivate the organization. + /// VI: Kích hoạt lại tổ chức. + /// + public void Reactivate() + { + if (Status != OrganizationStatus.Inactive) + throw new OrganizationDomainException("Only inactive organizations can be reactivated"); + + _statusId = OrganizationStatus.Active.Id; + Status = OrganizationStatus.Active; + UpdatedAt = DateTime.UtcNow; + } + + /// + /// EN: Soft delete the organization. + /// VI: Xóa mềm tổ chức. + /// + public void Delete() + { + _statusId = OrganizationStatus.Deleted.Id; + Status = OrganizationStatus.Deleted; + UpdatedAt = DateTime.UtcNow; + + AddDomainEvent(new OrganizationDeletedDomainEvent(Id)); + } +} diff --git a/services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/OrganizationAggregate/OrganizationStatus.cs b/services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/OrganizationAggregate/OrganizationStatus.cs new file mode 100644 index 00000000..6b189b39 --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/OrganizationAggregate/OrganizationStatus.cs @@ -0,0 +1,18 @@ +using OrganizationService.Domain.SeedWork; + +namespace OrganizationService.Domain.AggregatesModel.OrganizationAggregate; + +/// +/// EN: Status of an organization (Active, Inactive, PendingVerification, etc.). +/// VI: Trạng thái của tổ chức (Hoạt động, Không hoạt động, Chờ xác thực, v.v.). +/// +public class OrganizationStatus : Enumeration +{ + public static OrganizationStatus Active = new(1, nameof(Active)); + public static OrganizationStatus Inactive = new(2, nameof(Inactive)); + public static OrganizationStatus PendingVerification = new(3, nameof(PendingVerification)); + public static OrganizationStatus Suspended = new(4, nameof(Suspended)); + public static OrganizationStatus Deleted = new(5, nameof(Deleted)); + + public OrganizationStatus(int id, string name) : base(id, name) { } +} diff --git a/services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/OrganizationAggregate/OrganizationType.cs b/services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/OrganizationAggregate/OrganizationType.cs new file mode 100644 index 00000000..b52f890b --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/OrganizationAggregate/OrganizationType.cs @@ -0,0 +1,19 @@ +using OrganizationService.Domain.SeedWork; + +namespace OrganizationService.Domain.AggregatesModel.OrganizationAggregate; + +/// +/// EN: Type of organization (Company, Startup, NonProfit, etc.). +/// VI: Loại tổ chức (Công ty, Startup, Phi lợi nhuận, v.v.). +/// +public class OrganizationType : Enumeration +{ + public static OrganizationType Company = new(1, nameof(Company)); + public static OrganizationType Startup = new(2, nameof(Startup)); + public static OrganizationType NonProfit = new(3, nameof(NonProfit)); + public static OrganizationType Government = new(4, nameof(Government)); + public static OrganizationType Educational = new(5, nameof(Educational)); + public static OrganizationType Personal = new(6, nameof(Personal)); + + public OrganizationType(int id, string name) : base(id, name) { } +} diff --git a/services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/OrganizationAggregate/ValueObjects/Address.cs b/services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/OrganizationAggregate/ValueObjects/Address.cs new file mode 100644 index 00000000..89bc3fca --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/OrganizationAggregate/ValueObjects/Address.cs @@ -0,0 +1,61 @@ +using OrganizationService.Domain.SeedWork; + +namespace OrganizationService.Domain.AggregatesModel.OrganizationAggregate.ValueObjects; + +/// +/// EN: Physical address of an organization. +/// VI: Địa chỉ vật lý của tổ chức. +/// +public class Address : ValueObject +{ + public string Street { get; private set; } + public string City { get; private set; } + public string? State { get; private set; } + public string? PostalCode { get; private set; } + public string Country { get; private set; } + + protected Address() + { + Street = string.Empty; + City = string.Empty; + Country = string.Empty; + } + + public Address(string street, string city, string country, string? state = null, string? postalCode = null) + { + if (string.IsNullOrWhiteSpace(street)) + throw new ArgumentException("Street is required", nameof(street)); + if (string.IsNullOrWhiteSpace(city)) + throw new ArgumentException("City is required", nameof(city)); + if (string.IsNullOrWhiteSpace(country)) + throw new ArgumentException("Country is required", nameof(country)); + + Street = street.Trim(); + City = city.Trim(); + State = state?.Trim(); + PostalCode = postalCode?.Trim(); + Country = country.Trim(); + } + + protected override IEnumerable GetEqualityComponents() + { + yield return Street; + yield return City; + yield return State; + yield return PostalCode; + yield return Country; + } + + /// + /// EN: Get formatted address string. + /// VI: Lấy chuỗi địa chỉ đã được định dạng. + /// + public string GetFormattedAddress() + { + var parts = new List { Street, City }; + if (!string.IsNullOrEmpty(State)) parts.Add(State); + if (!string.IsNullOrEmpty(PostalCode)) parts.Add(PostalCode); + parts.Add(Country); + return string.Join(", ", parts); + } +} diff --git a/services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/OrganizationAggregate/ValueObjects/ContactInfo.cs b/services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/OrganizationAggregate/ValueObjects/ContactInfo.cs new file mode 100644 index 00000000..1260a079 --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/OrganizationAggregate/ValueObjects/ContactInfo.cs @@ -0,0 +1,57 @@ +using OrganizationService.Domain.SeedWork; + +namespace OrganizationService.Domain.AggregatesModel.OrganizationAggregate.ValueObjects; + +/// +/// EN: Contact information for an organization. +/// VI: Thông tin liên hệ của tổ chức. +/// +public class ContactInfo : ValueObject +{ + public string Email { get; private set; } + public string? Phone { get; private set; } + public string? Website { get; private set; } + public string? SocialMedia { get; private set; } + + protected ContactInfo() + { + Email = string.Empty; + } + + public ContactInfo(string email, string? phone = null, string? website = null, string? socialMedia = null) + { + if (string.IsNullOrWhiteSpace(email)) + throw new ArgumentException("Email is required", nameof(email)); + + Email = email.Trim().ToLowerInvariant(); + Phone = phone?.Trim(); + Website = website?.Trim(); + SocialMedia = socialMedia?.Trim(); + } + + protected override IEnumerable GetEqualityComponents() + { + yield return Email; + yield return Phone; + yield return Website; + yield return SocialMedia; + } + + /// + /// EN: Create a copy with updated email. + /// VI: Tạo bản sao với email được cập nhật. + /// + public ContactInfo WithEmail(string email) => new(email, Phone, Website, SocialMedia); + + /// + /// EN: Create a copy with updated phone. + /// VI: Tạo bản sao với số điện thoại được cập nhật. + /// + public ContactInfo WithPhone(string? phone) => new(Email, phone, Website, SocialMedia); + + /// + /// EN: Create a copy with updated website. + /// VI: Tạo bản sao với website được cập nhật. + /// + public ContactInfo WithWebsite(string? website) => new(Email, Phone, website, SocialMedia); +} diff --git a/services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/VerificationAggregate/VerificationDocument.cs b/services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/VerificationAggregate/VerificationDocument.cs new file mode 100644 index 00000000..ed6dcc88 --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/VerificationAggregate/VerificationDocument.cs @@ -0,0 +1,61 @@ +using OrganizationService.Domain.SeedWork; + +namespace OrganizationService.Domain.AggregatesModel.VerificationAggregate; + +/// +/// EN: Document attached to a verification request. +/// VI: Tài liệu đính kèm với yêu cầu xác thực. +/// +public class VerificationDocument : ValueObject +{ + public string FileName { get; private set; } + public string FileUrl { get; private set; } + public string ContentType { get; private set; } + public long FileSizeBytes { get; private set; } + public DateTime UploadedAt { get; private set; } + + protected VerificationDocument() + { + FileName = string.Empty; + FileUrl = string.Empty; + ContentType = string.Empty; + } + + public VerificationDocument(string fileName, string fileUrl, string contentType, long fileSizeBytes) + { + if (string.IsNullOrWhiteSpace(fileName)) + throw new ArgumentException("File name is required", nameof(fileName)); + if (string.IsNullOrWhiteSpace(fileUrl)) + throw new ArgumentException("File URL is required", nameof(fileUrl)); + if (string.IsNullOrWhiteSpace(contentType)) + throw new ArgumentException("Content type is required", nameof(contentType)); + if (fileSizeBytes <= 0) + throw new ArgumentException("File size must be positive", nameof(fileSizeBytes)); + + FileName = fileName.Trim(); + FileUrl = fileUrl.Trim(); + ContentType = contentType.Trim(); + FileSizeBytes = fileSizeBytes; + UploadedAt = DateTime.UtcNow; + } + + protected override IEnumerable GetEqualityComponents() + { + yield return FileName; + yield return FileUrl; + yield return ContentType; + yield return FileSizeBytes; + } + + /// + /// EN: Check if document is an image. + /// VI: Kiểm tra xem tài liệu có phải là hình ảnh không. + /// + public bool IsImage() => ContentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase); + + /// + /// EN: Check if document is a PDF. + /// VI: Kiểm tra xem tài liệu có phải là PDF không. + /// + public bool IsPdf() => ContentType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase); +} diff --git a/services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/VerificationAggregate/VerificationRequest.cs b/services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/VerificationAggregate/VerificationRequest.cs new file mode 100644 index 00000000..833f0599 --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/VerificationAggregate/VerificationRequest.cs @@ -0,0 +1,153 @@ +using OrganizationService.Domain.AggregatesModel.OrganizationAggregate; +using OrganizationService.Domain.Exceptions; +using OrganizationService.Domain.SeedWork; + +namespace OrganizationService.Domain.AggregatesModel.VerificationAggregate; + +/// +/// EN: Verification request for an organization. +/// VI: Yêu cầu xác thực cho tổ chức. +/// +public class VerificationRequest : Entity +{ + public Guid OrganizationId { get; private set; } + + private int _typeId; + public VerificationType Type { get; private set; } + + private int _statusId; + public VerificationStatus Status { get; private set; } + + private List _documents; + public IReadOnlyCollection Documents => _documents.AsReadOnly(); + + public string? Notes { get; private set; } + public string? RejectionReason { get; private set; } + + public Guid SubmittedBy { get; private set; } + public Guid? ReviewedBy { get; private set; } + + public DateTime SubmittedAt { get; private set; } + public DateTime? ReviewedAt { get; private set; } + public DateTime? ExpiresAt { get; private set; } + + // Navigation property + public Organization? Organization { get; private set; } + + // Constructor for EF Core + protected VerificationRequest() + { + Type = null!; + Status = null!; + _documents = new List(); + } + + private VerificationRequest( + Guid organizationId, + VerificationType type, + List documents, + Guid submittedBy) : this() + { + Id = Guid.NewGuid(); + OrganizationId = organizationId; + _typeId = type.Id; + Type = type; + _statusId = VerificationStatus.Pending.Id; + Status = VerificationStatus.Pending; + _documents = documents; + SubmittedBy = submittedBy; + SubmittedAt = DateTime.UtcNow; + } + + /// + /// EN: Create a new verification request. + /// VI: Tạo yêu cầu xác thực mới. + /// + public static VerificationRequest Create( + Guid organizationId, + VerificationType type, + List documents, + Guid submittedBy) + { + if (organizationId == Guid.Empty) + throw new OrganizationDomainException("Organization ID is required"); + if (documents == null || documents.Count == 0) + throw new OrganizationDomainException("At least one document is required"); + + return new VerificationRequest(organizationId, type, documents, submittedBy); + } + + /// + /// EN: Start review of the verification request. + /// VI: Bắt đầu xem xét yêu cầu xác thực. + /// + public void StartReview(Guid reviewerId) + { + if (!Status.CanBeReviewed()) + throw new OrganizationDomainException("Verification cannot be reviewed in current status"); + + _statusId = VerificationStatus.UnderReview.Id; + Status = VerificationStatus.UnderReview; + ReviewedBy = reviewerId; + } + + /// + /// EN: Approve the verification request. + /// VI: Phê duyệt yêu cầu xác thực. + /// + public void Approve(Guid reviewerId, string? notes = null) + { + if (!Status.CanBeReviewed()) + throw new OrganizationDomainException("Verification cannot be approved in current status"); + + _statusId = VerificationStatus.Approved.Id; + Status = VerificationStatus.Approved; + ReviewedBy = reviewerId; + ReviewedAt = DateTime.UtcNow; + Notes = notes; + ExpiresAt = DateTime.UtcNow.AddYears(1); // Verification valid for 1 year + } + + /// + /// EN: Reject the verification request. + /// VI: Từ chối yêu cầu xác thực. + /// + public void Reject(Guid reviewerId, string reason) + { + if (!Status.CanBeReviewed()) + throw new OrganizationDomainException("Verification cannot be rejected in current status"); + if (string.IsNullOrWhiteSpace(reason)) + throw new OrganizationDomainException("Rejection reason is required"); + + _statusId = VerificationStatus.Rejected.Id; + Status = VerificationStatus.Rejected; + ReviewedBy = reviewerId; + ReviewedAt = DateTime.UtcNow; + RejectionReason = reason; + } + + /// + /// EN: Mark verification as expired. + /// VI: Đánh dấu xác thực đã hết hạn. + /// + public void MarkExpired() + { + if (Status != VerificationStatus.Approved) + throw new OrganizationDomainException("Only approved verifications can expire"); + + _statusId = VerificationStatus.Expired.Id; + Status = VerificationStatus.Expired; + } + + /// + /// EN: Add additional document. + /// VI: Thêm tài liệu bổ sung. + /// + public void AddDocument(VerificationDocument document) + { + if (Status.IsFinal()) + throw new OrganizationDomainException("Cannot add documents to finalized verification"); + + _documents.Add(document); + } +} diff --git a/services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/VerificationAggregate/VerificationStatus.cs b/services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/VerificationAggregate/VerificationStatus.cs new file mode 100644 index 00000000..a81b0df7 --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/VerificationAggregate/VerificationStatus.cs @@ -0,0 +1,30 @@ +using OrganizationService.Domain.SeedWork; + +namespace OrganizationService.Domain.AggregatesModel.VerificationAggregate; + +/// +/// EN: Status of a verification request (Pending, UnderReview, Approved, Rejected). +/// VI: Trạng thái của yêu cầu xác thực (Chờ xử lý, Đang xem xét, Đã phê duyệt, Bị từ chối). +/// +public class VerificationStatus : Enumeration +{ + public static VerificationStatus Pending = new(1, nameof(Pending)); + public static VerificationStatus UnderReview = new(2, nameof(UnderReview)); + public static VerificationStatus Approved = new(3, nameof(Approved)); + public static VerificationStatus Rejected = new(4, nameof(Rejected)); + public static VerificationStatus Expired = new(5, nameof(Expired)); + + public VerificationStatus(int id, string name) : base(id, name) { } + + /// + /// EN: Check if verification is in a final state. + /// VI: Kiểm tra xem xác thực có ở trạng thái cuối cùng không. + /// + public bool IsFinal() => this == Approved || this == Rejected || this == Expired; + + /// + /// EN: Check if verification can be reviewed. + /// VI: Kiểm tra xem xác thực có thể được xem xét không. + /// + public bool CanBeReviewed() => this == Pending || this == UnderReview; +} diff --git a/services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/VerificationAggregate/VerificationType.cs b/services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/VerificationAggregate/VerificationType.cs new file mode 100644 index 00000000..10cd4e95 --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.Domain/AggregatesModel/VerificationAggregate/VerificationType.cs @@ -0,0 +1,18 @@ +using OrganizationService.Domain.SeedWork; + +namespace OrganizationService.Domain.AggregatesModel.VerificationAggregate; + +/// +/// EN: Type of verification request (Business License, Tax ID, Identity, etc.). +/// VI: Loại yêu cầu xác thực (Giấy phép kinh doanh, Mã số thuế, Danh tính, v.v.). +/// +public class VerificationType : Enumeration +{ + public static VerificationType BusinessLicense = new(1, nameof(BusinessLicense)); + public static VerificationType TaxRegistration = new(2, nameof(TaxRegistration)); + public static VerificationType IdentityVerification = new(3, nameof(IdentityVerification)); + public static VerificationType AddressVerification = new(4, nameof(AddressVerification)); + public static VerificationType BankAccountVerification = new(5, nameof(BankAccountVerification)); + + public VerificationType(int id, string name) : base(id, name) { } +} diff --git a/services/organization-service-net/src/OrganizationService.Domain/Events/OrganizationDomainEvents.cs b/services/organization-service-net/src/OrganizationService.Domain/Events/OrganizationDomainEvents.cs new file mode 100644 index 00000000..5553c99c --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.Domain/Events/OrganizationDomainEvents.cs @@ -0,0 +1,107 @@ +using MediatR; + +namespace OrganizationService.Domain.Events; + +/// +/// EN: Event raised when a new organization is created. +/// VI: Event được phát ra khi tổ chức mới được tạo. +/// +public record OrganizationCreatedDomainEvent( + Guid OrganizationId, + string Name, + string Slug, + Guid OwnerId +) : INotification; + +/// +/// EN: Event raised when an organization is verified. +/// VI: Event được phát ra khi tổ chức được xác thực. +/// +public record OrganizationVerifiedDomainEvent( + Guid OrganizationId, + Guid ReviewerId, + DateTime VerifiedAt +) : INotification; + +/// +/// EN: Event raised when an organization is deleted. +/// VI: Event được phát ra khi tổ chức bị xóa. +/// +public record OrganizationDeletedDomainEvent( + Guid OrganizationId +) : INotification; + +/// +/// EN: Event raised when a member is invited to an organization. +/// VI: Event được phát ra khi thành viên được mời vào tổ chức. +/// +public record MemberInvitedDomainEvent( + Guid OrganizationId, + Guid UserId, + string Role, + Guid InvitedBy +) : INotification; + +/// +/// EN: Event raised when a member is removed from an organization. +/// VI: Event được phát ra khi thành viên bị xóa khỏi tổ chức. +/// +public record MemberRemovedDomainEvent( + Guid OrganizationId, + Guid UserId, + Guid RemovedBy +) : INotification; + +/// +/// EN: Event raised when a member's role is changed. +/// VI: Event được phát ra khi vai trò của thành viên thay đổi. +/// +public record MemberRoleChangedDomainEvent( + Guid OrganizationId, + Guid UserId, + string OldRole, + string NewRole, + Guid ChangedBy +) : INotification; + +/// +/// EN: Event raised when organization ownership is transferred. +/// VI: Event được phát ra khi quyền sở hữu tổ chức được chuyển. +/// +public record OwnershipTransferredDomainEvent( + Guid OrganizationId, + Guid PreviousOwnerId, + Guid NewOwnerId +) : INotification; + +/// +/// EN: Event raised when a verification request is submitted. +/// VI: Event được phát ra khi yêu cầu xác thực được gửi. +/// +public record VerificationSubmittedDomainEvent( + Guid OrganizationId, + Guid VerificationRequestId, + string VerificationType, + Guid SubmittedBy +) : INotification; + +/// +/// EN: Event raised when a verification request is approved. +/// VI: Event được phát ra khi yêu cầu xác thực được phê duyệt. +/// +public record VerificationApprovedDomainEvent( + Guid OrganizationId, + Guid VerificationRequestId, + Guid ApprovedBy +) : INotification; + +/// +/// EN: Event raised when a verification request is rejected. +/// VI: Event được phát ra khi yêu cầu xác thực bị từ chối. +/// +public record VerificationRejectedDomainEvent( + Guid OrganizationId, + Guid VerificationRequestId, + string Reason, + Guid RejectedBy +) : INotification; diff --git a/services/organization-service-net/src/OrganizationService.Domain/Exceptions/DomainException.cs b/services/organization-service-net/src/OrganizationService.Domain/Exceptions/DomainException.cs new file mode 100644 index 00000000..43bde19b --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.Domain/Exceptions/DomainException.cs @@ -0,0 +1,21 @@ +namespace OrganizationService.Domain.Exceptions; + +/// +/// EN: Base exception for domain errors. +/// VI: Exception cơ sở cho các lỗi domain. +/// +public class DomainException : Exception +{ + public DomainException() + { + } + + public DomainException(string message) : base(message) + { + } + + public DomainException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/services/organization-service-net/src/OrganizationService.Domain/Exceptions/OrganizationDomainException.cs b/services/organization-service-net/src/OrganizationService.Domain/Exceptions/OrganizationDomainException.cs new file mode 100644 index 00000000..babd5f45 --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.Domain/Exceptions/OrganizationDomainException.cs @@ -0,0 +1,15 @@ +namespace OrganizationService.Domain.Exceptions; + +/// +/// EN: Base exception for organization domain errors. +/// VI: Exception cơ sở cho các lỗi domain tổ chức. +/// +public class OrganizationDomainException : DomainException +{ + public OrganizationDomainException() { } + + public OrganizationDomainException(string message) : base(message) { } + + public OrganizationDomainException(string message, Exception innerException) + : base(message, innerException) { } +} diff --git a/services/organization-service-net/src/OrganizationService.Domain/OrganizationService.Domain.csproj b/services/organization-service-net/src/OrganizationService.Domain/OrganizationService.Domain.csproj new file mode 100644 index 00000000..b55b4bf1 --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.Domain/OrganizationService.Domain.csproj @@ -0,0 +1,14 @@ + + + + OrganizationService.Domain + OrganizationService.Domain + Domain layer containing core business logic and entities + + + + + + + + diff --git a/services/organization-service-net/src/OrganizationService.Domain/SeedWork/Entity.cs b/services/organization-service-net/src/OrganizationService.Domain/SeedWork/Entity.cs new file mode 100644 index 00000000..4efc4b1f --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.Domain/SeedWork/Entity.cs @@ -0,0 +1,102 @@ +using MediatR; + +namespace OrganizationService.Domain.SeedWork; + +/// +/// EN: Base class for all domain entities. +/// VI: Lớp cơ sở cho tất cả các entity trong domain. +/// +public abstract class Entity +{ + private int? _requestedHashCode; + private Guid _id; + private List _domainEvents = new(); + + /// + /// EN: Unique identifier for the entity. + /// VI: Định danh duy nhất cho entity. + /// + public virtual Guid Id + { + get => _id; + protected set => _id = value; + } + + /// + /// EN: Domain events raised by this entity. + /// VI: Các domain event được phát ra bởi entity này. + /// + public IReadOnlyCollection DomainEvents => _domainEvents.AsReadOnly(); + + /// + /// EN: Add a domain event to be dispatched. + /// VI: Thêm một domain event để dispatch. + /// + public void AddDomainEvent(INotification eventItem) + { + _domainEvents.Add(eventItem); + } + + /// + /// EN: Remove a domain event. + /// VI: Xóa một domain event. + /// + public void RemoveDomainEvent(INotification eventItem) + { + _domainEvents.Remove(eventItem); + } + + /// + /// EN: Clear all domain events. + /// VI: Xóa tất cả domain events. + /// + public void ClearDomainEvents() + { + _domainEvents.Clear(); + } + + /// + /// EN: Check if entity is transient (not persisted yet). + /// VI: Kiểm tra xem entity có phải là transient (chưa lưu) không. + /// + public bool IsTransient() + { + return Id == default; + } + + public override bool Equals(object? obj) + { + if (obj is not Entity item) + return false; + + if (ReferenceEquals(this, item)) + return true; + + if (GetType() != item.GetType()) + return false; + + if (item.IsTransient() || IsTransient()) + return false; + + return item.Id == Id; + } + + public override int GetHashCode() + { + if (IsTransient()) + return base.GetHashCode(); + + _requestedHashCode ??= Id.GetHashCode() ^ 31; + return _requestedHashCode.Value; + } + + public static bool operator ==(Entity? left, Entity? right) + { + return left?.Equals(right) ?? right is null; + } + + public static bool operator !=(Entity? left, Entity? right) + { + return !(left == right); + } +} diff --git a/services/organization-service-net/src/OrganizationService.Domain/SeedWork/Enumeration.cs b/services/organization-service-net/src/OrganizationService.Domain/SeedWork/Enumeration.cs new file mode 100644 index 00000000..13962c53 --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.Domain/SeedWork/Enumeration.cs @@ -0,0 +1,95 @@ +using System.Reflection; + +namespace OrganizationService.Domain.SeedWork; + +/// +/// EN: Base class for enumeration classes (type-safe enum pattern). +/// VI: Lớp cơ sở cho các lớp enumeration (pattern enum an toàn kiểu). +/// +/// +/// EN: This provides a type-safe alternative to enums with additional functionality +/// like validation, parsing, and rich behavior. +/// VI: Cung cấp một thay thế an toàn kiểu cho enums với các chức năng bổ sung +/// như validation, parsing, và hành vi phong phú. +/// +public abstract class Enumeration : IComparable +{ + /// + /// EN: The name of the enumeration value. + /// VI: Tên của giá trị enumeration. + /// + public string Name { get; private set; } + + /// + /// EN: The unique identifier of the enumeration value. + /// VI: Định danh duy nhất của giá trị enumeration. + /// + public int Id { get; private set; } + + protected Enumeration(int id, string name) => (Id, Name) = (id, name); + + public override string ToString() => Name; + + /// + /// EN: Get all enumeration values of a given type. + /// VI: Lấy tất cả các giá trị enumeration của một kiểu cho trước. + /// + public static IEnumerable GetAll() where T : Enumeration => + typeof(T).GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly) + .Select(f => f.GetValue(null)) + .Cast(); + + public override bool Equals(object? obj) + { + if (obj is not Enumeration otherValue) + return false; + + var typeMatches = GetType() == obj.GetType(); + var valueMatches = Id.Equals(otherValue.Id); + + return typeMatches && valueMatches; + } + + public override int GetHashCode() => Id.GetHashCode(); + + /// + /// EN: Get absolute difference between two enumeration values. + /// VI: Lấy sự khác biệt tuyệt đối giữa hai giá trị enumeration. + /// + public static int AbsoluteDifference(Enumeration firstValue, Enumeration secondValue) + { + return Math.Abs(firstValue.Id - secondValue.Id); + } + + /// + /// EN: Parse an integer ID to the corresponding enumeration value. + /// VI: Parse một ID integer thành giá trị enumeration tương ứng. + /// + public static T FromValue(int value) where T : Enumeration + { + var matchingItem = Parse(value, "value", item => item.Id == value); + return matchingItem; + } + + /// + /// EN: Parse a display name to the corresponding enumeration value. + /// VI: Parse một tên hiển thị thành giá trị enumeration tương ứng. + /// + public static T FromDisplayName(string displayName) where T : Enumeration + { + var matchingItem = Parse(displayName, "display name", item => item.Name == displayName); + return matchingItem; + } + + private static T Parse(TValue value, string description, Func predicate) where T : Enumeration + { + var matchingItem = GetAll().FirstOrDefault(predicate); + + if (matchingItem is null) + throw new InvalidOperationException($"'{value}' is not a valid {description} in {typeof(T)}"); + + return matchingItem; + } + + public int CompareTo(object? other) => Id.CompareTo(((Enumeration)other!).Id); +} diff --git a/services/organization-service-net/src/OrganizationService.Domain/SeedWork/IAggregateRoot.cs b/services/organization-service-net/src/OrganizationService.Domain/SeedWork/IAggregateRoot.cs new file mode 100644 index 00000000..b3b46504 --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.Domain/SeedWork/IAggregateRoot.cs @@ -0,0 +1,15 @@ +namespace OrganizationService.Domain.SeedWork; + +/// +/// EN: Marker interface for aggregate roots. +/// VI: Interface đánh dấu cho aggregate roots. +/// +/// +/// EN: Aggregate roots are the entry points to aggregates and are the only objects +/// that outside code should hold references to. +/// VI: Aggregate roots là điểm vào của aggregates và là đối tượng duy nhất +/// mà code bên ngoài nên giữ tham chiếu đến. +/// +public interface IAggregateRoot +{ +} diff --git a/services/organization-service-net/src/OrganizationService.Domain/SeedWork/IRepository.cs b/services/organization-service-net/src/OrganizationService.Domain/SeedWork/IRepository.cs new file mode 100644 index 00000000..a725431a --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.Domain/SeedWork/IRepository.cs @@ -0,0 +1,15 @@ +namespace OrganizationService.Domain.SeedWork; + +/// +/// EN: Generic repository interface for aggregate roots. +/// VI: Interface repository generic cho aggregate roots. +/// +/// EN: The aggregate root type / VI: Kiểu aggregate root +public interface IRepository where T : IAggregateRoot +{ + /// + /// EN: The unit of work for this repository. + /// VI: Unit of work cho repository này. + /// + IUnitOfWork UnitOfWork { get; } +} diff --git a/services/organization-service-net/src/OrganizationService.Domain/SeedWork/IUnitOfWork.cs b/services/organization-service-net/src/OrganizationService.Domain/SeedWork/IUnitOfWork.cs new file mode 100644 index 00000000..0c0590f7 --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.Domain/SeedWork/IUnitOfWork.cs @@ -0,0 +1,30 @@ +namespace OrganizationService.Domain.SeedWork; + +/// +/// EN: Unit of Work pattern interface. +/// VI: Interface cho Unit of Work pattern. +/// +/// +/// EN: Maintains a list of objects affected by a business transaction +/// and coordinates the writing out of changes. +/// VI: Duy trì danh sách các đối tượng bị ảnh hưởng bởi một transaction nghiệp vụ +/// và điều phối việc ghi các thay đổi. +/// +public interface IUnitOfWork : IDisposable +{ + /// + /// EN: Save all changes made in this unit of work. + /// VI: Lưu tất cả các thay đổi được thực hiện trong unit of work này. + /// + /// EN: Cancellation token / VI: Token hủy + /// EN: Number of entities written / VI: Số entity đã ghi + Task SaveChangesAsync(CancellationToken cancellationToken = default); + + /// + /// EN: Save all changes and dispatch domain events. + /// VI: Lưu tất cả thay đổi và dispatch domain events. + /// + /// EN: Cancellation token / VI: Token hủy + /// EN: True if successful / VI: True nếu thành công + Task SaveEntitiesAsync(CancellationToken cancellationToken = default); +} diff --git a/services/organization-service-net/src/OrganizationService.Domain/SeedWork/ValueObject.cs b/services/organization-service-net/src/OrganizationService.Domain/SeedWork/ValueObject.cs new file mode 100644 index 00000000..f89dfdb8 --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.Domain/SeedWork/ValueObject.cs @@ -0,0 +1,53 @@ +namespace OrganizationService.Domain.SeedWork; + +/// +/// EN: Base class for Value Objects following DDD patterns. +/// VI: Lớp cơ sở cho Value Objects theo mẫu DDD. +/// +/// +/// EN: Value objects are immutable and compared by their values, not identity. +/// VI: Value objects là bất biến và được so sánh theo giá trị, không phải định danh. +/// +public abstract class ValueObject +{ + /// + /// EN: Get the atomic values that make up this value object. + /// VI: Lấy các giá trị nguyên tử tạo nên value object này. + /// + protected abstract IEnumerable GetEqualityComponents(); + + public override bool Equals(object? obj) + { + if (obj is null || obj.GetType() != GetType()) + return false; + + var other = (ValueObject)obj; + return GetEqualityComponents().SequenceEqual(other.GetEqualityComponents()); + } + + public override int GetHashCode() + { + return GetEqualityComponents() + .Select(x => x?.GetHashCode() ?? 0) + .Aggregate((x, y) => x ^ y); + } + + public static bool operator ==(ValueObject? left, ValueObject? right) + { + return left?.Equals(right) ?? right is null; + } + + public static bool operator !=(ValueObject? left, ValueObject? right) + { + return !(left == right); + } + + /// + /// EN: Create a copy of this value object with modifications. + /// VI: Tạo bản sao của value object này với các thay đổi. + /// + protected ValueObject GetCopy() + { + return (ValueObject)MemberwiseClone(); + } +} diff --git a/services/organization-service-net/src/OrganizationService.Infrastructure/DependencyInjection.cs b/services/organization-service-net/src/OrganizationService.Infrastructure/DependencyInjection.cs new file mode 100644 index 00000000..083248fc --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.Infrastructure/DependencyInjection.cs @@ -0,0 +1,58 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using OrganizationService.Domain.AggregatesModel.OrganizationAggregate; +using OrganizationService.Infrastructure.Idempotency; +using OrganizationService.Infrastructure.Repositories; + +namespace OrganizationService.Infrastructure; + +/// +/// EN: Dependency injection extensions for Infrastructure layer. +/// VI: Extensions dependency injection cho lớp Infrastructure. +/// +public static class DependencyInjection +{ + /// + /// EN: Add infrastructure services to the DI container. + /// VI: Thêm các services infrastructure vào DI container. + /// + public static IServiceCollection AddInfrastructure( + this IServiceCollection services, + IConfiguration configuration) + { + // EN: Add DbContext with PostgreSQL / VI: Thêm DbContext với PostgreSQL + services.AddDbContext(options => + { + var connectionString = configuration.GetConnectionString("DefaultConnection") + ?? configuration["DATABASE_URL"] + ?? throw new InvalidOperationException("Connection string not configured"); + + options.UseNpgsql(connectionString, npgsqlOptions => + { + npgsqlOptions.MigrationsAssembly(typeof(OrganizationServiceContext).Assembly.FullName); + npgsqlOptions.EnableRetryOnFailure( + maxRetryCount: 5, + maxRetryDelay: TimeSpan.FromSeconds(30), + errorCodesToAdd: null); + }); + + // EN: Enable sensitive data logging in development only + // VI: Chỉ bật sensitive data logging trong development + if (Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == "Development") + { + options.EnableSensitiveDataLogging(); + options.EnableDetailedErrors(); + } + }); + + // EN: Register repositories / VI: Đăng ký repositories + services.AddScoped(); + + // EN: Register idempotency services / VI: Đăng ký idempotency services + services.AddScoped(); + + return services; + } +} + diff --git a/services/organization-service-net/src/OrganizationService.Infrastructure/EntityConfigurations/MemberRoleEntityTypeConfiguration.cs b/services/organization-service-net/src/OrganizationService.Infrastructure/EntityConfigurations/MemberRoleEntityTypeConfiguration.cs new file mode 100644 index 00000000..d04285de --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.Infrastructure/EntityConfigurations/MemberRoleEntityTypeConfiguration.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using OrganizationService.Domain.AggregatesModel.MemberAggregate; + +namespace OrganizationService.Infrastructure.EntityConfigurations; + +/// +/// EN: Entity configuration for MemberRole enumeration. +/// VI: Cấu hình entity cho MemberRole enumeration. +/// +public class MemberRoleEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("member_roles"); + + builder.HasKey(r => r.Id); + + builder.Property(r => r.Id) + .HasColumnName("id") + .ValueGeneratedNever() + .IsRequired(); + + builder.Property(r => r.Name) + .HasColumnName("name") + .HasMaxLength(100) + .IsRequired(); + + // EN: Seed default member roles + // VI: Seed các vai trò thành viên mặc định + builder.HasData( + MemberRole.Owner, + MemberRole.Admin, + MemberRole.Manager, + MemberRole.Member, + MemberRole.Viewer + ); + } +} diff --git a/services/organization-service-net/src/OrganizationService.Infrastructure/EntityConfigurations/MemberStatusEntityTypeConfiguration.cs b/services/organization-service-net/src/OrganizationService.Infrastructure/EntityConfigurations/MemberStatusEntityTypeConfiguration.cs new file mode 100644 index 00000000..4f3ac178 --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.Infrastructure/EntityConfigurations/MemberStatusEntityTypeConfiguration.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using OrganizationService.Domain.AggregatesModel.MemberAggregate; + +namespace OrganizationService.Infrastructure.EntityConfigurations; + +/// +/// EN: Entity configuration for MemberStatus enumeration. +/// VI: Cấu hình entity cho MemberStatus enumeration. +/// +public class MemberStatusEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("member_statuses"); + + builder.HasKey(s => s.Id); + + builder.Property(s => s.Id) + .HasColumnName("id") + .ValueGeneratedNever() + .IsRequired(); + + builder.Property(s => s.Name) + .HasColumnName("name") + .HasMaxLength(100) + .IsRequired(); + + // EN: Seed default member statuses + // VI: Seed các trạng thái thành viên mặc định + builder.HasData( + MemberStatus.Invited, + MemberStatus.Active, + MemberStatus.Suspended, + MemberStatus.Left, + MemberStatus.Removed + ); + } +} diff --git a/services/organization-service-net/src/OrganizationService.Infrastructure/EntityConfigurations/OrganizationEntityTypeConfiguration.cs b/services/organization-service-net/src/OrganizationService.Infrastructure/EntityConfigurations/OrganizationEntityTypeConfiguration.cs new file mode 100644 index 00000000..8543f641 --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.Infrastructure/EntityConfigurations/OrganizationEntityTypeConfiguration.cs @@ -0,0 +1,144 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using OrganizationService.Domain.AggregatesModel.OrganizationAggregate; + +namespace OrganizationService.Infrastructure.EntityConfigurations; + +/// +/// EN: Entity configuration for Organization aggregate. +/// VI: Cấu hình entity cho Organization aggregate. +/// +public class OrganizationEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("organizations"); + + builder.HasKey(o => o.Id); + + builder.Property(o => o.Id) + .HasColumnName("id"); + + builder.Property(o => o.Name) + .HasColumnName("name") + .HasMaxLength(200) + .IsRequired(); + + builder.Property(o => o.Slug) + .HasColumnName("slug") + .HasMaxLength(200) + .IsRequired(); + + builder.HasIndex(o => o.Slug) + .IsUnique(); + + builder.Property(o => o.Description) + .HasColumnName("description") + .HasMaxLength(2000); + + builder.Property(o => o.LogoUrl) + .HasColumnName("logo_url") + .HasMaxLength(500); + + builder.Property(o => o.OwnerId) + .HasColumnName("owner_id") + .IsRequired(); + + builder.Property(o => o.IsVerified) + .HasColumnName("is_verified") + .HasDefaultValue(false); + + builder.Property(o => o.CreatedAt) + .HasColumnName("created_at") + .IsRequired(); + + builder.Property(o => o.UpdatedAt) + .HasColumnName("updated_at"); + + // EN: Configure OrganizationType as enumeration + // VI: Cấu hình OrganizationType như enumeration + builder.Property("_typeId") + .HasColumnName("type_id") + .IsRequired(); + + builder.HasOne(o => o.Type) + .WithMany() + .HasForeignKey("_typeId") + .IsRequired(); + + // EN: Configure OrganizationStatus as enumeration + // VI: Cấu hình OrganizationStatus như enumeration + builder.Property("_statusId") + .HasColumnName("status_id") + .IsRequired(); + + builder.HasOne(o => o.Status) + .WithMany() + .HasForeignKey("_statusId") + .IsRequired(); + + // EN: Configure ContactInfo as owned entity + // VI: Cấu hình ContactInfo như owned entity + builder.OwnsOne(o => o.ContactInfo, ci => + { + ci.Property(c => c.Email) + .HasColumnName("contact_email") + .HasMaxLength(255); + + ci.Property(c => c.Phone) + .HasColumnName("contact_phone") + .HasMaxLength(50); + + ci.Property(c => c.Website) + .HasColumnName("contact_website") + .HasMaxLength(500); + + ci.Property(c => c.SocialMedia) + .HasColumnName("contact_social_media") + .HasMaxLength(500); + }); + + // EN: Configure Address as owned entity + // VI: Cấu hình Address như owned entity + builder.OwnsOne(o => o.Address, a => + { + a.Property(x => x.Street) + .HasColumnName("address_street") + .HasMaxLength(500); + + a.Property(x => x.City) + .HasColumnName("address_city") + .HasMaxLength(100); + + a.Property(x => x.State) + .HasColumnName("address_state") + .HasMaxLength(100); + + a.Property(x => x.Country) + .HasColumnName("address_country") + .HasMaxLength(100); + + a.Property(x => x.PostalCode) + .HasColumnName("address_postal_code") + .HasMaxLength(20); + }); + + // EN: Configure navigation to members + // VI: Cấu hình navigation tới members + builder.HasMany(o => o.Members) + .WithOne(m => m.Organization) + .HasForeignKey(m => m.OrganizationId) + .OnDelete(DeleteBehavior.Cascade); + + // EN: Configure navigation to verification requests + // VI: Cấu hình navigation tới verification requests + builder.HasMany(o => o.VerificationRequests) + .WithOne(v => v.Organization) + .HasForeignKey(v => v.OrganizationId) + .OnDelete(DeleteBehavior.Cascade); + + // EN: Ignore domain events + // VI: Bỏ qua domain events + builder.Ignore(o => o.DomainEvents); + } +} diff --git a/services/organization-service-net/src/OrganizationService.Infrastructure/EntityConfigurations/OrganizationMemberEntityTypeConfiguration.cs b/services/organization-service-net/src/OrganizationService.Infrastructure/EntityConfigurations/OrganizationMemberEntityTypeConfiguration.cs new file mode 100644 index 00000000..77286dd8 --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.Infrastructure/EntityConfigurations/OrganizationMemberEntityTypeConfiguration.cs @@ -0,0 +1,72 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using OrganizationService.Domain.AggregatesModel.MemberAggregate; + +namespace OrganizationService.Infrastructure.EntityConfigurations; + +/// +/// EN: Entity configuration for OrganizationMember entity. +/// VI: Cấu hình entity cho OrganizationMember entity. +/// +public class OrganizationMemberEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("organization_members"); + + builder.HasKey(m => m.Id); + + builder.Property(m => m.Id) + .HasColumnName("id"); + + builder.Property(m => m.OrganizationId) + .HasColumnName("organization_id") + .IsRequired(); + + builder.Property(m => m.UserId) + .HasColumnName("user_id") + .IsRequired(); + + builder.Property(m => m.JoinedAt) + .HasColumnName("joined_at"); + + builder.Property(m => m.InvitedAt) + .HasColumnName("invited_at") + .IsRequired(); + + builder.Property(m => m.InvitedBy) + .HasColumnName("invited_by") + .IsRequired(); + + // EN: Configure MemberRole as enumeration + // VI: Cấu hình MemberRole như enumeration + builder.Property("_roleId") + .HasColumnName("role_id") + .IsRequired(); + + builder.HasOne(m => m.Role) + .WithMany() + .HasForeignKey("_roleId") + .IsRequired(); + + // EN: Configure MemberStatus as enumeration + // VI: Cấu hình MemberStatus như enumeration + builder.Property("_statusId") + .HasColumnName("status_id") + .IsRequired(); + + builder.HasOne(m => m.Status) + .WithMany() + .HasForeignKey("_statusId") + .IsRequired(); + + // EN: Create unique index for user per organization + // VI: Tạo unique index cho user trong mỗi organization + builder.HasIndex(m => new { m.OrganizationId, m.UserId }) + .IsUnique(); + + // EN: Ignore domain events + // VI: Bỏ qua domain events + builder.Ignore(m => m.DomainEvents); + } +} diff --git a/services/organization-service-net/src/OrganizationService.Infrastructure/EntityConfigurations/OrganizationStatusEntityTypeConfiguration.cs b/services/organization-service-net/src/OrganizationService.Infrastructure/EntityConfigurations/OrganizationStatusEntityTypeConfiguration.cs new file mode 100644 index 00000000..59d791b6 --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.Infrastructure/EntityConfigurations/OrganizationStatusEntityTypeConfiguration.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using OrganizationService.Domain.AggregatesModel.OrganizationAggregate; + +namespace OrganizationService.Infrastructure.EntityConfigurations; + +/// +/// EN: Entity configuration for OrganizationStatus enumeration. +/// VI: Cấu hình entity cho OrganizationStatus enumeration. +/// +public class OrganizationStatusEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("organization_statuses"); + + builder.HasKey(o => o.Id); + + builder.Property(o => o.Id) + .HasColumnName("id") + .ValueGeneratedNever() + .IsRequired(); + + builder.Property(o => o.Name) + .HasColumnName("name") + .HasMaxLength(100) + .IsRequired(); + + // EN: Seed default organization statuses + // VI: Seed các trạng thái tổ chức mặc định + builder.HasData( + OrganizationStatus.Active, + OrganizationStatus.Inactive, + OrganizationStatus.PendingVerification, + OrganizationStatus.Suspended, + OrganizationStatus.Deleted + ); + } +} diff --git a/services/organization-service-net/src/OrganizationService.Infrastructure/EntityConfigurations/OrganizationTypeEntityTypeConfiguration.cs b/services/organization-service-net/src/OrganizationService.Infrastructure/EntityConfigurations/OrganizationTypeEntityTypeConfiguration.cs new file mode 100644 index 00000000..6f28afc0 --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.Infrastructure/EntityConfigurations/OrganizationTypeEntityTypeConfiguration.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using OrganizationService.Domain.AggregatesModel.OrganizationAggregate; + +namespace OrganizationService.Infrastructure.EntityConfigurations; + +/// +/// EN: Entity configuration for OrganizationType enumeration. +/// VI: Cấu hình entity cho OrganizationType enumeration. +/// +public class OrganizationTypeEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("organization_types"); + + builder.HasKey(o => o.Id); + + builder.Property(o => o.Id) + .HasColumnName("id") + .ValueGeneratedNever() + .IsRequired(); + + builder.Property(o => o.Name) + .HasColumnName("name") + .HasMaxLength(100) + .IsRequired(); + + // EN: Seed default organization types + // VI: Seed các loại tổ chức mặc định + builder.HasData( + OrganizationType.Company, + OrganizationType.Startup, + OrganizationType.NonProfit, + OrganizationType.Government, + OrganizationType.Educational, + OrganizationType.Personal + ); + } +} diff --git a/services/organization-service-net/src/OrganizationService.Infrastructure/EntityConfigurations/VerificationRequestEntityTypeConfiguration.cs b/services/organization-service-net/src/OrganizationService.Infrastructure/EntityConfigurations/VerificationRequestEntityTypeConfiguration.cs new file mode 100644 index 00000000..8faa7c3a --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.Infrastructure/EntityConfigurations/VerificationRequestEntityTypeConfiguration.cs @@ -0,0 +1,87 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using OrganizationService.Domain.AggregatesModel.VerificationAggregate; + +namespace OrganizationService.Infrastructure.EntityConfigurations; + +/// +/// EN: Entity configuration for VerificationRequest entity. +/// VI: Cấu hình entity cho VerificationRequest entity. +/// +public class VerificationRequestEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("verification_requests"); + + builder.HasKey(v => v.Id); + + builder.Property(v => v.Id) + .HasColumnName("id"); + + builder.Property(v => v.OrganizationId) + .HasColumnName("organization_id") + .IsRequired(); + + builder.Property(v => v.Notes) + .HasColumnName("notes") + .HasMaxLength(2000); + + builder.Property(v => v.RejectionReason) + .HasColumnName("rejection_reason") + .HasMaxLength(2000); + + builder.Property(v => v.SubmittedBy) + .HasColumnName("submitted_by") + .IsRequired(); + + builder.Property(v => v.ReviewedBy) + .HasColumnName("reviewed_by"); + + builder.Property(v => v.SubmittedAt) + .HasColumnName("submitted_at") + .IsRequired(); + + builder.Property(v => v.ReviewedAt) + .HasColumnName("reviewed_at"); + + builder.Property(v => v.ExpiresAt) + .HasColumnName("expires_at"); + + // EN: Configure VerificationType as enumeration + // VI: Cấu hình VerificationType như enumeration + builder.Property("_typeId") + .HasColumnName("type_id") + .IsRequired(); + + builder.HasOne(v => v.Type) + .WithMany() + .HasForeignKey("_typeId") + .IsRequired(); + + // EN: Configure VerificationStatus as enumeration + // VI: Cấu hình VerificationStatus như enumeration + builder.Property("_statusId") + .HasColumnName("status_id") + .IsRequired(); + + builder.HasOne(v => v.Status) + .WithMany() + .HasForeignKey("_statusId") + .IsRequired(); + + // EN: Configure Documents as JSON column + // VI: Cấu hình Documents như JSON column + builder.OwnsMany(v => v.Documents, d => + { + d.ToJson("documents"); + d.Property(x => x.FileName).HasMaxLength(500); + d.Property(x => x.FileUrl).HasMaxLength(1000); + d.Property(x => x.ContentType).HasMaxLength(100); + }); + + // EN: Ignore domain events + // VI: Bỏ qua domain events + builder.Ignore(v => v.DomainEvents); + } +} diff --git a/services/organization-service-net/src/OrganizationService.Infrastructure/EntityConfigurations/VerificationStatusEntityTypeConfiguration.cs b/services/organization-service-net/src/OrganizationService.Infrastructure/EntityConfigurations/VerificationStatusEntityTypeConfiguration.cs new file mode 100644 index 00000000..e3e5eb8a --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.Infrastructure/EntityConfigurations/VerificationStatusEntityTypeConfiguration.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using OrganizationService.Domain.AggregatesModel.VerificationAggregate; + +namespace OrganizationService.Infrastructure.EntityConfigurations; + +/// +/// EN: Entity configuration for VerificationStatus enumeration. +/// VI: Cấu hình entity cho VerificationStatus enumeration. +/// +public class VerificationStatusEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("verification_statuses"); + + builder.HasKey(s => s.Id); + + builder.Property(s => s.Id) + .HasColumnName("id") + .ValueGeneratedNever() + .IsRequired(); + + builder.Property(s => s.Name) + .HasColumnName("name") + .HasMaxLength(100) + .IsRequired(); + + // EN: Seed default verification statuses + // VI: Seed các trạng thái xác thực mặc định + builder.HasData( + VerificationStatus.Pending, + VerificationStatus.UnderReview, + VerificationStatus.Approved, + VerificationStatus.Rejected, + VerificationStatus.Expired + ); + } +} diff --git a/services/organization-service-net/src/OrganizationService.Infrastructure/EntityConfigurations/VerificationTypeEntityTypeConfiguration.cs b/services/organization-service-net/src/OrganizationService.Infrastructure/EntityConfigurations/VerificationTypeEntityTypeConfiguration.cs new file mode 100644 index 00000000..756f9205 --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.Infrastructure/EntityConfigurations/VerificationTypeEntityTypeConfiguration.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using OrganizationService.Domain.AggregatesModel.VerificationAggregate; + +namespace OrganizationService.Infrastructure.EntityConfigurations; + +/// +/// EN: Entity configuration for VerificationType enumeration. +/// VI: Cấu hình entity cho VerificationType enumeration. +/// +public class VerificationTypeEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("verification_types"); + + builder.HasKey(t => t.Id); + + builder.Property(t => t.Id) + .HasColumnName("id") + .ValueGeneratedNever() + .IsRequired(); + + builder.Property(t => t.Name) + .HasColumnName("name") + .HasMaxLength(100) + .IsRequired(); + + // EN: Seed default verification types + // VI: Seed các loại xác thực mặc định + builder.HasData( + VerificationType.BusinessLicense, + VerificationType.TaxRegistration, + VerificationType.IdentityVerification, + VerificationType.AddressVerification, + VerificationType.BankAccountVerification + ); + } +} diff --git a/services/organization-service-net/src/OrganizationService.Infrastructure/Idempotency/ClientRequest.cs b/services/organization-service-net/src/OrganizationService.Infrastructure/Idempotency/ClientRequest.cs new file mode 100644 index 00000000..7a7a675c --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.Infrastructure/Idempotency/ClientRequest.cs @@ -0,0 +1,26 @@ +namespace OrganizationService.Infrastructure.Idempotency; + +/// +/// EN: Entity for tracking client requests to ensure idempotency. +/// VI: Entity để theo dõi các requests từ client đảm bảo idempotency. +/// +public class ClientRequest +{ + /// + /// EN: Unique request identifier. + /// VI: Định danh request duy nhất. + /// + public Guid Id { get; set; } + + /// + /// EN: Name of the command/request type. + /// VI: Tên của loại command/request. + /// + public string Name { get; set; } = null!; + + /// + /// EN: Timestamp when the request was received. + /// VI: Thời điểm request được nhận. + /// + public DateTime Time { get; set; } +} diff --git a/services/organization-service-net/src/OrganizationService.Infrastructure/Idempotency/IRequestManager.cs b/services/organization-service-net/src/OrganizationService.Infrastructure/Idempotency/IRequestManager.cs new file mode 100644 index 00000000..a9ed5fd6 --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.Infrastructure/Idempotency/IRequestManager.cs @@ -0,0 +1,24 @@ +namespace OrganizationService.Infrastructure.Idempotency; + +/// +/// EN: Interface for managing client request idempotency. +/// VI: Interface để quản lý idempotency của client requests. +/// +public interface IRequestManager +{ + /// + /// EN: Check if a request with the given ID exists. + /// VI: Kiểm tra xem request với ID cho trước có tồn tại không. + /// + /// EN: Request ID / VI: ID của request + /// EN: True if exists / VI: True nếu tồn tại + Task ExistAsync(Guid id); + + /// + /// EN: Create a new request record for tracking. + /// VI: Tạo bản ghi request mới để theo dõi. + /// + /// EN: Command type / VI: Loại command + /// EN: Request ID / VI: ID của request + Task CreateRequestForCommandAsync(Guid id); +} diff --git a/services/organization-service-net/src/OrganizationService.Infrastructure/Idempotency/RequestManager.cs b/services/organization-service-net/src/OrganizationService.Infrastructure/Idempotency/RequestManager.cs new file mode 100644 index 00000000..1729dda2 --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.Infrastructure/Idempotency/RequestManager.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore; + +namespace OrganizationService.Infrastructure.Idempotency; + +/// +/// EN: Implementation of request manager for idempotency. +/// VI: Triển khai request manager cho idempotency. +/// +public class RequestManager : IRequestManager +{ + private readonly OrganizationServiceContext _context; + + public RequestManager(OrganizationServiceContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + /// + public async Task ExistAsync(Guid id) + { + var request = await _context + .FindAsync(id); + + return request != null; + } + + /// + public async Task CreateRequestForCommandAsync(Guid id) + { + var exists = await ExistAsync(id); + + var request = exists + ? throw new InvalidOperationException($"Request with {id} already exists") + : new ClientRequest + { + Id = id, + Name = typeof(T).Name, + Time = DateTime.UtcNow + }; + + _context.Add(request); + + await _context.SaveChangesAsync(); + } +} diff --git a/services/organization-service-net/src/OrganizationService.Infrastructure/OrganizationService.Infrastructure.csproj b/services/organization-service-net/src/OrganizationService.Infrastructure/OrganizationService.Infrastructure.csproj new file mode 100644 index 00000000..96615e3a --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.Infrastructure/OrganizationService.Infrastructure.csproj @@ -0,0 +1,36 @@ + + + + OrganizationService.Infrastructure + OrganizationService.Infrastructure + Infrastructure layer for data access and external services + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + + + diff --git a/services/organization-service-net/src/OrganizationService.Infrastructure/OrganizationServiceContext.cs b/services/organization-service-net/src/OrganizationService.Infrastructure/OrganizationServiceContext.cs new file mode 100644 index 00000000..42ee6dca --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.Infrastructure/OrganizationServiceContext.cs @@ -0,0 +1,181 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; +using OrganizationService.Domain.AggregatesModel.MemberAggregate; +using OrganizationService.Domain.AggregatesModel.OrganizationAggregate; +using OrganizationService.Domain.AggregatesModel.VerificationAggregate; +using OrganizationService.Domain.SeedWork; +using OrganizationService.Infrastructure.EntityConfigurations; + +namespace OrganizationService.Infrastructure; + +/// +/// EN: EF Core DbContext for OrganizationService. +/// VI: EF Core DbContext cho OrganizationService. +/// +public class OrganizationServiceContext : DbContext, IUnitOfWork +{ + private readonly IMediator _mediator; + private IDbContextTransaction? _currentTransaction; + + /// + /// EN: Organizations table. + /// VI: Bảng Organizations. + /// + public DbSet Organizations => Set(); + + /// + /// EN: Organization Members table. + /// VI: Bảng Organization Members. + /// + public DbSet OrganizationMembers => Set(); + + /// + /// EN: Verification Requests table. + /// VI: Bảng Verification Requests. + /// + public DbSet VerificationRequests => Set(); + + /// + /// EN: Read-only access to current transaction. + /// VI: Truy cập chỉ đọc đến transaction hiện tại. + /// + public IDbContextTransaction? CurrentTransaction => _currentTransaction; + + /// + /// EN: Check if there is an active transaction. + /// VI: Kiểm tra xem có transaction đang hoạt động không. + /// + public bool HasActiveTransaction => _currentTransaction != null; + + public OrganizationServiceContext(DbContextOptions options) : base(options) + { + _mediator = null!; + } + + public OrganizationServiceContext(DbContextOptions options, IMediator mediator) : base(options) + { + _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); + + System.Diagnostics.Debug.WriteLine("OrganizationServiceContext::ctor - " + GetHashCode()); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // EN: Apply entity configurations + // VI: Áp dụng các cấu hình entity + modelBuilder.ApplyConfiguration(new OrganizationEntityTypeConfiguration()); + modelBuilder.ApplyConfiguration(new OrganizationTypeEntityTypeConfiguration()); + modelBuilder.ApplyConfiguration(new OrganizationStatusEntityTypeConfiguration()); + modelBuilder.ApplyConfiguration(new OrganizationMemberEntityTypeConfiguration()); + modelBuilder.ApplyConfiguration(new MemberRoleEntityTypeConfiguration()); + modelBuilder.ApplyConfiguration(new MemberStatusEntityTypeConfiguration()); + modelBuilder.ApplyConfiguration(new VerificationRequestEntityTypeConfiguration()); + modelBuilder.ApplyConfiguration(new VerificationTypeEntityTypeConfiguration()); + modelBuilder.ApplyConfiguration(new VerificationStatusEntityTypeConfiguration()); + } + + /// + /// EN: Save entities and dispatch domain events. + /// VI: Lưu entities và dispatch domain events. + /// + public async Task SaveEntitiesAsync(CancellationToken cancellationToken = default) + { + // EN: Dispatch domain events before saving (side effects) + // VI: Dispatch domain events trước khi lưu (side effects) + await DispatchDomainEventsAsync(); + + // EN: Save changes to database + // VI: Lưu thay đổi vào database + await base.SaveChangesAsync(cancellationToken); + + return true; + } + + /// + /// EN: Begin a new transaction if none is active. + /// VI: Bắt đầu một transaction mới nếu không có transaction nào đang hoạt động. + /// + public async Task BeginTransactionAsync() + { + if (_currentTransaction != null) return null; + + _currentTransaction = await Database.BeginTransactionAsync(System.Data.IsolationLevel.ReadCommitted); + + return _currentTransaction; + } + + /// + /// EN: Commit the current transaction. + /// VI: Commit transaction hiện tại. + /// + public async Task CommitTransactionAsync(IDbContextTransaction transaction) + { + ArgumentNullException.ThrowIfNull(transaction); + + if (transaction != _currentTransaction) + throw new InvalidOperationException($"Transaction {transaction.TransactionId} is not current"); + + try + { + await SaveChangesAsync(); + await transaction.CommitAsync(); + } + catch + { + RollbackTransaction(); + throw; + } + finally + { + if (_currentTransaction != null) + { + _currentTransaction.Dispose(); + _currentTransaction = null; + } + } + } + + /// + /// EN: Rollback the current transaction. + /// VI: Rollback transaction hiện tại. + /// + public void RollbackTransaction() + { + try + { + _currentTransaction?.Rollback(); + } + finally + { + if (_currentTransaction != null) + { + _currentTransaction.Dispose(); + _currentTransaction = null; + } + } + } + + /// + /// EN: Dispatch all domain events from tracked entities. + /// VI: Dispatch tất cả domain events từ các entities đang được track. + /// + private async Task DispatchDomainEventsAsync() + { + var domainEntities = ChangeTracker + .Entries() + .Where(x => x.Entity.DomainEvents.Any()) + .ToList(); + + var domainEvents = domainEntities + .SelectMany(x => x.Entity.DomainEvents) + .ToList(); + + domainEntities.ForEach(entity => entity.Entity.ClearDomainEvents()); + + foreach (var domainEvent in domainEvents) + { + await _mediator.Publish(domainEvent); + } + } +} diff --git a/services/organization-service-net/src/OrganizationService.Infrastructure/Repositories/OrganizationRepository.cs b/services/organization-service-net/src/OrganizationService.Infrastructure/Repositories/OrganizationRepository.cs new file mode 100644 index 00000000..6eb42600 --- /dev/null +++ b/services/organization-service-net/src/OrganizationService.Infrastructure/Repositories/OrganizationRepository.cs @@ -0,0 +1,76 @@ +using Microsoft.EntityFrameworkCore; +using OrganizationService.Domain.AggregatesModel.OrganizationAggregate; +using OrganizationService.Domain.SeedWork; + +namespace OrganizationService.Infrastructure.Repositories; + +/// +/// EN: Repository implementation for Organization aggregate. +/// VI: Repository implementation cho Organization aggregate. +/// +public class OrganizationRepository : IOrganizationRepository +{ + private readonly OrganizationServiceContext _context; + + public IUnitOfWork UnitOfWork => _context; + + public OrganizationRepository(OrganizationServiceContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + /// + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _context.Organizations + .Include(o => o.Type) + .Include(o => o.Status) + .Include(o => o.Members) + .ThenInclude(m => m.Role) + .Include(o => o.Members) + .ThenInclude(m => m.Status) + .Include(o => o.VerificationRequests) + .ThenInclude(v => v.Type) + .Include(o => o.VerificationRequests) + .ThenInclude(v => v.Status) + .FirstOrDefaultAsync(o => o.Id == id, cancellationToken); + } + + /// + public async Task GetBySlugAsync(string slug, CancellationToken cancellationToken = default) + { + return await _context.Organizations + .Include(o => o.Type) + .Include(o => o.Status) + .FirstOrDefaultAsync(o => o.Slug == slug, cancellationToken); + } + + /// + public async Task> GetByOwnerIdAsync(Guid ownerId, CancellationToken cancellationToken = default) + { + return await _context.Organizations + .Include(o => o.Type) + .Include(o => o.Status) + .Where(o => o.OwnerId == ownerId) + .ToListAsync(cancellationToken); + } + + /// + public async Task SlugExistsAsync(string slug, CancellationToken cancellationToken = default) + { + return await _context.Organizations + .AnyAsync(o => o.Slug == slug, cancellationToken); + } + + /// + public Organization Add(Organization organization) + { + return _context.Organizations.Add(organization).Entity; + } + + /// + public void Update(Organization organization) + { + _context.Entry(organization).State = EntityState.Modified; + } +} diff --git a/services/organization-service-net/tests/OrganizationService.FunctionalTests/Controllers/OrganizationsControllerTests.cs b/services/organization-service-net/tests/OrganizationService.FunctionalTests/Controllers/OrganizationsControllerTests.cs new file mode 100644 index 00000000..2087c485 --- /dev/null +++ b/services/organization-service-net/tests/OrganizationService.FunctionalTests/Controllers/OrganizationsControllerTests.cs @@ -0,0 +1,111 @@ +using System.Net; +using System.Net.Http.Json; +using OrganizationService.API.Application.Commands; +using OrganizationService.API.Application.Queries; +using Xunit; + +namespace OrganizationService.FunctionalTests.Controllers; + +/// +/// EN: Functional tests for Organizations API. +/// VI: Functional tests cho Organizations API. +/// +public class OrganizationsControllerTests : IClassFixture +{ + private readonly HttpClient _client; + + public OrganizationsControllerTests(CustomWebApplicationFactory factory) + { + _client = factory.CreateClient(); + } + + [Fact] + public async Task CreateOrganization_WithValidData_ShouldReturnCreated() + { + // Arrange + var command = new CreateOrganizationCommand( + Name: "Test Organization", + Slug: "test-org-" + Guid.NewGuid().ToString("N")[..8], + OwnerId: Guid.NewGuid(), + TypeId: 1, + Email: "test@example.com", + Phone: "123456789", + Website: "https://example.com", + Description: "Test organization" + ); + + // Act + var response = await _client.PostAsJsonAsync("/api/v1/organizations", command); + + // Assert + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var result = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(result); + Assert.Equal(command.Name, result.Name); + Assert.Equal(command.Slug, result.Slug); + } + + [Fact] + public async Task GetOrganization_WhenNotFound_ShouldReturnNotFound() + { + // Arrange + var nonExistentId = Guid.NewGuid(); + + // Act + var response = await _client.GetAsync($"/api/v1/organizations/{nonExistentId}"); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task GetOrganizationBySlug_WhenNotFound_ShouldReturnNotFound() + { + // Arrange + var nonExistentSlug = "non-existent-slug"; + + // Act + var response = await _client.GetAsync($"/api/v1/organizations/slug/{nonExistentSlug}"); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task GetOrganizationsByOwner_ShouldReturnEmptyList() + { + // Arrange + var ownerId = Guid.NewGuid(); + + // Act + var response = await _client.GetAsync($"/api/v1/organizations/owner/{ownerId}"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var result = await response.Content.ReadFromJsonAsync>(); + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public async Task CreateOrganization_WithInvalidData_ShouldReturnBadRequest() + { + // Arrange + var command = new CreateOrganizationCommand( + Name: "", + Slug: "test-slug", + OwnerId: Guid.NewGuid(), + TypeId: 1, + Email: "test@example.com", + Phone: null, + Website: null, + Description: null + ); + + // Act + var response = await _client.PostAsJsonAsync("/api/v1/organizations", command); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } +} diff --git a/services/organization-service-net/tests/OrganizationService.FunctionalTests/CustomWebApplicationFactory.cs b/services/organization-service-net/tests/OrganizationService.FunctionalTests/CustomWebApplicationFactory.cs new file mode 100644 index 00000000..9d738fa1 --- /dev/null +++ b/services/organization-service-net/tests/OrganizationService.FunctionalTests/CustomWebApplicationFactory.cs @@ -0,0 +1,81 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using OrganizationService.Infrastructure; +using Serilog; +using Serilog.Events; + +namespace OrganizationService.FunctionalTests; + +/// +/// EN: Custom WebApplicationFactory for functional tests. +/// VI: WebApplicationFactory tùy chỉnh cho functional tests. +/// +public class CustomWebApplicationFactory : WebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + // EN: Reset Serilog configuration for testing + // VI: Reset cấu hình Serilog cho testing + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Debug() + .WriteTo.Console() + .CreateLogger(); + + builder.UseEnvironment("Testing"); + + builder.ConfigureServices(services => + { + // EN: Remove all DbContext related registrations + // VI: Xóa tất cả các đăng ký liên quan đến DbContext + var descriptorsToRemove = services.Where(d => + d.ServiceType == typeof(DbContextOptions) || + d.ServiceType == typeof(DbContextOptions) || + d.ServiceType == typeof(OrganizationServiceContext) || + (d.ServiceType.FullName?.Contains("EntityFramework") == true && + !d.ServiceType.FullName.Contains("InMemory")) + ).ToList(); + + foreach (var descriptor in descriptorsToRemove) + { + services.Remove(descriptor); + } + + // EN: Remove Serilog services to avoid "already frozen" error + // VI: Xóa Serilog services để tránh lỗi "already frozen" + services.RemoveAll(typeof(Serilog.SerilogServiceCollectionExtensions)); + + // EN: Add in-memory database for testing + // VI: Thêm in-memory database để test + services.AddDbContext(options => + { + options.UseInMemoryDatabase("TestDatabase_" + Guid.NewGuid().ToString()); + }); + }); + + builder.ConfigureLogging(logging => + { + logging.ClearProviders(); + logging.AddConsole(); + }); + } + + protected override IHost CreateHost(IHostBuilder builder) + { + builder.ConfigureServices(services => + { + // EN: Ensure database is created after all services are configured + // VI: Đảm bảo database được tạo sau khi tất cả services được cấu hình + var sp = services.BuildServiceProvider(); + using var scope = sp.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.EnsureCreated(); + }); + + return base.CreateHost(builder); + } +} diff --git a/services/organization-service-net/tests/OrganizationService.FunctionalTests/OrganizationService.FunctionalTests.csproj b/services/organization-service-net/tests/OrganizationService.FunctionalTests/OrganizationService.FunctionalTests.csproj new file mode 100644 index 00000000..cdf954f1 --- /dev/null +++ b/services/organization-service-net/tests/OrganizationService.FunctionalTests/OrganizationService.FunctionalTests.csproj @@ -0,0 +1,38 @@ + + + + OrganizationService.FunctionalTests + OrganizationService.FunctionalTests + false + true + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/services/organization-service-net/tests/OrganizationService.UnitTests/Application/CreateOrganizationCommandHandlerTests.cs b/services/organization-service-net/tests/OrganizationService.UnitTests/Application/CreateOrganizationCommandHandlerTests.cs new file mode 100644 index 00000000..4aa66d96 --- /dev/null +++ b/services/organization-service-net/tests/OrganizationService.UnitTests/Application/CreateOrganizationCommandHandlerTests.cs @@ -0,0 +1,84 @@ +using Xunit; +using Moq; +using OrganizationService.API.Application.Commands; +using OrganizationService.Domain.AggregatesModel.OrganizationAggregate; +using OrganizationService.Domain.SeedWork; +using Microsoft.Extensions.Logging; + +namespace OrganizationService.UnitTests.Application; + +/// +/// EN: Unit tests for CreateOrganizationCommandHandler. +/// VI: Unit tests cho CreateOrganizationCommandHandler. +/// +public class CreateOrganizationCommandHandlerTests +{ + private readonly Mock _repositoryMock; + private readonly Mock> _loggerMock; + private readonly CreateOrganizationCommandHandler _handler; + + public CreateOrganizationCommandHandlerTests() + { + _repositoryMock = new Mock(); + _loggerMock = new Mock>(); + + var unitOfWorkMock = new Mock(); + unitOfWorkMock.Setup(u => u.SaveEntitiesAsync(It.IsAny())) + .ReturnsAsync(true); + _repositoryMock.Setup(r => r.UnitOfWork).Returns(unitOfWorkMock.Object); + + _handler = new CreateOrganizationCommandHandler(_repositoryMock.Object, _loggerMock.Object); + } + + [Fact] + public async Task Handle_ShouldCreateOrganization_WhenValidCommand() + { + // Arrange + var command = new CreateOrganizationCommand( + Name: "Test Organization", + Slug: "test-organization", + OwnerId: Guid.NewGuid(), + TypeId: 1, + Email: "test@example.com", + Phone: null, + Website: null, + Description: "A test organization" + ); + + _repositoryMock.Setup(r => r.SlugExistsAsync(command.Slug, It.IsAny())) + .ReturnsAsync(false); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Equal(command.Name, result.Name); + Assert.Equal(command.Slug, result.Slug); + _repositoryMock.Verify(r => r.Add(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_ShouldThrowException_WhenSlugExists() + { + // Arrange + var command = new CreateOrganizationCommand( + Name: "Test Organization", + Slug: "existing-slug", + OwnerId: Guid.NewGuid(), + TypeId: 1, + Email: "test@example.com", + Phone: null, + Website: null, + Description: null + ); + + _repositoryMock.Setup(r => r.SlugExistsAsync(command.Slug, It.IsAny())) + .ReturnsAsync(true); + + // Act & Assert + await Assert.ThrowsAsync( + () => _handler.Handle(command, CancellationToken.None) + ); + } +} diff --git a/services/organization-service-net/tests/OrganizationService.UnitTests/Domain/OrganizationAggregateTests.cs b/services/organization-service-net/tests/OrganizationService.UnitTests/Domain/OrganizationAggregateTests.cs new file mode 100644 index 00000000..97765e26 --- /dev/null +++ b/services/organization-service-net/tests/OrganizationService.UnitTests/Domain/OrganizationAggregateTests.cs @@ -0,0 +1,115 @@ +using Xunit; +using OrganizationService.Domain.AggregatesModel.OrganizationAggregate; +using OrganizationService.Domain.AggregatesModel.OrganizationAggregate.ValueObjects; +using OrganizationService.Domain.Exceptions; + +namespace OrganizationService.UnitTests.Domain; + +/// +/// EN: Unit tests for Organization aggregate. +/// VI: Unit tests cho Organization aggregate. +/// +public class OrganizationAggregateTests +{ + [Fact] + public void Create_ShouldCreateOrganization_WithValidParameters() + { + // Arrange + var name = "Test Organization"; + var slug = "test-organization"; + var ownerId = Guid.NewGuid(); + var type = OrganizationType.Company; + var contactInfo = new ContactInfo("test@example.com", "123456789"); + + // Act + var organization = Organization.Create(name, slug, ownerId, type, contactInfo); + + // Assert + Assert.NotEqual(Guid.Empty, organization.Id); + Assert.Equal(name, organization.Name); + Assert.Equal(slug, organization.Slug); + Assert.Equal(ownerId, organization.OwnerId); + Assert.Equal(type, organization.Type); + Assert.Equal(OrganizationStatus.Active, organization.Status); + Assert.False(organization.IsVerified); + } + + [Fact] + public void Create_ShouldThrowException_WhenNameIsEmpty() + { + // Arrange + var contactInfo = new ContactInfo("test@example.com"); + + // Act & Assert + Assert.Throws(() => + Organization.Create("", "test-slug", Guid.NewGuid(), OrganizationType.Company, contactInfo)); + } + + [Fact] + public void Create_ShouldThrowException_WhenSlugIsEmpty() + { + // Arrange + var contactInfo = new ContactInfo("test@example.com"); + + // Act & Assert + Assert.Throws(() => + Organization.Create("Test Name", "", Guid.NewGuid(), OrganizationType.Company, contactInfo)); + } + + [Fact] + public void UpdateInfo_ShouldUpdateOrganizationDetails() + { + // Arrange + var organization = CreateTestOrganization(); + var newName = "Updated Organization"; + var newDescription = "Updated description"; + var newLogoUrl = "https://example.com/logo.png"; + + // Act + organization.UpdateInfo(newName, newDescription, newLogoUrl); + + // Assert + Assert.Equal(newName, organization.Name); + Assert.Equal(newDescription, organization.Description); + Assert.Equal(newLogoUrl, organization.LogoUrl); + } + + [Fact] + public void MarkAsVerified_ShouldSetIsVerifiedToTrue() + { + // Arrange + var organization = CreateTestOrganization(); + var reviewerId = Guid.NewGuid(); + + // Act + organization.MarkAsVerified(reviewerId); + + // Assert + Assert.True(organization.IsVerified); + Assert.Equal(OrganizationStatus.Active, organization.Status); + } + + [Fact] + public void Deactivate_ShouldChangeStatusToInactive() + { + // Arrange + var organization = CreateTestOrganization(); + + // Act + organization.Deactivate(); + + // Assert + Assert.Equal(OrganizationStatus.Inactive, organization.Status); + } + + private static Organization CreateTestOrganization() + { + return Organization.Create( + "Test Organization", + "test-organization", + Guid.NewGuid(), + OrganizationType.Company, + new ContactInfo("test@example.com") + ); + } +} diff --git a/services/organization-service-net/tests/OrganizationService.UnitTests/OrganizationService.UnitTests.csproj b/services/organization-service-net/tests/OrganizationService.UnitTests/OrganizationService.UnitTests.csproj new file mode 100644 index 00000000..1df7bce1 --- /dev/null +++ b/services/organization-service-net/tests/OrganizationService.UnitTests/OrganizationService.UnitTests.csproj @@ -0,0 +1,35 @@ + + + + OrganizationService.UnitTests + OrganizationService.UnitTests + false + true + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/services/social-service-net/.env.example b/services/social-service-net/.env.example new file mode 100644 index 00000000..7b4c1e9d --- /dev/null +++ b/services/social-service-net/.env.example @@ -0,0 +1,40 @@ +# Environment / Môi Trường +ASPNETCORE_ENVIRONMENT=Development + +# Database / Cơ Sở Dữ Liệu +# PostgreSQL connection string (Neon or local) +DATABASE_URL=Host=localhost;Port=5432;Database=socialservice_db;Username=postgres;Password=postgres + +# Redis Cache +REDIS_URL=localhost:6379 +REDIS_PASSWORD= + +# JWT Authentication / Xác Thực JWT +JWT_SECRET=your-secret-key-min-32-characters-long-here +JWT_ISSUER=goodgo-platform +JWT_AUDIENCE=goodgo-services +JWT_ACCESS_TOKEN_EXPIRY_MINUTES=15 +JWT_REFRESH_TOKEN_EXPIRY_DAYS=7 + +# API Configuration / Cấu Hình API +API_PORT=5000 +API_BASE_PATH=/api/v1/socialservice + +# Observability / Quan Sát +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 +OTEL_SERVICE_NAME=socialservice + +# Logging +LOG_LEVEL=Information +SEQ_URL=http://localhost:5341 + +# Feature Flags +FEATURE_SWAGGER_ENABLED=true +FEATURE_DETAILED_ERRORS=true + +# Rate Limiting +RATE_LIMIT_PERMITS_PER_MINUTE=100 +RATE_LIMIT_QUEUE_LIMIT=10 + +# Health Checks +HEALTHCHECK_TIMEOUT_SECONDS=5 diff --git a/services/social-service-net/.gitignore b/services/social-service-net/.gitignore new file mode 100644 index 00000000..84b02a53 --- /dev/null +++ b/services/social-service-net/.gitignore @@ -0,0 +1,75 @@ +# Build results +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio +.vs/ +*.user +*.userosscache +*.suo +*.userprefs +*.sln.docstates + +# Rider +.idea/ +*.sln.iml + +# Visual Studio Code +.vscode/ + +# NuGet +*.nupkg +*.snupkg +.nuget/ +packages/ +project.lock.json +project.fragment.lock.json + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# Coverage +TestResults/ +*.coverage +*.coveragexml +coverage*.json +coverage*.xml + +# Publish output +publish/ +out/ + +# Environment files +.env +.env.local +.env.*.local +*.env + +# Secrets +appsettings.*.json +!appsettings.json +!appsettings.Development.json + +# macOS +.DS_Store + +# Windows +Thumbs.db +ehthumbs.db + +# JetBrains +*.resharper + +# dotnet tools +.config/dotnet-tools.json + +# Migration scripts (only keep structure) +Migrations/ + +# Temp files +*.tmp +*.temp +~$* diff --git a/services/social-service-net/Directory.Build.props b/services/social-service-net/Directory.Build.props new file mode 100644 index 00000000..c3b74373 --- /dev/null +++ b/services/social-service-net/Directory.Build.props @@ -0,0 +1,22 @@ + + + net10.0 + 14.0 + enable + enable + true + true + $(NoWarn);1591;CA2017 + + + + GoodGo Team + GoodGo + © 2026 GoodGo. All rights reserved. + git + + + + + + diff --git a/services/social-service-net/Dockerfile b/services/social-service-net/Dockerfile new file mode 100644 index 00000000..fdbe7e10 --- /dev/null +++ b/services/social-service-net/Dockerfile @@ -0,0 +1,66 @@ +# Build stage / Giai đoạn build +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +# EN: Copy project files for layer caching +# VI: Sao chép các file project để tận dụng layer caching +COPY ["src/SocialService.API/SocialService.API.csproj", "src/SocialService.API/"] +COPY ["src/SocialService.Domain/SocialService.Domain.csproj", "src/SocialService.Domain/"] +COPY ["src/SocialService.Infrastructure/SocialService.Infrastructure.csproj", "src/SocialService.Infrastructure/"] +COPY ["Directory.Build.props", "./"] + +# EN: Restore dependencies +# VI: Khôi phục dependencies +RUN dotnet restore "src/SocialService.API/SocialService.API.csproj" + +# EN: Copy all source code +# VI: Sao chép toàn bộ source code +COPY src/ ./src/ + +# EN: Build the application +# VI: Build ứng dụng +WORKDIR "/src/src/SocialService.API" +RUN dotnet build "SocialService.API.csproj" -c Release -o /app/build --no-restore + +# Publish stage / Giai đoạn publish +FROM build AS publish +RUN dotnet publish "SocialService.API.csproj" -c Release -o /app/publish /p:UseAppHost=false --no-restore + +# Runtime stage / Giai đoạn runtime +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final +WORKDIR /app + +# EN: Create non-root user for security +# VI: Tạo user non-root cho bảo mật +RUN groupadd -g 1001 dotnetuser && \ + useradd -u 1001 -g dotnetuser -s /bin/sh dotnetuser + +# EN: Copy published application +# VI: Sao chép ứng dụng đã publish +COPY --from=publish /app/publish . + +# EN: Change ownership to non-root user +# VI: Thay đổi quyền sở hữu sang user non-root +RUN chown -R dotnetuser:dotnetuser /app + +# EN: Switch to non-root user +# VI: Chuyển sang user non-root +USER dotnetuser + +# EN: Expose port +# VI: Mở cổng +EXPOSE 8080 + +# EN: Set environment variables +# VI: Thiết lập biến môi trường +ENV ASPNETCORE_URLS=http://+:8080 +ENV ASPNETCORE_ENVIRONMENT=Production + +# EN: Health check +# VI: Kiểm tra health +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD curl -f http://localhost:8080/health/live || exit 1 + +# EN: Start the application +# VI: Khởi động ứng dụng +ENTRYPOINT ["dotnet", "SocialService.API.dll"] diff --git a/services/social-service-net/SocialService.slnx b/services/social-service-net/SocialService.slnx new file mode 100644 index 00000000..395671b6 --- /dev/null +++ b/services/social-service-net/SocialService.slnx @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/services/social-service-net/docker-compose.yml b/services/social-service-net/docker-compose.yml new file mode 100644 index 00000000..e46697cb --- /dev/null +++ b/services/social-service-net/docker-compose.yml @@ -0,0 +1,72 @@ +version: '3.8' + +# EN: Docker Compose for local development +# VI: Docker Compose cho phát triển local + +services: + socialservice-api: + build: + context: . + dockerfile: Dockerfile + container_name: socialservice-api + ports: + - "5000:8080" + environment: + - ASPNETCORE_ENVIRONMENT=Development + - DATABASE_URL=Host=postgres;Port=5432;Database=socialservice_db;Username=postgres;Password=postgres + - REDIS_URL=redis:6379 + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + networks: + - socialservice-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health/live"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + + postgres: + image: postgres:16-alpine + container_name: socialservice-postgres + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: socialservice_db + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - socialservice-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + container_name: socialservice-redis + ports: + - "6379:6379" + volumes: + - redis_data:/data + networks: + - socialservice-network + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + postgres_data: + redis_data: + +networks: + socialservice-network: + driver: bridge diff --git a/services/social-service-net/docs/en/ARCHITECTURE.md b/services/social-service-net/docs/en/ARCHITECTURE.md new file mode 100644 index 00000000..bc306599 --- /dev/null +++ b/services/social-service-net/docs/en/ARCHITECTURE.md @@ -0,0 +1,271 @@ +# Architecture Documentation + +> Detailed architecture documentation for the .NET 10 Microservice Template. + +## Architecture Overview + +```mermaid +graph TB + subgraph "API Layer" + C[Controllers] + CMD[Commands] + Q[Queries] + B[Behaviors] + V[Validations] + end + + subgraph "Domain Layer" + AR[Aggregate Roots] + E[Entities] + VO[Value Objects] + DE[Domain Events] + DX[Domain Exceptions] + end + + subgraph "Infrastructure Layer" + DB[(PostgreSQL)] + R[Repositories] + CTX[DbContext] + ID[Idempotency] + end + + C --> CMD + C --> Q + CMD --> B --> V + CMD --> AR + Q --> R + R --> CTX --> DB + AR --> DE + R --> AR + + style C fill:#4a90d9,stroke:#2d5986,color:#fff + style AR fill:#50c878,stroke:#2d8659,color:#fff + style DB fill:#ff6b6b,stroke:#c0392b,color:#fff +``` + +## Layer Responsibilities + +### 1. Domain Layer (SocialService.Domain) + +The heart of the application containing pure business logic. This layer: +- Has **ZERO** external dependencies (except MediatR.Contracts for events) +- Contains only POCO classes +- Implements DDD tactical patterns + +#### Components + +| Component | Purpose | +|-----------|---------| +| **SeedWork** | Base classes: Entity, ValueObject, Enumeration, IAggregateRoot | +| **AggregatesModel** | Aggregate roots with their entities and value objects | +| **Events** | Domain events for cross-aggregate communication | +| **Exceptions** | Domain-specific exceptions for business rule violations | + +### 2. Infrastructure Layer (SocialService.Infrastructure) + +Technical implementations and external concerns: +- Database access (EF Core) +- Repository implementations +- External service integrations + +### 3. API Layer (SocialService.API) + +Application entry point and CQRS implementation: +- Controllers for HTTP handling +- Commands for write operations +- Queries for read operations +- MediatR behaviors for cross-cutting concerns + +## CQRS Flow + +```mermaid +sequenceDiagram + participant Client + participant Controller + participant MediatR + participant LoggingBehavior + participant ValidatorBehavior + participant TransactionBehavior + participant CommandHandler + participant Repository + participant DbContext + + Client->>Controller: HTTP Request + Controller->>MediatR: Send(Command) + MediatR->>LoggingBehavior: Handle + LoggingBehavior->>ValidatorBehavior: Next() + ValidatorBehavior->>TransactionBehavior: Next() + TransactionBehavior->>CommandHandler: Next() + CommandHandler->>Repository: Add/Update/Delete + Repository->>DbContext: SaveEntitiesAsync() + DbContext-->>Repository: Success + Repository-->>CommandHandler: Result + CommandHandler-->>Controller: Response + Controller-->>Client: HTTP Response +``` + +## Domain Events + +```mermaid +graph LR + AR[Aggregate Root] -->|Raises| DE[Domain Event] + DE -->|Dispatched by| CTX[DbContext] + CTX -->|Publishes to| M[MediatR] + M -->|Handled by| H1[Handler 1] + M -->|Handled by| H2[Handler 2] + + style AR fill:#50c878,stroke:#2d8659,color:#fff + style DE fill:#f39c12,stroke:#d68910,color:#fff + style M fill:#9b59b6,stroke:#7d3c98,color:#fff +``` + +## Database Schema + +### Sample Aggregate + +```mermaid +erDiagram + samples { + uuid id PK + varchar(200) name + varchar(1000) description + int status_id FK + timestamp created_at + timestamp updated_at + } + + sample_statuses { + int id PK + varchar(50) name + } + + samples ||--o{ sample_statuses : has +``` + +## MediatR Pipeline + +``` +Request → LoggingBehavior → ValidatorBehavior → TransactionBehavior → Handler → Response + │ │ │ + ▼ ▼ ▼ + Log start/end Validate Begin/Commit + + timing with Transaction + FluentValidation +``` + +### Behavior Order + +1. **LoggingBehavior** - Logs request handling with timing +2. **ValidatorBehavior** - Validates request using FluentValidation +3. **TransactionBehavior** - Wraps command handlers in database transactions + +## Error Handling + +### Exception Hierarchy + +``` +Exception +└── DomainException + └── SampleDomainException +``` + +### Problem Details (RFC 7807) + +All errors are returned in Problem Details format: + +```json +{ + "type": "https://tools.ietf.org/html/rfc7807", + "title": "Validation Error", + "status": 400, + "detail": "One or more validation errors occurred.", + "errors": { + "Name": ["Name is required"] + } +} +``` + +## Health Checks + +```mermaid +graph TD + HC[Health Check Endpoint] + HC --> |/health/live| L[Liveness] + HC --> |/health/ready| R[Readiness] + HC --> |/health| F[Full Status] + + R --> PG[(PostgreSQL)] + R --> RD[(Redis)] + + style HC fill:#3498db,stroke:#2980b9,color:#fff + style L fill:#2ecc71,stroke:#27ae60,color:#fff + style R fill:#f39c12,stroke:#d68910,color:#fff +``` + +## Deployment Architecture + +### Docker Compose (Local) + +```yaml +services: + socialservice-api: + build: . + ports: ["5000:8080"] + depends_on: + - postgres + - redis + + postgres: + image: postgres:16-alpine + + redis: + image: redis:7-alpine +``` + +### Kubernetes (Production) + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: socialservice-api +spec: + replicas: 3 + template: + spec: + containers: + - name: api + image: socialservice:latest + ports: + - containerPort: 8080 + livenessProbe: + httpGet: + path: /health/live + port: 8080 + readinessProbe: + httpGet: + path: /health/ready + port: 8080 +``` + +## Security Considerations + +1. **Authentication**: JWT Bearer token (configure in production) +2. **Authorization**: Role-based access control +3. **Input Validation**: FluentValidation on all requests +4. **SQL Injection**: EF Core parameterized queries +5. **Secrets**: Environment variables, never in code + +## Performance Optimization + +1. **Connection Pooling**: EF Core with Npgsql connection resilience +2. **Async/Await**: All I/O operations are async +3. **Response Caching**: Add caching headers for queries +4. **Database Indexes**: Configure in EntityConfigurations + +## References + +- [eShopOnContainers](https://github.com/dotnet-architecture/eShopOnContainers) +- [.NET Microservices Architecture Guide](https://docs.microsoft.com/en-us/dotnet/architecture/microservices/) +- [Domain-Driven Design](https://martinfowler.com/bliki/DomainDrivenDesign.html) +- [CQRS Pattern](https://docs.microsoft.com/en-us/azure/architecture/patterns/cqrs) diff --git a/services/social-service-net/docs/en/README.md b/services/social-service-net/docs/en/README.md new file mode 100644 index 00000000..d1baaa64 --- /dev/null +++ b/services/social-service-net/docs/en/README.md @@ -0,0 +1,265 @@ +# .NET 10 Microservice Template + +> Enterprise-grade .NET 10 microservice template following DDD, CQRS, and Clean Architecture patterns. + +## Overview + +This template provides a production-ready structure for .NET microservices based on the eShopOnContainers reference architecture with: + +- **Domain-Driven Design (DDD)** - Aggregates, Entities, Value Objects, Domain Events +- **CQRS Pattern** - Separate Commands (write) and Queries (read) with MediatR +- **Clean Architecture** - Domain, Infrastructure, API layered separation +- **EF Core 10** - PostgreSQL with connection resilience +- **FluentValidation** - Request validation +- **API Versioning** - URL segment versioning +- **Health Checks** - Kubernetes-ready probes +- **Structured Logging** - Serilog with console and Seq + +## Prerequisites + +| Requirement | Version | +|-------------|---------| +| .NET SDK | 10.0.101+ | +| Docker | 24.0+ | +| PostgreSQL | 15+ (or use Docker) | + +```bash +# Check .NET version +dotnet --version +# Should output: 10.0.xxx +``` + +## Quick Start + +### 1. Create New Service + +```bash +# Copy template to new service +cp -r services/_template_dot_net services/your-service-name + +# Navigate to service directory +cd services/your-service-name + +# Rename all occurrences of "SocialService" to "YourService" +find . -type f -name "*.cs" -exec sed -i '' 's/SocialService/YourService/g' {} + +find . -type f -name "*.csproj" -exec sed -i '' 's/SocialService/YourService/g' {} + +``` + +### 2. Configure Environment + +```bash +# Copy environment template +cp .env.example .env + +# Edit with your configuration +nano .env +``` + +### 3. Run with Docker + +```bash +# Start all services (API + PostgreSQL + Redis) +docker-compose up -d + +# View logs +docker-compose logs -f socialservice-api +``` + +### 4. Run Locally + +```bash +# Restore dependencies +dotnet restore + +# Build all projects +dotnet build + +# Run the API +dotnet run --project src/SocialService.API +``` + +## Project Structure + +``` +_template_dot_net/ +├── src/ +│ ├── SocialService.API/ # Presentation Layer (Controllers, CQRS) +│ │ ├── Controllers/ # API endpoints +│ │ ├── Application/ # CQRS Implementation +│ │ │ ├── Commands/ # Write operations (MediatR) +│ │ │ ├── Queries/ # Read operations +│ │ │ ├── Behaviors/ # MediatR pipeline behaviors +│ │ │ └── Validations/ # FluentValidation validators +│ │ ├── Middleware/ # Custom middleware +│ │ └── Program.cs # Application entry point +│ │ +│ ├── SocialService.Domain/ # Domain Layer (Pure business logic) +│ │ ├── AggregatesModel/ # Aggregate roots and entities +│ │ ├── Events/ # Domain events +│ │ ├── Exceptions/ # Domain exceptions +│ │ └── SeedWork/ # Base classes (Entity, ValueObject, etc.) +│ │ +│ └── SocialService.Infrastructure/ # Infrastructure Layer (Data access) +│ ├── EntityConfigurations/ # EF Core Fluent API configurations +│ ├── Repositories/ # Repository implementations +│ ├── Idempotency/ # Request idempotency handling +│ └── SocialServiceContext.cs # DbContext with Unit of Work +│ +├── tests/ +│ ├── SocialService.UnitTests/ # Unit tests (Domain, Application) +│ └── SocialService.FunctionalTests/ # Integration tests (API endpoints) +│ +├── Dockerfile # Multi-stage Docker build +├── docker-compose.yml # Local development setup +├── global.json # .NET SDK version pinning +└── Directory.Build.props # Common MSBuild properties +``` + +## API Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/api/v1/samples` | Get all samples | +| `GET` | `/api/v1/samples/{id}` | Get sample by ID | +| `POST` | `/api/v1/samples` | Create new sample | +| `PUT` | `/api/v1/samples/{id}` | Update sample | +| `DELETE` | `/api/v1/samples/{id}` | Delete sample | +| `PATCH` | `/api/v1/samples/{id}/status` | Change status | + +### Health Endpoints + +| Endpoint | Purpose | +|----------|---------| +| `/health` | Full health status | +| `/health/live` | Liveness probe | +| `/health/ready` | Readiness probe | + +## CQRS Pattern + +### Commands (Write Operations) + +```csharp +// Define command +public record CreateSampleCommand(string Name, string? Description) + : IRequest; + +// Handle command +public class CreateSampleCommandHandler : IRequestHandler +{ + public async Task Handle(CreateSampleCommand request, CancellationToken ct) + { + var sample = new Sample(request.Name, request.Description); + _repository.Add(sample); + await _repository.UnitOfWork.SaveEntitiesAsync(ct); + return new CreateSampleCommandResult(sample.Id); + } +} +``` + +### Queries (Read Operations) + +```csharp +// Define query +public record GetSampleQuery(Guid SampleId) : IRequest; +``` + +## Domain Model + +### Aggregate Root + +```csharp +public class Sample : Entity, IAggregateRoot +{ + public string Name => _name; + public SampleStatus Status => _status; + + public Sample(string name, string? description) { + // Business logic validation + if (string.IsNullOrWhiteSpace(name)) + throw new SampleDomainException("Sample name cannot be empty"); + + // Domain event + AddDomainEvent(new SampleCreatedDomainEvent(this)); + } + + public void Activate() { + if (_status != SampleStatus.Draft) + throw new SampleDomainException("Only draft samples can be activated"); + // State transition + } +} +``` + +## Testing + +```bash +# Run all tests +dotnet test + +# Run with coverage +dotnet test /p:CollectCoverage=true /p:CoverageReportFormat=cobertura + +# Run specific test project +dotnet test tests/SocialService.UnitTests +``` + +## Configuration + +### Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `ASPNETCORE_ENVIRONMENT` | Environment name | `Development` | +| `DATABASE_URL` | PostgreSQL connection string | - | +| `REDIS_URL` | Redis connection string | - | +| `JWT_SECRET` | JWT signing secret (min 32 chars) | - | + +### appsettings.json + +```json +{ + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Database=socialservice;Username=postgres;Password=postgres" + }, + "Serilog": { + "MinimumLevel": "Information" + } +} +``` + +## Deployment + +### Docker Build + +```bash +# Build Docker image +docker build -t socialservice:latest . + +# Run container +docker run -p 5000:8080 --env-file .env socialservice:latest +``` + +### Kubernetes + +See [ARCHITECTURE.md](./ARCHITECTURE.md) for Kubernetes deployment manifests. + +## What's New in .NET 10 + +- **C# 14** language features +- Improved **Native AOT** support +- Better **async/await** performance +- Enhanced **JSON serialization** +- Performance improvements across the board +- 3-year **LTS** support (until November 2028) + +## Resources + +- [eShopOnContainers](https://github.com/dotnet-architecture/eShopOnContainers) - Reference architecture +- [.NET 10 Documentation](https://docs.microsoft.com/en-us/dotnet/core/whats-new/dotnet-10) +- [DDD with .NET](https://docs.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/) +- [MediatR](https://github.com/jbogard/MediatR) - CQRS library +- [FluentValidation](https://docs.fluentvalidation.net/) - Validation library + +## License + +Proprietary - GoodGo Platform diff --git a/services/social-service-net/docs/vi/ARCHITECTURE.md b/services/social-service-net/docs/vi/ARCHITECTURE.md new file mode 100644 index 00000000..98d304d7 --- /dev/null +++ b/services/social-service-net/docs/vi/ARCHITECTURE.md @@ -0,0 +1,271 @@ +# Tài Liệu Kiến Trúc + +> Tài liệu kiến trúc chi tiết cho Template Microservice .NET 10. + +## Tổng Quan Kiến Trúc + +```mermaid +graph TB + subgraph "Lớp API" + C[Controllers] + CMD[Commands] + Q[Queries] + B[Behaviors] + V[Validations] + end + + subgraph "Lớp Domain" + AR[Aggregate Roots] + E[Entities] + VO[Value Objects] + DE[Domain Events] + DX[Domain Exceptions] + end + + subgraph "Lớp Infrastructure" + DB[(PostgreSQL)] + R[Repositories] + CTX[DbContext] + ID[Idempotency] + end + + C --> CMD + C --> Q + CMD --> B --> V + CMD --> AR + Q --> R + R --> CTX --> DB + AR --> DE + R --> AR + + style C fill:#4a90d9,stroke:#2d5986,color:#fff + style AR fill:#50c878,stroke:#2d8659,color:#fff + style DB fill:#ff6b6b,stroke:#c0392b,color:#fff +``` + +## Trách Nhiệm Các Lớp + +### 1. Lớp Domain (SocialService.Domain) + +Trái tim của ứng dụng chứa business logic thuần túy. Lớp này: +- Có **ZERO** phụ thuộc bên ngoài (ngoại trừ MediatR.Contracts cho events) +- Chỉ chứa các class POCO +- Triển khai các tactical patterns của DDD + +#### Thành Phần + +| Thành phần | Mục Đích | +|------------|----------| +| **SeedWork** | Base classes: Entity, ValueObject, Enumeration, IAggregateRoot | +| **AggregatesModel** | Aggregate roots với entities và value objects | +| **Events** | Domain events cho giao tiếp cross-aggregate | +| **Exceptions** | Domain exceptions cho vi phạm business rules | + +### 2. Lớp Infrastructure (SocialService.Infrastructure) + +Triển khai kỹ thuật và các mối quan tâm bên ngoài: +- Truy cập database (EF Core) +- Triển khai repositories +- Tích hợp external services + +### 3. Lớp API (SocialService.API) + +Điểm vào ứng dụng và triển khai CQRS: +- Controllers để xử lý HTTP +- Commands cho các thao tác ghi +- Queries cho các thao tác đọc +- MediatR behaviors cho cross-cutting concerns + +## Luồng CQRS + +```mermaid +sequenceDiagram + participant Client + participant Controller + participant MediatR + participant LoggingBehavior + participant ValidatorBehavior + participant TransactionBehavior + participant CommandHandler + participant Repository + participant DbContext + + Client->>Controller: HTTP Request + Controller->>MediatR: Send(Command) + MediatR->>LoggingBehavior: Handle + LoggingBehavior->>ValidatorBehavior: Next() + ValidatorBehavior->>TransactionBehavior: Next() + TransactionBehavior->>CommandHandler: Next() + CommandHandler->>Repository: Add/Update/Delete + Repository->>DbContext: SaveEntitiesAsync() + DbContext-->>Repository: Success + Repository-->>CommandHandler: Result + CommandHandler-->>Controller: Response + Controller-->>Client: HTTP Response +``` + +## Domain Events + +```mermaid +graph LR + AR[Aggregate Root] -->|Phát sinh| DE[Domain Event] + DE -->|Dispatch bởi| CTX[DbContext] + CTX -->|Publish tới| M[MediatR] + M -->|Xử lý bởi| H1[Handler 1] + M -->|Xử lý bởi| H2[Handler 2] + + style AR fill:#50c878,stroke:#2d8659,color:#fff + style DE fill:#f39c12,stroke:#d68910,color:#fff + style M fill:#9b59b6,stroke:#7d3c98,color:#fff +``` + +## Schema Database + +### Sample Aggregate + +```mermaid +erDiagram + samples { + uuid id PK + varchar(200) name + varchar(1000) description + int status_id FK + timestamp created_at + timestamp updated_at + } + + sample_statuses { + int id PK + varchar(50) name + } + + samples ||--o{ sample_statuses : has +``` + +## Pipeline MediatR + +``` +Request → LoggingBehavior → ValidatorBehavior → TransactionBehavior → Handler → Response + │ │ │ + ▼ ▼ ▼ + Log start/end Validate Begin/Commit + + timing với Transaction + FluentValidation +``` + +### Thứ Tự Behaviors + +1. **LoggingBehavior** - Ghi log xử lý request với timing +2. **ValidatorBehavior** - Validate request sử dụng FluentValidation +3. **TransactionBehavior** - Bao bọc command handlers trong database transactions + +## Xử Lý Lỗi + +### Phân Cấp Exceptions + +``` +Exception +└── DomainException + └── SampleDomainException +``` + +### Problem Details (RFC 7807) + +Tất cả lỗi được trả về theo định dạng Problem Details: + +```json +{ + "type": "https://tools.ietf.org/html/rfc7807", + "title": "Lỗi Validation", + "status": 400, + "detail": "Một hoặc nhiều lỗi validation đã xảy ra.", + "errors": { + "Name": ["Tên là bắt buộc"] + } +} +``` + +## Health Checks + +```mermaid +graph TD + HC[Health Check Endpoint] + HC --> |/health/live| L[Liveness] + HC --> |/health/ready| R[Readiness] + HC --> |/health| F[Full Status] + + R --> PG[(PostgreSQL)] + R --> RD[(Redis)] + + style HC fill:#3498db,stroke:#2980b9,color:#fff + style L fill:#2ecc71,stroke:#27ae60,color:#fff + style R fill:#f39c12,stroke:#d68910,color:#fff +``` + +## Kiến Trúc Deployment + +### Docker Compose (Local) + +```yaml +services: + socialservice-api: + build: . + ports: ["5000:8080"] + depends_on: + - postgres + - redis + + postgres: + image: postgres:16-alpine + + redis: + image: redis:7-alpine +``` + +### Kubernetes (Production) + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: socialservice-api +spec: + replicas: 3 + template: + spec: + containers: + - name: api + image: socialservice:latest + ports: + - containerPort: 8080 + livenessProbe: + httpGet: + path: /health/live + port: 8080 + readinessProbe: + httpGet: + path: /health/ready + port: 8080 +``` + +## Cân Nhắc Bảo Mật + +1. **Authentication**: JWT Bearer token (cấu hình trong production) +2. **Authorization**: Role-based access control +3. **Input Validation**: FluentValidation trên tất cả requests +4. **SQL Injection**: EF Core parameterized queries +5. **Secrets**: Biến môi trường, không bao giờ trong code + +## Tối Ưu Hiệu Năng + +1. **Connection Pooling**: EF Core với Npgsql connection resilience +2. **Async/Await**: Tất cả I/O operations đều async +3. **Response Caching**: Thêm caching headers cho queries +4. **Database Indexes**: Cấu hình trong EntityConfigurations + +## Tài Liệu Tham Khảo + +- [eShopOnContainers](https://github.com/dotnet-architecture/eShopOnContainers) +- [Hướng dẫn Kiến trúc .NET Microservices](https://docs.microsoft.com/en-us/dotnet/architecture/microservices/) +- [Domain-Driven Design](https://martinfowler.com/bliki/DomainDrivenDesign.html) +- [CQRS Pattern](https://docs.microsoft.com/en-us/azure/architecture/patterns/cqrs) diff --git a/services/social-service-net/docs/vi/README.md b/services/social-service-net/docs/vi/README.md new file mode 100644 index 00000000..e188f513 --- /dev/null +++ b/services/social-service-net/docs/vi/README.md @@ -0,0 +1,265 @@ +# Template Microservice .NET 10 + +> Template microservice .NET 10 cấp doanh nghiệp theo các pattern DDD, CQRS và Clean Architecture. + +## Tổng Quan + +Template này cung cấp cấu trúc sẵn sàng production cho microservices .NET dựa trên kiến trúc tham chiếu eShopOnContainers với: + +- **Domain-Driven Design (DDD)** - Aggregates, Entities, Value Objects, Domain Events +- **CQRS Pattern** - Tách biệt Commands (ghi) và Queries (đọc) với MediatR +- **Clean Architecture** - Phân tầng Domain, Infrastructure, API +- **EF Core 10** - PostgreSQL với connection resilience +- **FluentValidation** - Validation request +- **API Versioning** - Versioning theo URL segment +- **Health Checks** - Probes sẵn sàng cho Kubernetes +- **Structured Logging** - Serilog với console và Seq + +## Yêu Cầu + +| Yêu cầu | Phiên bản | +|---------|-----------| +| .NET SDK | 10.0.101+ | +| Docker | 24.0+ | +| PostgreSQL | 15+ (hoặc dùng Docker) | + +```bash +# Kiểm tra phiên bản .NET +dotnet --version +# Kết quả nên là: 10.0.xxx +``` + +## Bắt Đầu Nhanh + +### 1. Tạo Service Mới + +```bash +# Sao chép template sang service mới +cp -r services/_template_dot_net services/your-service-name + +# Di chuyển đến thư mục service +cd services/your-service-name + +# Đổi tên tất cả "SocialService" thành "YourService" +find . -type f -name "*.cs" -exec sed -i '' 's/SocialService/YourService/g' {} + +find . -type f -name "*.csproj" -exec sed -i '' 's/SocialService/YourService/g' {} + +``` + +### 2. Cấu Hình Môi Trường + +```bash +# Sao chép template môi trường +cp .env.example .env + +# Chỉnh sửa với cấu hình của bạn +nano .env +``` + +### 3. Chạy với Docker + +```bash +# Khởi động tất cả services (API + PostgreSQL + Redis) +docker-compose up -d + +# Xem logs +docker-compose logs -f socialservice-api +``` + +### 4. Chạy Local + +```bash +# Khôi phục dependencies +dotnet restore + +# Build tất cả projects +dotnet build + +# Chạy API +dotnet run --project src/SocialService.API +``` + +## Cấu Trúc Dự Án + +``` +_template_dot_net/ +├── src/ +│ ├── SocialService.API/ # Lớp Presentation (Controllers, CQRS) +│ │ ├── Controllers/ # Các API endpoints +│ │ ├── Application/ # Triển khai CQRS +│ │ │ ├── Commands/ # Thao tác ghi (MediatR) +│ │ │ ├── Queries/ # Thao tác đọc +│ │ │ ├── Behaviors/ # MediatR pipeline behaviors +│ │ │ └── Validations/ # FluentValidation validators +│ │ ├── Middleware/ # Custom middleware +│ │ └── Program.cs # Điểm vào ứng dụng +│ │ +│ ├── SocialService.Domain/ # Lớp Domain (Business logic thuần túy) +│ │ ├── AggregatesModel/ # Aggregate roots và entities +│ │ ├── Events/ # Domain events +│ │ ├── Exceptions/ # Domain exceptions +│ │ └── SeedWork/ # Base classes (Entity, ValueObject, etc.) +│ │ +│ └── SocialService.Infrastructure/ # Lớp Infrastructure (Truy cập dữ liệu) +│ ├── EntityConfigurations/ # Cấu hình EF Core Fluent API +│ ├── Repositories/ # Triển khai repositories +│ ├── Idempotency/ # Xử lý idempotency request +│ └── SocialServiceContext.cs # DbContext với Unit of Work +│ +├── tests/ +│ ├── SocialService.UnitTests/ # Unit tests (Domain, Application) +│ └── SocialService.FunctionalTests/ # Integration tests (API endpoints) +│ +├── Dockerfile # Multi-stage Docker build +├── docker-compose.yml # Thiết lập phát triển local +├── global.json # Pin phiên bản .NET SDK +└── Directory.Build.props # Thuộc tính MSBuild chung +``` + +## Các Endpoint API + +| Method | Endpoint | Mô Tả | +|--------|----------|-------| +| `GET` | `/api/v1/samples` | Lấy tất cả samples | +| `GET` | `/api/v1/samples/{id}` | Lấy sample theo ID | +| `POST` | `/api/v1/samples` | Tạo sample mới | +| `PUT` | `/api/v1/samples/{id}` | Cập nhật sample | +| `DELETE` | `/api/v1/samples/{id}` | Xóa sample | +| `PATCH` | `/api/v1/samples/{id}/status` | Thay đổi trạng thái | + +### Health Endpoints + +| Endpoint | Mục Đích | +|----------|----------| +| `/health` | Trạng thái health đầy đủ | +| `/health/live` | Kiểm tra sống | +| `/health/ready` | Kiểm tra sẵn sàng | + +## Pattern CQRS + +### Commands (Thao Tác Ghi) + +```csharp +// Định nghĩa command +public record CreateSampleCommand(string Name, string? Description) + : IRequest; + +// Xử lý command +public class CreateSampleCommandHandler : IRequestHandler +{ + public async Task Handle(CreateSampleCommand request, CancellationToken ct) + { + var sample = new Sample(request.Name, request.Description); + _repository.Add(sample); + await _repository.UnitOfWork.SaveEntitiesAsync(ct); + return new CreateSampleCommandResult(sample.Id); + } +} +``` + +### Queries (Thao Tác Đọc) + +```csharp +// Định nghĩa query +public record GetSampleQuery(Guid SampleId) : IRequest; +``` + +## Domain Model + +### Aggregate Root + +```csharp +public class Sample : Entity, IAggregateRoot +{ + public string Name => _name; + public SampleStatus Status => _status; + + public Sample(string name, string? description) { + // Validation business logic + if (string.IsNullOrWhiteSpace(name)) + throw new SampleDomainException("Tên sample không được để trống"); + + // Domain event + AddDomainEvent(new SampleCreatedDomainEvent(this)); + } + + public void Activate() { + if (_status != SampleStatus.Draft) + throw new SampleDomainException("Chỉ sample draft mới có thể kích hoạt"); + // Chuyển đổi trạng thái + } +} +``` + +## Kiểm Thử + +```bash +# Chạy tất cả tests +dotnet test + +# Chạy với coverage +dotnet test /p:CollectCoverage=true /p:CoverageReportFormat=cobertura + +# Chạy project test cụ thể +dotnet test tests/SocialService.UnitTests +``` + +## Cấu Hình + +### Biến Môi Trường + +| Biến | Mô Tả | Mặc định | +|------|-------|----------| +| `ASPNETCORE_ENVIRONMENT` | Tên môi trường | `Development` | +| `DATABASE_URL` | Connection string PostgreSQL | - | +| `REDIS_URL` | Connection string Redis | - | +| `JWT_SECRET` | Secret ký JWT (tối thiểu 32 ký tự) | - | + +### appsettings.json + +```json +{ + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Database=socialservice;Username=postgres;Password=postgres" + }, + "Serilog": { + "MinimumLevel": "Information" + } +} +``` + +## Triển Khai + +### Docker Build + +```bash +# Build Docker image +docker build -t socialservice:latest . + +# Chạy container +docker run -p 5000:8080 --env-file .env socialservice:latest +``` + +### Kubernetes + +Xem [ARCHITECTURE.md](./ARCHITECTURE.md) để biết manifests triển khai Kubernetes. + +## Có Gì Mới Trong .NET 10 + +- Tính năng ngôn ngữ **C# 14** +- Hỗ trợ **Native AOT** được cải thiện +- Hiệu suất **async/await** tốt hơn +- **JSON serialization** được nâng cao +- Cải thiện hiệu suất toàn diện +- Hỗ trợ **LTS** 3 năm (đến tháng 11/2028) + +## Tài Nguyên + +- [eShopOnContainers](https://github.com/dotnet-architecture/eShopOnContainers) - Kiến trúc tham chiếu +- [Tài liệu .NET 10](https://docs.microsoft.com/en-us/dotnet/core/whats-new/dotnet-10) +- [DDD với .NET](https://docs.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/) +- [MediatR](https://github.com/jbogard/MediatR) - Thư viện CQRS +- [FluentValidation](https://docs.fluentvalidation.net/) - Thư viện validation + +## Giấy Phép + +Độc quyền - GoodGo Platform diff --git a/services/social-service-net/global.json b/services/social-service-net/global.json new file mode 100644 index 00000000..f78eeaf4 --- /dev/null +++ b/services/social-service-net/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "10.0.101", + "rollForward": "latestMinor", + "allowPrerelease": false + } +} \ No newline at end of file diff --git a/services/social-service-net/src/SocialService.API/Application/Behaviors/LoggingBehavior.cs b/services/social-service-net/src/SocialService.API/Application/Behaviors/LoggingBehavior.cs new file mode 100644 index 00000000..0f622a6d --- /dev/null +++ b/services/social-service-net/src/SocialService.API/Application/Behaviors/LoggingBehavior.cs @@ -0,0 +1,58 @@ +using System.Diagnostics; +using MediatR; + +namespace SocialService.API.Application.Behaviors; + +/// +/// EN: MediatR behavior for logging request handling. +/// VI: MediatR behavior để logging việc xử lý request. +/// +/// EN: Request type / VI: Loại request +/// EN: Response type / VI: Loại response +public class LoggingBehavior : IPipelineBehavior + where TRequest : IRequest +{ + private readonly ILogger> _logger; + + public LoggingBehavior(ILogger> logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + var requestName = typeof(TRequest).Name; + + _logger.LogInformation( + "Handling {RequestName} / Đang xử lý {RequestName}", + requestName); + + var stopwatch = Stopwatch.StartNew(); + + try + { + var response = await next(); + + stopwatch.Stop(); + + _logger.LogInformation( + "Handled {RequestName} in {ElapsedMs}ms / Đã xử lý {RequestName} trong {ElapsedMs}ms", + requestName, stopwatch.ElapsedMilliseconds); + + return response; + } + catch (Exception ex) + { + stopwatch.Stop(); + + _logger.LogError(ex, + "Error handling {RequestName} after {ElapsedMs}ms / Lỗi xử lý {RequestName} sau {ElapsedMs}ms", + requestName, stopwatch.ElapsedMilliseconds); + + throw; + } + } +} diff --git a/services/social-service-net/src/SocialService.API/Application/Behaviors/TransactionBehavior.cs b/services/social-service-net/src/SocialService.API/Application/Behaviors/TransactionBehavior.cs new file mode 100644 index 00000000..6bfbaaa5 --- /dev/null +++ b/services/social-service-net/src/SocialService.API/Application/Behaviors/TransactionBehavior.cs @@ -0,0 +1,84 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using SocialService.Infrastructure; + +namespace SocialService.API.Application.Behaviors; + +/// +/// EN: MediatR behavior for handling database transactions. +/// VI: MediatR behavior để xử lý database transactions. +/// +/// EN: Request type / VI: Loại request +/// EN: Response type / VI: Loại response +public class TransactionBehavior : IPipelineBehavior + where TRequest : IRequest +{ + private readonly SocialServiceContext _dbContext; + private readonly ILogger> _logger; + + public TransactionBehavior( + SocialServiceContext dbContext, + ILogger> logger) + { + _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + var requestName = typeof(TRequest).Name; + + // EN: Skip transaction for queries (read operations) + // VI: Bỏ qua transaction cho queries (các thao tác đọc) + if (requestName.EndsWith("Query")) + { + return await next(); + } + + // EN: Skip if already in a transaction + // VI: Bỏ qua nếu đã trong transaction + if (_dbContext.HasActiveTransaction) + { + return await next(); + } + + var strategy = _dbContext.Database.CreateExecutionStrategy(); + + return await strategy.ExecuteAsync(async () => + { + await using var transaction = await _dbContext.BeginTransactionAsync(); + + _logger.LogInformation( + "Begin transaction {TransactionId} for {RequestName} / Bắt đầu transaction {TransactionId} cho {RequestName}", + transaction?.TransactionId, requestName); + + try + { + var response = await next(); + + if (transaction != null) + { + await _dbContext.CommitTransactionAsync(transaction); + + _logger.LogInformation( + "Committed transaction {TransactionId} for {RequestName} / Đã commit transaction {TransactionId} cho {RequestName}", + transaction.TransactionId, requestName); + } + + return response; + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error during transaction {TransactionId} for {RequestName} / Lỗi trong transaction {TransactionId} cho {RequestName}", + transaction?.TransactionId, requestName); + + _dbContext.RollbackTransaction(); + throw; + } + }); + } +} diff --git a/services/social-service-net/src/SocialService.API/Application/Behaviors/ValidatorBehavior.cs b/services/social-service-net/src/SocialService.API/Application/Behaviors/ValidatorBehavior.cs new file mode 100644 index 00000000..2b6f7c6c --- /dev/null +++ b/services/social-service-net/src/SocialService.API/Application/Behaviors/ValidatorBehavior.cs @@ -0,0 +1,63 @@ +using FluentValidation; +using MediatR; + +namespace SocialService.API.Application.Behaviors; + +/// +/// EN: MediatR behavior for FluentValidation integration. +/// VI: MediatR behavior để tích hợp FluentValidation. +/// +/// EN: Request type / VI: Loại request +/// EN: Response type / VI: Loại response +public class ValidatorBehavior : IPipelineBehavior + where TRequest : IRequest +{ + private readonly IEnumerable> _validators; + private readonly ILogger> _logger; + + public ValidatorBehavior( + IEnumerable> validators, + ILogger> logger) + { + _validators = validators ?? throw new ArgumentNullException(nameof(validators)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + var requestName = typeof(TRequest).Name; + + if (!_validators.Any()) + { + return await next(); + } + + _logger.LogDebug( + "Validating {RequestName} / Đang validate {RequestName}", + requestName); + + var context = new ValidationContext(request); + + var validationResults = await Task.WhenAll( + _validators.Select(v => v.ValidateAsync(context, cancellationToken))); + + var failures = validationResults + .SelectMany(r => r.Errors) + .Where(f => f != null) + .ToList(); + + if (failures.Count != 0) + { + _logger.LogWarning( + "Validation failed for {RequestName} with {ErrorCount} errors / Validation thất bại cho {RequestName} với {ErrorCount} lỗi", + requestName, failures.Count); + + throw new ValidationException(failures); + } + + return await next(); + } +} diff --git a/services/social-service-net/src/SocialService.API/Application/Commands/BlockUserCommand.cs b/services/social-service-net/src/SocialService.API/Application/Commands/BlockUserCommand.cs new file mode 100644 index 00000000..15dfad69 --- /dev/null +++ b/services/social-service-net/src/SocialService.API/Application/Commands/BlockUserCommand.cs @@ -0,0 +1,18 @@ +using MediatR; + +namespace SocialService.API.Application.Commands; + +/// +/// EN: Command to block a user. +/// VI: Command để block user. +/// +/// EN: ID of user blocking / VI: ID user block +/// EN: ID of user being blocked / VI: ID user bị block +/// EN: Optional reason for blocking / VI: Lý do block (tùy chọn) +public record BlockUserCommand(Guid BlockerId, Guid BlockedId, string? Reason = null) : IRequest; + +/// +/// EN: Result of block user command. +/// VI: Kết quả của command block user. +/// +public record BlockUserResult(Guid BlockId, bool Success); diff --git a/services/social-service-net/src/SocialService.API/Application/Commands/BlockUserCommandHandler.cs b/services/social-service-net/src/SocialService.API/Application/Commands/BlockUserCommandHandler.cs new file mode 100644 index 00000000..56feb6be --- /dev/null +++ b/services/social-service-net/src/SocialService.API/Application/Commands/BlockUserCommandHandler.cs @@ -0,0 +1,100 @@ +using MediatR; +using SocialService.Domain.AggregatesModel.RelationshipAggregate; +using SocialService.Domain.AggregatesModel.UserBlockAggregate; +using SocialService.Domain.Exceptions; + +namespace SocialService.API.Application.Commands; + +/// +/// EN: Handler for BlockUserCommand. +/// VI: Handler cho BlockUserCommand. +/// +public class BlockUserCommandHandler : IRequestHandler +{ + private readonly IUserBlockRepository _userBlockRepository; + private readonly IRelationshipRepository _relationshipRepository; + private readonly ILogger _logger; + + public BlockUserCommandHandler( + IUserBlockRepository userBlockRepository, + IRelationshipRepository relationshipRepository, + ILogger logger) + { + _userBlockRepository = userBlockRepository; + _relationshipRepository = relationshipRepository; + _logger = logger; + } + + public async Task Handle(BlockUserCommand request, CancellationToken cancellationToken) + { + // EN: Cannot block yourself + // VI: Không thể block chính mình + if (request.BlockerId == request.BlockedId) + { + throw new SocialDomainException("Cannot block yourself"); + } + + // EN: Check if already blocked + // VI: Kiểm tra xem đã block chưa + var existingBlock = await _userBlockRepository.GetByUsersAsync(request.BlockerId, request.BlockedId); + if (existingBlock != null) + { + throw new SocialDomainException("User is already blocked"); + } + + // EN: Create block + // VI: Tạo block + var block = new UserBlock(request.BlockerId, request.BlockedId, request.Reason); + _userBlockRepository.Add(block); + + // EN: Remove any existing relationships between the users + // VI: Xóa các quan hệ đang tồn tại giữa hai users + await RemoveExistingRelationshipsAsync(request.BlockerId, request.BlockedId); + + await _userBlockRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation("User {BlockerId} blocked {BlockedId}", + request.BlockerId, request.BlockedId); + + return new BlockUserResult(block.Id, true); + } + + private async Task RemoveExistingRelationshipsAsync(Guid userId1, Guid userId2) + { + // EN: Remove friendship in both directions + // VI: Xóa kết bạn theo cả hai hướng + var friendship1 = await _relationshipRepository.GetByUsersAndTypeAsync( + userId1, userId2, RelationshipType.Friendship); + if (friendship1 != null) + { + friendship1.Remove(); + _relationshipRepository.Update(friendship1); + } + + var friendship2 = await _relationshipRepository.GetByUsersAndTypeAsync( + userId2, userId1, RelationshipType.Friendship); + if (friendship2 != null) + { + friendship2.Remove(); + _relationshipRepository.Update(friendship2); + } + + // EN: Remove following in both directions + // VI: Xóa following theo cả hai hướng + var follow1 = await _relationshipRepository.GetByUsersAndTypeAsync( + userId1, userId2, RelationshipType.Following); + if (follow1 != null) + { + follow1.Remove(); + _relationshipRepository.Update(follow1); + } + + var follow2 = await _relationshipRepository.GetByUsersAndTypeAsync( + userId2, userId1, RelationshipType.Following); + if (follow2 != null) + { + follow2.Remove(); + _relationshipRepository.Update(follow2); + } + } +} diff --git a/services/social-service-net/src/SocialService.API/Application/Commands/FollowUserCommand.cs b/services/social-service-net/src/SocialService.API/Application/Commands/FollowUserCommand.cs new file mode 100644 index 00000000..3bd4afc2 --- /dev/null +++ b/services/social-service-net/src/SocialService.API/Application/Commands/FollowUserCommand.cs @@ -0,0 +1,17 @@ +using MediatR; + +namespace SocialService.API.Application.Commands; + +/// +/// EN: Command to follow another user. +/// VI: Command để theo dõi user khác. +/// +/// EN: ID of user following / VI: ID user theo dõi +/// EN: ID of user being followed / VI: ID user được theo dõi +public record FollowUserCommand(Guid FollowerId, Guid FolloweeId) : IRequest; + +/// +/// EN: Result of follow user command. +/// VI: Kết quả của command theo dõi user. +/// +public record FollowUserResult(Guid RelationshipId, bool Success); diff --git a/services/social-service-net/src/SocialService.API/Application/Commands/FollowUserCommandHandler.cs b/services/social-service-net/src/SocialService.API/Application/Commands/FollowUserCommandHandler.cs new file mode 100644 index 00000000..2a44b15c --- /dev/null +++ b/services/social-service-net/src/SocialService.API/Application/Commands/FollowUserCommandHandler.cs @@ -0,0 +1,76 @@ +using MediatR; +using SocialService.Domain.AggregatesModel.RelationshipAggregate; +using SocialService.Domain.AggregatesModel.UserBlockAggregate; +using SocialService.Domain.Exceptions; + +namespace SocialService.API.Application.Commands; + +/// +/// EN: Handler for FollowUserCommand. +/// VI: Handler cho FollowUserCommand. +/// +public class FollowUserCommandHandler : IRequestHandler +{ + private readonly IRelationshipRepository _relationshipRepository; + private readonly IUserBlockRepository _userBlockRepository; + private readonly ILogger _logger; + + public FollowUserCommandHandler( + IRelationshipRepository relationshipRepository, + IUserBlockRepository userBlockRepository, + ILogger logger) + { + _relationshipRepository = relationshipRepository; + _userBlockRepository = userBlockRepository; + _logger = logger; + } + + public async Task Handle(FollowUserCommand request, CancellationToken cancellationToken) + { + // EN: Cannot follow yourself + // VI: Không thể tự follow chính mình + if (request.FollowerId == request.FolloweeId) + { + throw new SocialDomainException("Cannot follow yourself"); + } + + // EN: Check if users have blocked each other + // VI: Kiểm tra xem users có block lẫn nhau không + var hasBlock = await _userBlockRepository.HasBlockBetweenAsync(request.FollowerId, request.FolloweeId); + if (hasBlock) + { + throw new SocialDomainException("Cannot follow blocked user"); + } + + // EN: Check if already following + // VI: Kiểm tra xem đã follow chưa + var existingFollow = await _relationshipRepository.GetByUsersAndTypeAsync( + request.FollowerId, + request.FolloweeId, + RelationshipType.Following); + + if (existingFollow != null && existingFollow.StatusId == RelationshipStatus.Accepted.Id) + { + throw new SocialDomainException("Already following this user"); + } + + // EN: Create new following relationship (auto-accepted) + // VI: Tạo quan hệ following mới (tự động chấp nhận) + var relationship = new Relationship( + request.FollowerId, + request.FolloweeId, + RelationshipType.Following); + + // EN: Following is auto-accepted (no approval needed) + // VI: Following được tự động chấp nhận (không cần phê duyệt) + relationship.Accept(); + + _relationshipRepository.Add(relationship); + await _relationshipRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation("User {FollowerId} started following {FolloweeId}", + request.FollowerId, request.FolloweeId); + + return new FollowUserResult(relationship.Id, true); + } +} diff --git a/services/social-service-net/src/SocialService.API/Application/Commands/RespondToFriendRequestCommand.cs b/services/social-service-net/src/SocialService.API/Application/Commands/RespondToFriendRequestCommand.cs new file mode 100644 index 00000000..8086bdd8 --- /dev/null +++ b/services/social-service-net/src/SocialService.API/Application/Commands/RespondToFriendRequestCommand.cs @@ -0,0 +1,12 @@ +using MediatR; + +namespace SocialService.API.Application.Commands; + +/// +/// EN: Command to respond to a friend request (accept/reject). +/// VI: Command để phản hồi yêu cầu kết bạn (chấp nhận/từ chối). +/// +/// EN: ID of relationship / VI: ID của quan hệ +/// EN: ID of user responding / VI: ID user phản hồi +/// EN: True to accept, false to reject / VI: True để chấp nhận, false để từ chối +public record RespondToFriendRequestCommand(Guid RelationshipId, Guid UserId, bool Accept) : IRequest; diff --git a/services/social-service-net/src/SocialService.API/Application/Commands/RespondToFriendRequestCommandHandler.cs b/services/social-service-net/src/SocialService.API/Application/Commands/RespondToFriendRequestCommandHandler.cs new file mode 100644 index 00000000..342e5c40 --- /dev/null +++ b/services/social-service-net/src/SocialService.API/Application/Commands/RespondToFriendRequestCommandHandler.cs @@ -0,0 +1,58 @@ +using MediatR; +using SocialService.Domain.AggregatesModel.RelationshipAggregate; +using SocialService.Domain.Exceptions; + +namespace SocialService.API.Application.Commands; + +/// +/// EN: Handler for RespondToFriendRequestCommand. +/// VI: Handler cho RespondToFriendRequestCommand. +/// +public class RespondToFriendRequestCommandHandler : IRequestHandler +{ + private readonly IRelationshipRepository _relationshipRepository; + private readonly ILogger _logger; + + public RespondToFriendRequestCommandHandler( + IRelationshipRepository relationshipRepository, + ILogger logger) + { + _relationshipRepository = relationshipRepository; + _logger = logger; + } + + public async Task Handle(RespondToFriendRequestCommand request, CancellationToken cancellationToken) + { + var relationship = await _relationshipRepository.GetByIdAsync(request.RelationshipId); + + if (relationship == null) + { + throw new SocialDomainException("Friend request not found"); + } + + // EN: Verify the user is the addressee + // VI: Xác minh user là người nhận yêu cầu + if (relationship.AddresseeId != request.UserId) + { + throw new SocialDomainException("You can only respond to friend requests sent to you"); + } + + if (request.Accept) + { + relationship.Accept(); + _logger.LogInformation("Friend request {RelationshipId} accepted by {UserId}", + request.RelationshipId, request.UserId); + } + else + { + relationship.Reject(); + _logger.LogInformation("Friend request {RelationshipId} rejected by {UserId}", + request.RelationshipId, request.UserId); + } + + _relationshipRepository.Update(relationship); + await _relationshipRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + return true; + } +} diff --git a/services/social-service-net/src/SocialService.API/Application/Commands/SendFriendRequestCommand.cs b/services/social-service-net/src/SocialService.API/Application/Commands/SendFriendRequestCommand.cs new file mode 100644 index 00000000..ecfd2684 --- /dev/null +++ b/services/social-service-net/src/SocialService.API/Application/Commands/SendFriendRequestCommand.cs @@ -0,0 +1,17 @@ +using MediatR; + +namespace SocialService.API.Application.Commands; + +/// +/// EN: Command to send a friend request. +/// VI: Command để gửi yêu cầu kết bạn. +/// +/// EN: ID of user sending request / VI: ID user gửi yêu cầu +/// EN: ID of user receiving request / VI: ID user nhận yêu cầu +public record SendFriendRequestCommand(Guid RequesterId, Guid AddresseeId) : IRequest; + +/// +/// EN: Result of send friend request command. +/// VI: Kết quả của command gửi yêu cầu kết bạn. +/// +public record SendFriendRequestResult(Guid RelationshipId, string Status); diff --git a/services/social-service-net/src/SocialService.API/Application/Commands/SendFriendRequestCommandHandler.cs b/services/social-service-net/src/SocialService.API/Application/Commands/SendFriendRequestCommandHandler.cs new file mode 100644 index 00000000..e0aa2341 --- /dev/null +++ b/services/social-service-net/src/SocialService.API/Application/Commands/SendFriendRequestCommandHandler.cs @@ -0,0 +1,93 @@ +using MediatR; +using SocialService.Domain.AggregatesModel.RelationshipAggregate; +using SocialService.Domain.AggregatesModel.UserBlockAggregate; +using SocialService.Domain.Exceptions; + +namespace SocialService.API.Application.Commands; + +/// +/// EN: Handler for SendFriendRequestCommand. +/// VI: Handler cho SendFriendRequestCommand. +/// +public class SendFriendRequestCommandHandler : IRequestHandler +{ + private readonly IRelationshipRepository _relationshipRepository; + private readonly IUserBlockRepository _userBlockRepository; + private readonly ILogger _logger; + + public SendFriendRequestCommandHandler( + IRelationshipRepository relationshipRepository, + IUserBlockRepository userBlockRepository, + ILogger logger) + { + _relationshipRepository = relationshipRepository; + _userBlockRepository = userBlockRepository; + _logger = logger; + } + + public async Task Handle(SendFriendRequestCommand request, CancellationToken cancellationToken) + { + // EN: Check if users have blocked each other + // VI: Kiểm tra xem users có block lẫn nhau không + var hasBlock = await _userBlockRepository.HasBlockBetweenAsync(request.RequesterId, request.AddresseeId); + if (hasBlock) + { + throw new SocialDomainException("Cannot send friend request to blocked user"); + } + + // EN: Check if relationship already exists + // VI: Kiểm tra xem quan hệ đã tồn tại chưa + var existingRelationship = await _relationshipRepository.GetByUsersAndTypeAsync( + request.RequesterId, + request.AddresseeId, + RelationshipType.Friendship); + + if (existingRelationship != null) + { + if (existingRelationship.StatusId == RelationshipStatus.Accepted.Id) + { + throw new SocialDomainException("Users are already friends"); + } + if (existingRelationship.StatusId == RelationshipStatus.Pending.Id) + { + throw new SocialDomainException("Friend request already pending"); + } + } + + // EN: Check if reverse relationship exists (other user sent request) + // VI: Kiểm tra xem quan hệ ngược có tồn tại không (user khác đã gửi yêu cầu) + var reverseRelationship = await _relationshipRepository.GetByUsersAndTypeAsync( + request.AddresseeId, + request.RequesterId, + RelationshipType.Friendship); + + if (reverseRelationship != null && reverseRelationship.StatusId == RelationshipStatus.Pending.Id) + { + // EN: Auto-accept if the other user already sent a request + // VI: Tự động chấp nhận nếu user kia đã gửi yêu cầu + reverseRelationship.Accept(); + _relationshipRepository.Update(reverseRelationship); + await _relationshipRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation("Auto-accepted mutual friend request between {RequesterId} and {AddresseeId}", + request.RequesterId, request.AddresseeId); + + return new SendFriendRequestResult(reverseRelationship.Id, "Accepted"); + } + + // EN: Create new friend request + // VI: Tạo yêu cầu kết bạn mới + var relationship = new Relationship( + request.RequesterId, + request.AddresseeId, + RelationshipType.Friendship); + + _relationshipRepository.Add(relationship); + await _relationshipRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation("Friend request sent from {RequesterId} to {AddresseeId}", + request.RequesterId, request.AddresseeId); + + return new SendFriendRequestResult(relationship.Id, "Pending"); + } +} diff --git a/services/social-service-net/src/SocialService.API/Application/Commands/UnblockUserCommand.cs b/services/social-service-net/src/SocialService.API/Application/Commands/UnblockUserCommand.cs new file mode 100644 index 00000000..9a94b434 --- /dev/null +++ b/services/social-service-net/src/SocialService.API/Application/Commands/UnblockUserCommand.cs @@ -0,0 +1,11 @@ +using MediatR; + +namespace SocialService.API.Application.Commands; + +/// +/// EN: Command to unblock a user. +/// VI: Command để bỏ block user. +/// +/// EN: ID of user unblocking / VI: ID user bỏ block +/// EN: ID of user being unblocked / VI: ID user được bỏ block +public record UnblockUserCommand(Guid BlockerId, Guid BlockedId) : IRequest; diff --git a/services/social-service-net/src/SocialService.API/Application/Commands/UnblockUserCommandHandler.cs b/services/social-service-net/src/SocialService.API/Application/Commands/UnblockUserCommandHandler.cs new file mode 100644 index 00000000..73eec4ea --- /dev/null +++ b/services/social-service-net/src/SocialService.API/Application/Commands/UnblockUserCommandHandler.cs @@ -0,0 +1,41 @@ +using MediatR; +using SocialService.Domain.AggregatesModel.UserBlockAggregate; +using SocialService.Domain.Exceptions; + +namespace SocialService.API.Application.Commands; + +/// +/// EN: Handler for UnblockUserCommand. +/// VI: Handler cho UnblockUserCommand. +/// +public class UnblockUserCommandHandler : IRequestHandler +{ + private readonly IUserBlockRepository _userBlockRepository; + private readonly ILogger _logger; + + public UnblockUserCommandHandler( + IUserBlockRepository userBlockRepository, + ILogger logger) + { + _userBlockRepository = userBlockRepository; + _logger = logger; + } + + public async Task Handle(UnblockUserCommand request, CancellationToken cancellationToken) + { + var block = await _userBlockRepository.GetByUsersAsync(request.BlockerId, request.BlockedId); + + if (block == null) + { + throw new SocialDomainException("User is not blocked"); + } + + _userBlockRepository.Remove(block); + await _userBlockRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation("User {BlockerId} unblocked {BlockedId}", + request.BlockerId, request.BlockedId); + + return true; + } +} diff --git a/services/social-service-net/src/SocialService.API/Application/Commands/UnfollowUserCommand.cs b/services/social-service-net/src/SocialService.API/Application/Commands/UnfollowUserCommand.cs new file mode 100644 index 00000000..de445773 --- /dev/null +++ b/services/social-service-net/src/SocialService.API/Application/Commands/UnfollowUserCommand.cs @@ -0,0 +1,11 @@ +using MediatR; + +namespace SocialService.API.Application.Commands; + +/// +/// EN: Command to unfollow a user. +/// VI: Command để bỏ theo dõi user. +/// +/// EN: ID of user unfollowing / VI: ID user bỏ theo dõi +/// EN: ID of user being unfollowed / VI: ID user bị bỏ theo dõi +public record UnfollowUserCommand(Guid FollowerId, Guid FolloweeId) : IRequest; diff --git a/services/social-service-net/src/SocialService.API/Application/Commands/UnfollowUserCommandHandler.cs b/services/social-service-net/src/SocialService.API/Application/Commands/UnfollowUserCommandHandler.cs new file mode 100644 index 00000000..f0eeb807 --- /dev/null +++ b/services/social-service-net/src/SocialService.API/Application/Commands/UnfollowUserCommandHandler.cs @@ -0,0 +1,45 @@ +using MediatR; +using SocialService.Domain.AggregatesModel.RelationshipAggregate; +using SocialService.Domain.Exceptions; + +namespace SocialService.API.Application.Commands; + +/// +/// EN: Handler for UnfollowUserCommand. +/// VI: Handler cho UnfollowUserCommand. +/// +public class UnfollowUserCommandHandler : IRequestHandler +{ + private readonly IRelationshipRepository _relationshipRepository; + private readonly ILogger _logger; + + public UnfollowUserCommandHandler( + IRelationshipRepository relationshipRepository, + ILogger logger) + { + _relationshipRepository = relationshipRepository; + _logger = logger; + } + + public async Task Handle(UnfollowUserCommand request, CancellationToken cancellationToken) + { + var relationship = await _relationshipRepository.GetByUsersAndTypeAsync( + request.FollowerId, + request.FolloweeId, + RelationshipType.Following); + + if (relationship == null) + { + throw new SocialDomainException("Not following this user"); + } + + relationship.Remove(); + _relationshipRepository.Update(relationship); + await _relationshipRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation("User {FollowerId} unfollowed {FolloweeId}", + request.FollowerId, request.FolloweeId); + + return true; + } +} diff --git a/services/social-service-net/src/SocialService.API/Application/Queries/GetBlockedUsersQueryHandler.cs b/services/social-service-net/src/SocialService.API/Application/Queries/GetBlockedUsersQueryHandler.cs new file mode 100644 index 00000000..ed138701 --- /dev/null +++ b/services/social-service-net/src/SocialService.API/Application/Queries/GetBlockedUsersQueryHandler.cs @@ -0,0 +1,52 @@ +using MediatR; +using SocialService.API.Controllers; +using SocialService.Domain.AggregatesModel.UserBlockAggregate; +using SocialService.Domain.AggregatesModel.UserProfileAggregate; + +namespace SocialService.API.Application.Queries; + +/// +/// EN: Handler for GetBlockedUsersQuery. +/// VI: Handler cho GetBlockedUsersQuery. +/// +public class GetBlockedUsersQueryHandler : IRequestHandler +{ + private readonly IUserBlockRepository _userBlockRepository; + private readonly IUserProfileRepository _userProfileRepository; + + public GetBlockedUsersQueryHandler( + IUserBlockRepository userBlockRepository, + IUserProfileRepository userProfileRepository) + { + _userBlockRepository = userBlockRepository; + _userProfileRepository = userProfileRepository; + } + + public async Task Handle(GetBlockedUsersQuery request, CancellationToken cancellationToken) + { + var blocks = await _userBlockRepository.GetBlockedByUserAsync(request.UserId, request.Skip, request.Take); + var totalCount = await _userBlockRepository.GetBlockedCountAsync(request.UserId); + + var blockList = blocks.ToList(); + var blockedUserIds = blockList.Select(b => b.BlockedId).ToList(); + + // EN: Get user profiles for display + // VI: Lấy hồ sơ user để hiển thị + var profiles = await _userProfileRepository.GetByUserIdsAsync(blockedUserIds); + var profileDict = profiles.ToDictionary(p => p.UserId, p => p); + + var blockedUsers = blockList.Select(b => + { + var profile = profileDict.GetValueOrDefault(b.BlockedId); + return new BlockedUserDto( + b.BlockedId, + profile?.DisplayName ?? "Unknown", + profile?.AvatarUrl, + b.CreatedAt, + b.Reason + ); + }); + + return new GetBlockedUsersResult(blockedUsers, totalCount); + } +} diff --git a/services/social-service-net/src/SocialService.API/Application/Queries/GetFriendSuggestionsQuery.cs b/services/social-service-net/src/SocialService.API/Application/Queries/GetFriendSuggestionsQuery.cs new file mode 100644 index 00000000..e7501d78 --- /dev/null +++ b/services/social-service-net/src/SocialService.API/Application/Queries/GetFriendSuggestionsQuery.cs @@ -0,0 +1,24 @@ +using MediatR; +using SocialService.Domain.Services; + +namespace SocialService.API.Application.Queries; + +/// +/// EN: Query to get friend suggestions for a user. +/// VI: Query để lấy gợi ý kết bạn cho user. +/// +/// EN: ID of user / VI: ID của user +/// EN: Maximum suggestions / VI: Số gợi ý tối đa +public record GetFriendSuggestionsQuery(Guid UserId, int Limit = 10) : IRequest; + +/// +/// EN: Result of get friend suggestions query. +/// VI: Kết quả của query lấy gợi ý kết bạn. +/// +public record GetFriendSuggestionsResult(IEnumerable Suggestions); + +/// +/// EN: DTO for friend suggestion. +/// VI: DTO cho gợi ý kết bạn. +/// +public record FriendSuggestionDto(Guid UserId, string DisplayName, string? AvatarUrl, int MutualFriendsCount); diff --git a/services/social-service-net/src/SocialService.API/Application/Queries/GetFriendSuggestionsQueryHandler.cs b/services/social-service-net/src/SocialService.API/Application/Queries/GetFriendSuggestionsQueryHandler.cs new file mode 100644 index 00000000..9f408777 --- /dev/null +++ b/services/social-service-net/src/SocialService.API/Application/Queries/GetFriendSuggestionsQueryHandler.cs @@ -0,0 +1,48 @@ +using MediatR; +using SocialService.Domain.AggregatesModel.UserProfileAggregate; +using SocialService.Domain.Services; + +namespace SocialService.API.Application.Queries; + +/// +/// EN: Handler for GetFriendSuggestionsQuery. +/// VI: Handler cho GetFriendSuggestionsQuery. +/// +public class GetFriendSuggestionsQueryHandler : IRequestHandler +{ + private readonly IGraphQueryService _graphQueryService; + private readonly IUserProfileRepository _userProfileRepository; + + public GetFriendSuggestionsQueryHandler( + IGraphQueryService graphQueryService, + IUserProfileRepository userProfileRepository) + { + _graphQueryService = graphQueryService; + _userProfileRepository = userProfileRepository; + } + + public async Task Handle(GetFriendSuggestionsQuery request, CancellationToken cancellationToken) + { + var suggestions = await _graphQueryService.GetFriendSuggestionsAsync(request.UserId, request.Limit); + var suggestionList = suggestions.ToList(); + + // EN: Get user profiles for display + // VI: Lấy hồ sơ user để hiển thị + var userIds = suggestionList.Select(s => s.UserId).ToList(); + var profiles = await _userProfileRepository.GetByUserIdsAsync(userIds); + var profileDict = profiles.ToDictionary(p => p.UserId, p => p); + + var result = suggestionList.Select(s => + { + var profile = profileDict.GetValueOrDefault(s.UserId); + return new FriendSuggestionDto( + s.UserId, + profile?.DisplayName ?? "Unknown", + profile?.AvatarUrl, + s.MutualFriendsCount + ); + }); + + return new GetFriendSuggestionsResult(result); + } +} diff --git a/services/social-service-net/src/SocialService.API/Application/Queries/GetFriendsQuery.cs b/services/social-service-net/src/SocialService.API/Application/Queries/GetFriendsQuery.cs new file mode 100644 index 00000000..b22323ed --- /dev/null +++ b/services/social-service-net/src/SocialService.API/Application/Queries/GetFriendsQuery.cs @@ -0,0 +1,24 @@ +using MediatR; + +namespace SocialService.API.Application.Queries; + +/// +/// EN: Query to get friends of a user. +/// VI: Query để lấy danh sách bạn bè của user. +/// +/// EN: ID of user / VI: ID của user +/// EN: Number of items to skip / VI: Số item bỏ qua +/// EN: Number of items to take / VI: Số item lấy +public record GetFriendsQuery(Guid UserId, int Skip = 0, int Take = 20) : IRequest; + +/// +/// EN: Result of get friends query. +/// VI: Kết quả của query lấy danh sách bạn bè. +/// +public record GetFriendsResult(IEnumerable Friends, int TotalCount); + +/// +/// EN: DTO for friend. +/// VI: DTO cho bạn bè. +/// +public record FriendDto(Guid UserId, string DisplayName, string? AvatarUrl, DateTime FriendsSince); diff --git a/services/social-service-net/src/SocialService.API/Application/Queries/GetFriendsQueryHandler.cs b/services/social-service-net/src/SocialService.API/Application/Queries/GetFriendsQueryHandler.cs new file mode 100644 index 00000000..372bb52e --- /dev/null +++ b/services/social-service-net/src/SocialService.API/Application/Queries/GetFriendsQueryHandler.cs @@ -0,0 +1,55 @@ +using MediatR; +using SocialService.Domain.AggregatesModel.RelationshipAggregate; +using SocialService.Domain.AggregatesModel.UserProfileAggregate; + +namespace SocialService.API.Application.Queries; + +/// +/// EN: Handler for GetFriendsQuery. +/// VI: Handler cho GetFriendsQuery. +/// +public class GetFriendsQueryHandler : IRequestHandler +{ + private readonly IRelationshipRepository _relationshipRepository; + private readonly IUserProfileRepository _userProfileRepository; + + public GetFriendsQueryHandler( + IRelationshipRepository relationshipRepository, + IUserProfileRepository userProfileRepository) + { + _relationshipRepository = relationshipRepository; + _userProfileRepository = userProfileRepository; + } + + public async Task Handle(GetFriendsQuery request, CancellationToken cancellationToken) + { + var friendships = await _relationshipRepository.GetFriendsAsync(request.UserId, request.Skip, request.Take); + var totalCount = await _relationshipRepository.GetFriendsCountAsync(request.UserId); + + // EN: Extract friend user IDs + // VI: Trích xuất ID của bạn bè + var friendUserIds = friendships.Select(f => + f.RequesterId == request.UserId ? f.AddresseeId : f.RequesterId + ).ToList(); + + // EN: Get user profiles for display + // VI: Lấy hồ sơ user để hiển thị + var profiles = await _userProfileRepository.GetByUserIdsAsync(friendUserIds); + var profileDict = profiles.ToDictionary(p => p.UserId, p => p); + + var friends = friendships.Select(f => + { + var friendId = f.RequesterId == request.UserId ? f.AddresseeId : f.RequesterId; + var profile = profileDict.GetValueOrDefault(friendId); + + return new FriendDto( + friendId, + profile?.DisplayName ?? "Unknown", + profile?.AvatarUrl, + f.CreatedAt + ); + }); + + return new GetFriendsResult(friends, totalCount); + } +} diff --git a/services/social-service-net/src/SocialService.API/Application/Queries/GetMutualFriendsQuery.cs b/services/social-service-net/src/SocialService.API/Application/Queries/GetMutualFriendsQuery.cs new file mode 100644 index 00000000..be002796 --- /dev/null +++ b/services/social-service-net/src/SocialService.API/Application/Queries/GetMutualFriendsQuery.cs @@ -0,0 +1,17 @@ +using MediatR; + +namespace SocialService.API.Application.Queries; + +/// +/// EN: Query to get mutual friends between two users. +/// VI: Query để lấy danh sách bạn chung giữa hai users. +/// +/// EN: First user ID / VI: ID user thứ nhất +/// EN: Second user ID / VI: ID user thứ hai +public record GetMutualFriendsQuery(Guid UserId1, Guid UserId2) : IRequest; + +/// +/// EN: Result of get mutual friends query. +/// VI: Kết quả của query lấy bạn chung. +/// +public record GetMutualFriendsResult(IEnumerable MutualFriends, int Count); diff --git a/services/social-service-net/src/SocialService.API/Application/Queries/GetMutualFriendsQueryHandler.cs b/services/social-service-net/src/SocialService.API/Application/Queries/GetMutualFriendsQueryHandler.cs new file mode 100644 index 00000000..4f54b596 --- /dev/null +++ b/services/social-service-net/src/SocialService.API/Application/Queries/GetMutualFriendsQueryHandler.cs @@ -0,0 +1,47 @@ +using MediatR; +using SocialService.Domain.AggregatesModel.UserProfileAggregate; +using SocialService.Domain.Services; + +namespace SocialService.API.Application.Queries; + +/// +/// EN: Handler for GetMutualFriendsQuery. +/// VI: Handler cho GetMutualFriendsQuery. +/// +public class GetMutualFriendsQueryHandler : IRequestHandler +{ + private readonly IGraphQueryService _graphQueryService; + private readonly IUserProfileRepository _userProfileRepository; + + public GetMutualFriendsQueryHandler( + IGraphQueryService graphQueryService, + IUserProfileRepository userProfileRepository) + { + _graphQueryService = graphQueryService; + _userProfileRepository = userProfileRepository; + } + + public async Task Handle(GetMutualFriendsQuery request, CancellationToken cancellationToken) + { + var mutualFriendIds = await _graphQueryService.GetMutualFriendsAsync(request.UserId1, request.UserId2); + var mutualFriendIdList = mutualFriendIds.ToList(); + + // EN: Get user profiles for display + // VI: Lấy hồ sơ user để hiển thị + var profiles = await _userProfileRepository.GetByUserIdsAsync(mutualFriendIdList); + var profileDict = profiles.ToDictionary(p => p.UserId, p => p); + + var mutualFriends = mutualFriendIdList.Select(id => + { + var profile = profileDict.GetValueOrDefault(id); + return new FriendDto( + id, + profile?.DisplayName ?? "Unknown", + profile?.AvatarUrl, + profile?.CreatedAt ?? DateTime.UtcNow + ); + }); + + return new GetMutualFriendsResult(mutualFriends, mutualFriendIdList.Count); + } +} diff --git a/services/social-service-net/src/SocialService.API/Controllers/BlocksController.cs b/services/social-service-net/src/SocialService.API/Controllers/BlocksController.cs new file mode 100644 index 00000000..f94d4c1d --- /dev/null +++ b/services/social-service-net/src/SocialService.API/Controllers/BlocksController.cs @@ -0,0 +1,79 @@ +using MediatR; +using Microsoft.AspNetCore.Mvc; +using SocialService.API.Application.Commands; + +namespace SocialService.API.Controllers; + +/// +/// EN: Controller for managing user blocks. +/// VI: Controller để quản lý block users. +/// +[ApiController] +[Route("api/v1/[controller]")] +public class BlocksController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public BlocksController(IMediator mediator, ILogger logger) + { + _mediator = mediator; + _logger = logger; + } + + /// + /// EN: Block a user. + /// VI: Block user. + /// + [HttpPost] + [ProducesResponseType(typeof(BlockUserResult), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task BlockUser([FromBody] BlockUserRequest request) + { + var command = new BlockUserCommand(request.BlockerId, request.BlockedId, request.Reason); + var result = await _mediator.Send(command); + return CreatedAtAction(nameof(GetBlockedUsers), new { userId = request.BlockerId }, result); + } + + /// + /// EN: Unblock a user. + /// VI: Bỏ block user. + /// + [HttpDelete] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task UnblockUser([FromBody] UnblockUserRequest request) + { + var command = new UnblockUserCommand(request.BlockerId, request.BlockedId); + var result = await _mediator.Send(command); + return Ok(new { success = result }); + } + + /// + /// EN: Get list of blocked users. + /// VI: Lấy danh sách users bị block. + /// + [HttpGet("users/{userId}")] + [ProducesResponseType(typeof(GetBlockedUsersResult), StatusCodes.Status200OK)] + public async Task GetBlockedUsers(Guid userId, [FromQuery] int skip = 0, [FromQuery] int take = 20) + { + var query = new GetBlockedUsersQuery(userId, skip, take); + var result = await _mediator.Send(query); + return Ok(result); + } +} + +#region Request DTOs + +public record BlockUserRequest(Guid BlockerId, Guid BlockedId, string? Reason = null); +public record UnblockUserRequest(Guid BlockerId, Guid BlockedId); + +#endregion + +#region Query Classes + +public record GetBlockedUsersQuery(Guid UserId, int Skip = 0, int Take = 20) : IRequest; +public record GetBlockedUsersResult(IEnumerable BlockedUsers, int TotalCount); +public record BlockedUserDto(Guid UserId, string DisplayName, string? AvatarUrl, DateTime BlockedAt, string? Reason); + +#endregion diff --git a/services/social-service-net/src/SocialService.API/Controllers/RelationshipsController.cs b/services/social-service-net/src/SocialService.API/Controllers/RelationshipsController.cs new file mode 100644 index 00000000..1e7dabe1 --- /dev/null +++ b/services/social-service-net/src/SocialService.API/Controllers/RelationshipsController.cs @@ -0,0 +1,137 @@ +using MediatR; +using Microsoft.AspNetCore.Mvc; +using SocialService.API.Application.Commands; +using SocialService.API.Application.Queries; + +namespace SocialService.API.Controllers; + +/// +/// EN: Controller for managing relationships (friends, following). +/// VI: Controller để quản lý quan hệ (bạn bè, theo dõi). +/// +[ApiController] +[Route("api/v1/[controller]")] +public class RelationshipsController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public RelationshipsController(IMediator mediator, ILogger logger) + { + _mediator = mediator; + _logger = logger; + } + + #region Friends + + /// + /// EN: Get friends of a user. + /// VI: Lấy danh sách bạn bè của user. + /// + [HttpGet("users/{userId}/friends")] + [ProducesResponseType(typeof(GetFriendsResult), StatusCodes.Status200OK)] + public async Task GetFriends(Guid userId, [FromQuery] int skip = 0, [FromQuery] int take = 20) + { + var query = new GetFriendsQuery(userId, skip, take); + var result = await _mediator.Send(query); + return Ok(result); + } + + /// + /// EN: Send a friend request. + /// VI: Gửi yêu cầu kết bạn. + /// + [HttpPost("friend-requests")] + [ProducesResponseType(typeof(SendFriendRequestResult), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task SendFriendRequest([FromBody] SendFriendRequestRequest request) + { + var command = new SendFriendRequestCommand(request.RequesterId, request.AddresseeId); + var result = await _mediator.Send(command); + return CreatedAtAction(nameof(GetFriends), new { userId = request.RequesterId }, result); + } + + /// + /// EN: Respond to a friend request (accept/reject). + /// VI: Phản hồi yêu cầu kết bạn (chấp nhận/từ chối). + /// + [HttpPut("friend-requests/{relationshipId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task RespondToFriendRequest(Guid relationshipId, [FromBody] RespondToFriendRequestRequest request) + { + var command = new RespondToFriendRequestCommand(relationshipId, request.UserId, request.Accept); + var result = await _mediator.Send(command); + return Ok(new { success = result }); + } + + /// + /// EN: Get mutual friends between two users. + /// VI: Lấy bạn chung giữa hai users. + /// + [HttpGet("users/{userId1}/mutual-friends/{userId2}")] + [ProducesResponseType(typeof(GetMutualFriendsResult), StatusCodes.Status200OK)] + public async Task GetMutualFriends(Guid userId1, Guid userId2) + { + var query = new GetMutualFriendsQuery(userId1, userId2); + var result = await _mediator.Send(query); + return Ok(result); + } + + /// + /// EN: Get friend suggestions for a user. + /// VI: Lấy gợi ý kết bạn cho user. + /// + [HttpGet("users/{userId}/suggestions")] + [ProducesResponseType(typeof(GetFriendSuggestionsResult), StatusCodes.Status200OK)] + public async Task GetFriendSuggestions(Guid userId, [FromQuery] int limit = 10) + { + var query = new GetFriendSuggestionsQuery(userId, limit); + var result = await _mediator.Send(query); + return Ok(result); + } + + #endregion + + #region Following + + /// + /// EN: Follow a user. + /// VI: Theo dõi user. + /// + [HttpPost("follow")] + [ProducesResponseType(typeof(FollowUserResult), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task FollowUser([FromBody] FollowUserRequest request) + { + var command = new FollowUserCommand(request.FollowerId, request.FolloweeId); + var result = await _mediator.Send(command); + return CreatedAtAction(nameof(GetFriends), new { userId = request.FollowerId }, result); + } + + /// + /// EN: Unfollow a user. + /// VI: Bỏ theo dõi user. + /// + [HttpDelete("follow")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task UnfollowUser([FromBody] UnfollowUserRequest request) + { + var command = new UnfollowUserCommand(request.FollowerId, request.FolloweeId); + var result = await _mediator.Send(command); + return Ok(new { success = result }); + } + + #endregion +} + +#region Request DTOs + +public record SendFriendRequestRequest(Guid RequesterId, Guid AddresseeId); +public record RespondToFriendRequestRequest(Guid UserId, bool Accept); +public record FollowUserRequest(Guid FollowerId, Guid FolloweeId); +public record UnfollowUserRequest(Guid FollowerId, Guid FolloweeId); + +#endregion diff --git a/services/social-service-net/src/SocialService.API/Program.cs b/services/social-service-net/src/SocialService.API/Program.cs new file mode 100644 index 00000000..7f875345 --- /dev/null +++ b/services/social-service-net/src/SocialService.API/Program.cs @@ -0,0 +1,144 @@ +using Asp.Versioning; +using FluentValidation; +using Hellang.Middleware.ProblemDetails; +using SocialService.API.Application.Behaviors; +using SocialService.Infrastructure; +using Serilog; + +// EN: Configure Serilog early / VI: Cấu hình Serilog sớm +Log.Logger = new LoggerConfiguration() + .WriteTo.Console() + .CreateBootstrapLogger(); + +try +{ + Log.Information("Starting SocialService API / Khởi động SocialService API"); + + var builder = WebApplication.CreateBuilder(args); + + // EN: Configure Serilog / VI: Cấu hình Serilog + builder.Host.UseSerilog((context, services, configuration) => configuration + .ReadFrom.Configuration(context.Configuration) + .ReadFrom.Services(services) + .Enrich.FromLogContext() + .WriteTo.Console()); + + // EN: Add Infrastructure services / VI: Thêm Infrastructure services + builder.Services.AddInfrastructure(builder.Configuration); + + // EN: Add MediatR with behaviors / VI: Thêm MediatR với behaviors + builder.Services.AddMediatR(cfg => + { + cfg.RegisterServicesFromAssemblyContaining(); + cfg.AddOpenBehavior(typeof(LoggingBehavior<,>)); + cfg.AddOpenBehavior(typeof(ValidatorBehavior<,>)); + cfg.AddOpenBehavior(typeof(TransactionBehavior<,>)); + }); + + // EN: Add FluentValidation / VI: Thêm FluentValidation + builder.Services.AddValidatorsFromAssemblyContaining(); + + // EN: Add API versioning / VI: Thêm API versioning + builder.Services.AddApiVersioning(options => + { + options.DefaultApiVersion = new ApiVersion(1, 0); + options.AssumeDefaultVersionWhenUnspecified = true; + options.ReportApiVersions = true; + options.ApiVersionReader = ApiVersionReader.Combine( + new UrlSegmentApiVersionReader(), + new HeaderApiVersionReader("X-Api-Version")); + }) + .AddApiExplorer(options => + { + options.GroupNameFormat = "'v'VVV"; + options.SubstituteApiVersionInUrl = true; + }); + + // EN: Add controllers / VI: Thêm controllers + builder.Services.AddControllers(); + + // EN: Add ProblemDetails middleware (RFC 7807) / VI: Thêm ProblemDetails middleware + builder.Services.AddProblemDetails(options => + { + options.IncludeExceptionDetails = (ctx, ex) => + builder.Environment.IsDevelopment(); + }); + + // EN: Add Swagger / VI: Thêm Swagger + builder.Services.AddEndpointsApiExplorer(); + builder.Services.AddSwaggerGen(options => + { + options.SwaggerDoc("v1", new() + { + Title = "SocialService API", + Version = "v1", + Description = "SocialService microservice API / API microservice SocialService" + }); + }); + + // EN: Add health checks / VI: Thêm health checks + builder.Services.AddHealthChecks() + .AddNpgSql( + builder.Configuration.GetConnectionString("DefaultConnection") + ?? builder.Configuration["DATABASE_URL"] + ?? "", + name: "postgresql", + tags: ["db", "postgresql"]); + + // EN: Add CORS / VI: Thêm CORS + builder.Services.AddCors(options => + { + options.AddDefaultPolicy(policy => + { + policy.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); + }); + + var app = builder.Build(); + + // EN: Configure middleware pipeline / VI: Cấu hình middleware pipeline + app.UseSerilogRequestLogging(); + app.UseProblemDetails(); + + if (app.Environment.IsDevelopment()) + { + app.UseSwagger(); + app.UseSwaggerUI(c => + { + c.SwaggerEndpoint("/swagger/v1/swagger.json", "SocialService API v1"); + c.RoutePrefix = "swagger"; + }); + } + + app.UseCors(); + app.UseRouting(); + + // EN: Map health check endpoints / VI: Map health check endpoints + app.MapHealthChecks("/health"); + app.MapHealthChecks("/health/live", new() + { + Predicate = _ => false // EN: Just checks app is running / VI: Chỉ kiểm tra app đang chạy + }); + app.MapHealthChecks("/health/ready"); + + // EN: Map controllers / VI: Map controllers + app.MapControllers(); + + // EN: Run the application / VI: Chạy ứng dụng + app.Run(); +} +catch (Exception ex) +{ + Log.Fatal(ex, "Application terminated unexpectedly / Ứng dụng kết thúc bất ngờ"); + throw; +} +finally +{ + Log.CloseAndFlush(); +} + +// EN: Make Program class accessible for integration tests +// VI: Làm cho class Program có thể truy cập cho integration tests +public partial class Program { } diff --git a/services/social-service-net/src/SocialService.API/Properties/launchSettings.json b/services/social-service-net/src/SocialService.API/Properties/launchSettings.json new file mode 100644 index 00000000..6355d40b --- /dev/null +++ b/services/social-service-net/src/SocialService.API/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} \ No newline at end of file diff --git a/services/social-service-net/src/SocialService.API/SocialService.API.csproj b/services/social-service-net/src/SocialService.API/SocialService.API.csproj new file mode 100644 index 00000000..a2c7f8ac --- /dev/null +++ b/services/social-service-net/src/SocialService.API/SocialService.API.csproj @@ -0,0 +1,47 @@ + + + + SocialService.API + SocialService.API + Web API layer with CQRS pattern + socialservice-api + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/services/social-service-net/src/SocialService.API/appsettings.Development.json b/services/social-service-net/src/SocialService.API/appsettings.Development.json new file mode 100644 index 00000000..e407ac85 --- /dev/null +++ b/services/social-service-net/src/SocialService.API/appsettings.Development.json @@ -0,0 +1,19 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft.AspNetCore": "Information", + "Microsoft.EntityFrameworkCore.Database.Command": "Information" + } + }, + "Serilog": { + "MinimumLevel": { + "Default": "Debug", + "Override": { + "Microsoft": "Information", + "Microsoft.EntityFrameworkCore.Database.Command": "Information", + "System": "Information" + } + } + } +} \ No newline at end of file diff --git a/services/social-service-net/src/SocialService.API/appsettings.json b/services/social-service-net/src/SocialService.API/appsettings.json new file mode 100644 index 00000000..7bc8739d --- /dev/null +++ b/services/social-service-net/src/SocialService.API/appsettings.json @@ -0,0 +1,46 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + }, + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "Microsoft.EntityFrameworkCore": "Warning", + "System": "Warning" + } + }, + "WriteTo": [ + { + "Name": "Console", + "Args": { + "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}" + } + } + ], + "Enrich": [ + "FromLogContext", + "WithMachineName", + "WithThreadId" + ] + }, + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Port=5432;Database=socialservice_db;Username=postgres;Password=postgres" + }, + "Redis": { + "ConnectionString": "localhost:6379" + }, + "Jwt": { + "Secret": "your-super-secret-key-min-32-characters", + "Issuer": "goodgo-platform", + "Audience": "goodgo-services", + "AccessTokenExpiryMinutes": 15, + "RefreshTokenExpiryDays": 7 + }, + "AllowedHosts": "*" +} \ No newline at end of file diff --git a/services/social-service-net/src/SocialService.Domain/AggregatesModel/RelationshipAggregate/IRelationshipRepository.cs b/services/social-service-net/src/SocialService.Domain/AggregatesModel/RelationshipAggregate/IRelationshipRepository.cs new file mode 100644 index 00000000..5fb2bb82 --- /dev/null +++ b/services/social-service-net/src/SocialService.Domain/AggregatesModel/RelationshipAggregate/IRelationshipRepository.cs @@ -0,0 +1,94 @@ +using SocialService.Domain.SeedWork; + +namespace SocialService.Domain.AggregatesModel.RelationshipAggregate; + +/// +/// EN: Repository interface for Relationship aggregate. +/// VI: Interface repository cho Relationship aggregate. +/// +public interface IRelationshipRepository : IRepository +{ + /// + /// EN: Add a new relationship. + /// VI: Thêm một quan hệ mới. + /// + Relationship Add(Relationship relationship); + + /// + /// EN: Update an existing relationship. + /// VI: Cập nhật một quan hệ đã tồn tại. + /// + Relationship Update(Relationship relationship); + + /// + /// EN: Get relationship by ID. + /// VI: Lấy quan hệ theo ID. + /// + Task GetByIdAsync(Guid id); + + /// + /// EN: Get relationship between two users of a specific type. + /// VI: Lấy quan hệ giữa hai users theo loại cụ thể. + /// + Task GetByUsersAndTypeAsync(Guid requesterId, Guid addresseeId, RelationshipType type); + + /// + /// EN: Get all friends of a user (accepted friendships). + /// VI: Lấy tất cả bạn bè của user (kết bạn đã chấp nhận). + /// + Task> GetFriendsAsync(Guid userId, int skip = 0, int take = 20); + + /// + /// EN: Get all followers of a user. + /// VI: Lấy tất cả followers của user. + /// + Task> GetFollowersAsync(Guid userId, int skip = 0, int take = 20); + + /// + /// EN: Get all users that a user is following. + /// VI: Lấy tất cả users mà user đang theo dõi. + /// + Task> GetFollowingAsync(Guid userId, int skip = 0, int take = 20); + + /// + /// EN: Get pending friend requests received by a user. + /// VI: Lấy các yêu cầu kết bạn đang chờ mà user nhận được. + /// + Task> GetPendingRequestsReceivedAsync(Guid userId, int skip = 0, int take = 20); + + /// + /// EN: Get pending friend requests sent by a user. + /// VI: Lấy các yêu cầu kết bạn đang chờ mà user đã gửi. + /// + Task> GetPendingRequestsSentAsync(Guid userId, int skip = 0, int take = 20); + + /// + /// EN: Check if two users are friends. + /// VI: Kiểm tra hai users có phải bạn bè không. + /// + Task AreFriendsAsync(Guid userId1, Guid userId2); + + /// + /// EN: Check if a user is following another user. + /// VI: Kiểm tra user có đang theo dõi user khác không. + /// + Task IsFollowingAsync(Guid followerId, Guid followeeId); + + /// + /// EN: Get count of friends for a user. + /// VI: Lấy số lượng bạn bè của user. + /// + Task GetFriendsCountAsync(Guid userId); + + /// + /// EN: Get count of followers for a user. + /// VI: Lấy số lượng followers của user. + /// + Task GetFollowersCountAsync(Guid userId); + + /// + /// EN: Get count of following for a user. + /// VI: Lấy số lượng following của user. + /// + Task GetFollowingCountAsync(Guid userId); +} diff --git a/services/social-service-net/src/SocialService.Domain/AggregatesModel/RelationshipAggregate/Relationship.cs b/services/social-service-net/src/SocialService.Domain/AggregatesModel/RelationshipAggregate/Relationship.cs new file mode 100644 index 00000000..5012f8f3 --- /dev/null +++ b/services/social-service-net/src/SocialService.Domain/AggregatesModel/RelationshipAggregate/Relationship.cs @@ -0,0 +1,189 @@ +using SocialService.Domain.Events; +using SocialService.Domain.Exceptions; +using SocialService.Domain.SeedWork; + +namespace SocialService.Domain.AggregatesModel.RelationshipAggregate; + +/// +/// EN: Aggregate root for managing relationships between users (friendship, following). +/// VI: Aggregate root để quản lý quan hệ giữa các users (kết bạn, theo dõi). +/// +public class Relationship : Entity, IAggregateRoot +{ + // EN: Private fields for encapsulation + // VI: Fields private để đóng gói + private Guid _requesterId; + private Guid _addresseeId; + private RelationshipType _type = null!; + private RelationshipStatus _status = null!; + private DateTime _createdAt; + private DateTime? _updatedAt; + + /// + /// EN: ID of the user who initiated the relationship. + /// VI: ID của user đã khởi tạo quan hệ. + /// + public Guid RequesterId => _requesterId; + + /// + /// EN: ID of the user who received the relationship request. + /// VI: ID của user nhận được yêu cầu quan hệ. + /// + public Guid AddresseeId => _addresseeId; + + /// + /// EN: Type of relationship (Friendship, Following). + /// VI: Loại quan hệ (Kết bạn, Theo dõi). + /// + public RelationshipType Type => _type; + + /// + /// EN: Type ID for EF Core mapping. + /// VI: ID type cho EF Core mapping. + /// + public int TypeId { get; private set; } + + /// + /// EN: Current status of the relationship. + /// VI: Trạng thái hiện tại của quan hệ. + /// + public RelationshipStatus 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: 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 Relationship() { } + + /// + /// EN: Create a new relationship (friend request or follow). + /// VI: Tạo một quan hệ mới (yêu cầu kết bạn hoặc theo dõi). + /// + /// EN: Requester user ID / VI: ID user gửi yêu cầu + /// EN: Addressee user ID / VI: ID user nhận yêu cầu + /// EN: Relationship type / VI: Loại quan hệ + public Relationship(Guid requesterId, Guid addresseeId, RelationshipType type) : this() + { + if (requesterId == Guid.Empty) + throw new SocialDomainException("Requester ID cannot be empty"); + if (addresseeId == Guid.Empty) + throw new SocialDomainException("Addressee ID cannot be empty"); + if (requesterId == addresseeId) + throw new SocialDomainException("Cannot create relationship with yourself"); + + Id = Guid.NewGuid(); + _requesterId = requesterId; + _addresseeId = addresseeId; + _type = type ?? throw new SocialDomainException("Relationship type is required"); + TypeId = type.Id; + + // EN: Following is auto-accepted, Friendship requires acceptance + // VI: Following tự động chấp nhận, Friendship cần chấp nhận + if (type == RelationshipType.Following) + { + _status = RelationshipStatus.Accepted; + StatusId = RelationshipStatus.Accepted.Id; + AddDomainEvent(new UserFollowedDomainEvent(this)); + } + else + { + _status = RelationshipStatus.Pending; + StatusId = RelationshipStatus.Pending.Id; + AddDomainEvent(new FriendRequestSentDomainEvent(this)); + } + + _createdAt = DateTime.UtcNow; + } + + /// + /// EN: Accept the relationship request (for friendship). + /// VI: Chấp nhận yêu cầu quan hệ (cho kết bạn). + /// + public void Accept() + { + if (_type != RelationshipType.Friendship) + throw new SocialDomainException("Only friendship requests can be accepted"); + if (_status != RelationshipStatus.Pending) + throw new SocialDomainException("Only pending requests can be accepted"); + + var previousStatus = _status; + _status = RelationshipStatus.Accepted; + StatusId = RelationshipStatus.Accepted.Id; + _updatedAt = DateTime.UtcNow; + + AddDomainEvent(new FriendshipCreatedDomainEvent(this)); + AddDomainEvent(new RelationshipStatusChangedDomainEvent(Id, previousStatus, _status)); + } + + /// + /// EN: Reject the relationship request (for friendship). + /// VI: Từ chối yêu cầu quan hệ (cho kết bạn). + /// + public void Reject() + { + if (_type != RelationshipType.Friendship) + throw new SocialDomainException("Only friendship requests can be rejected"); + if (_status != RelationshipStatus.Pending) + throw new SocialDomainException("Only pending requests can be rejected"); + + var previousStatus = _status; + _status = RelationshipStatus.Rejected; + StatusId = RelationshipStatus.Rejected.Id; + _updatedAt = DateTime.UtcNow; + + AddDomainEvent(new RelationshipStatusChangedDomainEvent(Id, previousStatus, _status)); + } + + /// + /// EN: Cancel the relationship request (by requester). + /// VI: Hủy yêu cầu quan hệ (bởi người gửi). + /// + public void Cancel() + { + if (_status != RelationshipStatus.Pending) + throw new SocialDomainException("Only pending requests can be cancelled"); + + var previousStatus = _status; + _status = RelationshipStatus.Cancelled; + StatusId = RelationshipStatus.Cancelled.Id; + _updatedAt = DateTime.UtcNow; + + AddDomainEvent(new RelationshipStatusChangedDomainEvent(Id, previousStatus, _status)); + } + + /// + /// EN: Remove the relationship (unfriend or unfollow). + /// VI: Xóa quan hệ (hủy kết bạn hoặc hủy theo dõi). + /// + public void Remove() + { + if (_status != RelationshipStatus.Accepted) + throw new SocialDomainException("Only accepted relationships can be removed"); + + var previousStatus = _status; + _status = RelationshipStatus.Cancelled; + StatusId = RelationshipStatus.Cancelled.Id; + _updatedAt = DateTime.UtcNow; + + AddDomainEvent(new RelationshipRemovedDomainEvent(this)); + AddDomainEvent(new RelationshipStatusChangedDomainEvent(Id, previousStatus, _status)); + } +} diff --git a/services/social-service-net/src/SocialService.Domain/AggregatesModel/RelationshipAggregate/RelationshipStatus.cs b/services/social-service-net/src/SocialService.Domain/AggregatesModel/RelationshipAggregate/RelationshipStatus.cs new file mode 100644 index 00000000..6fc424da --- /dev/null +++ b/services/social-service-net/src/SocialService.Domain/AggregatesModel/RelationshipAggregate/RelationshipStatus.cs @@ -0,0 +1,36 @@ +using SocialService.Domain.SeedWork; + +namespace SocialService.Domain.AggregatesModel.RelationshipAggregate; + +/// +/// EN: Enumeration for relationship statuses. +/// VI: Enumeration cho các trạng thái quan hệ. +/// +public class RelationshipStatus : Enumeration +{ + /// + /// EN: Pending - waiting for acceptance. + /// VI: Đang chờ - chờ được chấp nhận. + /// + public static RelationshipStatus Pending = new(1, nameof(Pending)); + + /// + /// EN: Accepted - relationship is active. + /// VI: Đã chấp nhận - quan hệ đang hoạt động. + /// + public static RelationshipStatus Accepted = new(2, nameof(Accepted)); + + /// + /// EN: Rejected - request was rejected. + /// VI: Đã từ chối - yêu cầu bị từ chối. + /// + public static RelationshipStatus Rejected = new(3, nameof(Rejected)); + + /// + /// EN: Cancelled - request was cancelled by requester. + /// VI: Đã hủy - yêu cầu bị hủy bởi người gửi. + /// + public static RelationshipStatus Cancelled = new(4, nameof(Cancelled)); + + public RelationshipStatus(int id, string name) : base(id, name) { } +} diff --git a/services/social-service-net/src/SocialService.Domain/AggregatesModel/RelationshipAggregate/RelationshipType.cs b/services/social-service-net/src/SocialService.Domain/AggregatesModel/RelationshipAggregate/RelationshipType.cs new file mode 100644 index 00000000..c143e1dc --- /dev/null +++ b/services/social-service-net/src/SocialService.Domain/AggregatesModel/RelationshipAggregate/RelationshipType.cs @@ -0,0 +1,24 @@ +using SocialService.Domain.SeedWork; + +namespace SocialService.Domain.AggregatesModel.RelationshipAggregate; + +/// +/// EN: Enumeration for relationship types (Friendship, Following). +/// VI: Enumeration cho các loại quan hệ (Kết bạn, Theo dõi). +/// +public class RelationshipType : Enumeration +{ + /// + /// EN: Friendship relationship (bidirectional, requires acceptance). + /// VI: Quan hệ bạn bè (hai chiều, cần chấp nhận). + /// + public static RelationshipType Friendship = new(1, nameof(Friendship)); + + /// + /// EN: Following relationship (unidirectional, no acceptance needed). + /// VI: Quan hệ theo dõi (một chiều, không cần chấp nhận). + /// + public static RelationshipType Following = new(2, nameof(Following)); + + public RelationshipType(int id, string name) : base(id, name) { } +} diff --git a/services/social-service-net/src/SocialService.Domain/AggregatesModel/UserBlockAggregate/IUserBlockRepository.cs b/services/social-service-net/src/SocialService.Domain/AggregatesModel/UserBlockAggregate/IUserBlockRepository.cs new file mode 100644 index 00000000..f9a503cf --- /dev/null +++ b/services/social-service-net/src/SocialService.Domain/AggregatesModel/UserBlockAggregate/IUserBlockRepository.cs @@ -0,0 +1,58 @@ +using SocialService.Domain.SeedWork; + +namespace SocialService.Domain.AggregatesModel.UserBlockAggregate; + +/// +/// EN: Repository interface for UserBlock aggregate. +/// VI: Interface repository cho UserBlock aggregate. +/// +public interface IUserBlockRepository : IRepository +{ + /// + /// EN: Add a new user block. + /// VI: Thêm một block mới. + /// + UserBlock Add(UserBlock block); + + /// + /// EN: Remove a user block. + /// VI: Xóa một block. + /// + void Remove(UserBlock block); + + /// + /// EN: Get block by ID. + /// VI: Lấy block theo ID. + /// + Task GetByIdAsync(Guid id); + + /// + /// EN: Get block between two users. + /// VI: Lấy block giữa hai users. + /// + Task GetByUsersAsync(Guid blockerId, Guid blockedId); + + /// + /// EN: Get all users blocked by a user. + /// VI: Lấy tất cả users bị block bởi một user. + /// + Task> GetBlockedByUserAsync(Guid blockerId, int skip = 0, int take = 20); + + /// + /// EN: Check if a user has blocked another user. + /// VI: Kiểm tra user có block user khác không. + /// + Task IsBlockedAsync(Guid blockerId, Guid blockedId); + + /// + /// EN: Check if either user has blocked the other. + /// VI: Kiểm tra một trong hai user có block lẫn nhau không. + /// + Task HasBlockBetweenAsync(Guid userId1, Guid userId2); + + /// + /// EN: Get count of blocked users by a user. + /// VI: Lấy số lượng users bị block bởi user. + /// + Task GetBlockedCountAsync(Guid blockerId); +} diff --git a/services/social-service-net/src/SocialService.Domain/AggregatesModel/UserBlockAggregate/UserBlock.cs b/services/social-service-net/src/SocialService.Domain/AggregatesModel/UserBlockAggregate/UserBlock.cs new file mode 100644 index 00000000..4881bf46 --- /dev/null +++ b/services/social-service-net/src/SocialService.Domain/AggregatesModel/UserBlockAggregate/UserBlock.cs @@ -0,0 +1,74 @@ +using SocialService.Domain.Events; +using SocialService.Domain.Exceptions; +using SocialService.Domain.SeedWork; + +namespace SocialService.Domain.AggregatesModel.UserBlockAggregate; + +/// +/// EN: Aggregate root for managing user blocks. +/// VI: Aggregate root để quản lý việc block user. +/// +public class UserBlock : Entity, IAggregateRoot +{ + // EN: Private fields for encapsulation + // VI: Fields private để đóng gói + private Guid _blockerId; + private Guid _blockedId; + private string? _reason; + private DateTime _createdAt; + + /// + /// EN: ID of the user who blocked. + /// VI: ID của user đã block. + /// + public Guid BlockerId => _blockerId; + + /// + /// EN: ID of the user who was blocked. + /// VI: ID của user bị block. + /// + public Guid BlockedId => _blockedId; + + /// + /// EN: Optional reason for blocking. + /// VI: Lý do block (tùy chọn). + /// + public string? Reason => _reason; + + /// + /// EN: Creation timestamp. + /// VI: Thời gian tạo. + /// + public DateTime CreatedAt => _createdAt; + + /// + /// EN: Private constructor for EF Core. + /// VI: Constructor private cho EF Core. + /// + protected UserBlock() { } + + /// + /// EN: Create a new user block. + /// VI: Tạo một block user mới. + /// + /// EN: Blocker user ID / VI: ID user block + /// EN: Blocked user ID / VI: ID user bị block + /// EN: Optional reason / VI: Lý do (tùy chọn) + public UserBlock(Guid blockerId, Guid blockedId, string? reason = null) : this() + { + if (blockerId == Guid.Empty) + throw new SocialDomainException("Blocker ID cannot be empty"); + if (blockedId == Guid.Empty) + throw new SocialDomainException("Blocked ID cannot be empty"); + if (blockerId == blockedId) + throw new SocialDomainException("Cannot block yourself"); + + Id = Guid.NewGuid(); + _blockerId = blockerId; + _blockedId = blockedId; + _reason = reason; + _createdAt = DateTime.UtcNow; + + AddDomainEvent(new UserBlockedDomainEvent(this)); + } +} diff --git a/services/social-service-net/src/SocialService.Domain/AggregatesModel/UserProfileAggregate/IUserProfileRepository.cs b/services/social-service-net/src/SocialService.Domain/AggregatesModel/UserProfileAggregate/IUserProfileRepository.cs new file mode 100644 index 00000000..84a85272 --- /dev/null +++ b/services/social-service-net/src/SocialService.Domain/AggregatesModel/UserProfileAggregate/IUserProfileRepository.cs @@ -0,0 +1,52 @@ +using SocialService.Domain.SeedWork; + +namespace SocialService.Domain.AggregatesModel.UserProfileAggregate; + +/// +/// EN: Repository interface for UserProfile aggregate. +/// VI: Interface repository cho UserProfile aggregate. +/// +public interface IUserProfileRepository : IRepository +{ + /// + /// EN: Add a new user profile. + /// VI: Thêm một hồ sơ user mới. + /// + UserProfile Add(UserProfile profile); + + /// + /// EN: Update an existing user profile. + /// VI: Cập nhật một hồ sơ user đã tồn tại. + /// + UserProfile Update(UserProfile profile); + + /// + /// EN: Get user profile by ID. + /// VI: Lấy hồ sơ user theo ID. + /// + Task GetByIdAsync(Guid id); + + /// + /// EN: Get user profile by user ID from IAM. + /// VI: Lấy hồ sơ user theo user ID từ IAM. + /// + Task GetByUserIdAsync(Guid userId); + + /// + /// EN: Get multiple user profiles by user IDs. + /// VI: Lấy nhiều hồ sơ user theo danh sách user IDs. + /// + Task> GetByUserIdsAsync(IEnumerable userIds); + + /// + /// EN: Search user profiles by display name. + /// VI: Tìm kiếm hồ sơ user theo tên hiển thị. + /// + Task> SearchByDisplayNameAsync(string searchTerm, int skip = 0, int take = 20); + + /// + /// EN: Check if user profile exists. + /// VI: Kiểm tra hồ sơ user có tồn tại không. + /// + Task ExistsAsync(Guid userId); +} diff --git a/services/social-service-net/src/SocialService.Domain/AggregatesModel/UserProfileAggregate/UserProfile.cs b/services/social-service-net/src/SocialService.Domain/AggregatesModel/UserProfileAggregate/UserProfile.cs new file mode 100644 index 00000000..fef3e2ea --- /dev/null +++ b/services/social-service-net/src/SocialService.Domain/AggregatesModel/UserProfileAggregate/UserProfile.cs @@ -0,0 +1,104 @@ +using SocialService.Domain.Exceptions; +using SocialService.Domain.SeedWork; + +namespace SocialService.Domain.AggregatesModel.UserProfileAggregate; + +/// +/// EN: Aggregate root for user profile data synced from IAM Service. +/// VI: Aggregate root cho dữ liệu hồ sơ user được đồng bộ từ IAM Service. +/// +public class UserProfile : Entity, IAggregateRoot +{ + // EN: Private fields for encapsulation + // VI: Fields private để đóng gói + private Guid _userId; + private string _displayName = null!; + private string? _avatarUrl; + private string? _bio; + private DateTime _lastSyncedAt; + private DateTime _createdAt; + + /// + /// EN: User ID from IAM Service. + /// VI: ID user từ IAM Service. + /// + public Guid UserId => _userId; + + /// + /// EN: User's display name. + /// VI: Tên hiển thị của user. + /// + public string DisplayName => _displayName; + + /// + /// EN: URL to user's avatar image. + /// VI: URL đến ảnh avatar của user. + /// + public string? AvatarUrl => _avatarUrl; + + /// + /// EN: User's bio/description. + /// VI: Tiểu sử/mô tả của user. + /// + public string? Bio => _bio; + + /// + /// EN: Last time the profile was synced from IAM. + /// VI: Lần cuối hồ sơ được đồng bộ từ IAM. + /// + public DateTime LastSyncedAt => _lastSyncedAt; + + /// + /// EN: Creation timestamp. + /// VI: Thời gian tạo. + /// + public DateTime CreatedAt => _createdAt; + + /// + /// EN: Private constructor for EF Core. + /// VI: Constructor private cho EF Core. + /// + protected UserProfile() { } + + /// + /// EN: Create a new user profile from IAM data. + /// VI: Tạo hồ sơ user mới từ dữ liệu IAM. + /// + /// EN: User ID from IAM / VI: ID user từ IAM + /// EN: Display name / VI: Tên hiển thị + /// EN: Avatar URL / VI: URL avatar + /// EN: Bio / VI: Tiểu sử + public UserProfile(Guid userId, string displayName, string? avatarUrl = null, string? bio = null) : this() + { + if (userId == Guid.Empty) + throw new SocialDomainException("User ID cannot be empty"); + if (string.IsNullOrWhiteSpace(displayName)) + throw new SocialDomainException("Display name cannot be empty"); + + Id = Guid.NewGuid(); + _userId = userId; + _displayName = displayName; + _avatarUrl = avatarUrl; + _bio = bio; + _lastSyncedAt = DateTime.UtcNow; + _createdAt = DateTime.UtcNow; + } + + /// + /// EN: Update profile from IAM integration event. + /// VI: Cập nhật hồ sơ từ IAM integration event. + /// + /// EN: New display name / VI: Tên hiển thị mới + /// EN: New avatar URL / VI: URL avatar mới + /// EN: New bio / VI: Tiểu sử mới + public void UpdateFromEvent(string displayName, string? avatarUrl, string? bio = null) + { + if (string.IsNullOrWhiteSpace(displayName)) + throw new SocialDomainException("Display name cannot be empty"); + + _displayName = displayName; + _avatarUrl = avatarUrl; + _bio = bio; + _lastSyncedAt = DateTime.UtcNow; + } +} diff --git a/services/social-service-net/src/SocialService.Domain/Events/RelationshipDomainEvents.cs b/services/social-service-net/src/SocialService.Domain/Events/RelationshipDomainEvents.cs new file mode 100644 index 00000000..66f40fc5 --- /dev/null +++ b/services/social-service-net/src/SocialService.Domain/Events/RelationshipDomainEvents.cs @@ -0,0 +1,91 @@ +using MediatR; +using SocialService.Domain.AggregatesModel.RelationshipAggregate; + +namespace SocialService.Domain.Events; + +/// +/// EN: Domain event raised when a friend request is sent. +/// VI: Domain event được raise khi yêu cầu kết bạn được gửi. +/// +public class FriendRequestSentDomainEvent : INotification +{ + public Relationship Relationship { get; } + public DateTime OccurredOn { get; } + + public FriendRequestSentDomainEvent(Relationship relationship) + { + Relationship = relationship; + OccurredOn = DateTime.UtcNow; + } +} + +/// +/// EN: Domain event raised when a friendship is created (request accepted). +/// VI: Domain event được raise khi kết bạn được tạo (yêu cầu được chấp nhận). +/// +public class FriendshipCreatedDomainEvent : INotification +{ + public Relationship Relationship { get; } + public DateTime OccurredOn { get; } + + public FriendshipCreatedDomainEvent(Relationship relationship) + { + Relationship = relationship; + OccurredOn = DateTime.UtcNow; + } +} + +/// +/// EN: Domain event raised when a user follows another user. +/// VI: Domain event được raise khi user theo dõi user khác. +/// +public class UserFollowedDomainEvent : INotification +{ + public Relationship Relationship { get; } + public DateTime OccurredOn { get; } + + public UserFollowedDomainEvent(Relationship relationship) + { + Relationship = relationship; + OccurredOn = DateTime.UtcNow; + } +} + +/// +/// EN: Domain event raised when a relationship status changes. +/// VI: Domain event được raise khi trạng thái quan hệ thay đổi. +/// +public class RelationshipStatusChangedDomainEvent : INotification +{ + public Guid RelationshipId { get; } + public RelationshipStatus PreviousStatus { get; } + public RelationshipStatus NewStatus { get; } + public DateTime OccurredOn { get; } + + public RelationshipStatusChangedDomainEvent( + Guid relationshipId, + RelationshipStatus previousStatus, + RelationshipStatus newStatus) + { + RelationshipId = relationshipId; + PreviousStatus = previousStatus; + NewStatus = newStatus; + OccurredOn = DateTime.UtcNow; + } +} + +/// +/// EN: Domain event raised when a relationship is removed (unfriend/unfollow). +/// VI: Domain event được raise khi quan hệ bị xóa (hủy kết bạn/hủy theo dõi). +/// +public class RelationshipRemovedDomainEvent : INotification +{ + public Relationship Relationship { get; } + public DateTime OccurredOn { get; } + + public RelationshipRemovedDomainEvent(Relationship relationship) + { + Relationship = relationship; + OccurredOn = DateTime.UtcNow; + } +} diff --git a/services/social-service-net/src/SocialService.Domain/Events/UserBlockDomainEvents.cs b/services/social-service-net/src/SocialService.Domain/Events/UserBlockDomainEvents.cs new file mode 100644 index 00000000..fe48c51f --- /dev/null +++ b/services/social-service-net/src/SocialService.Domain/Events/UserBlockDomainEvents.cs @@ -0,0 +1,38 @@ +using MediatR; +using SocialService.Domain.AggregatesModel.UserBlockAggregate; + +namespace SocialService.Domain.Events; + +/// +/// EN: Domain event raised when a user blocks another user. +/// VI: Domain event được raise khi user block user khác. +/// +public class UserBlockedDomainEvent : INotification +{ + public UserBlock Block { get; } + public DateTime OccurredOn { get; } + + public UserBlockedDomainEvent(UserBlock block) + { + Block = block; + OccurredOn = DateTime.UtcNow; + } +} + +/// +/// EN: Domain event raised when a user unblocks another user. +/// VI: Domain event được raise khi user unblock user khác. +/// +public class UserUnblockedDomainEvent : INotification +{ + public Guid BlockerId { get; } + public Guid BlockedId { get; } + public DateTime OccurredOn { get; } + + public UserUnblockedDomainEvent(Guid blockerId, Guid blockedId) + { + BlockerId = blockerId; + BlockedId = blockedId; + OccurredOn = DateTime.UtcNow; + } +} diff --git a/services/social-service-net/src/SocialService.Domain/Exceptions/DomainException.cs b/services/social-service-net/src/SocialService.Domain/Exceptions/DomainException.cs new file mode 100644 index 00000000..4571ec7f --- /dev/null +++ b/services/social-service-net/src/SocialService.Domain/Exceptions/DomainException.cs @@ -0,0 +1,21 @@ +namespace SocialService.Domain.Exceptions; + +/// +/// EN: Base exception for domain errors. +/// VI: Exception cơ sở cho các lỗi domain. +/// +public class DomainException : Exception +{ + public DomainException() + { + } + + public DomainException(string message) : base(message) + { + } + + public DomainException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/services/social-service-net/src/SocialService.Domain/Exceptions/SampleDomainException.cs b/services/social-service-net/src/SocialService.Domain/Exceptions/SampleDomainException.cs new file mode 100644 index 00000000..a068291e --- /dev/null +++ b/services/social-service-net/src/SocialService.Domain/Exceptions/SampleDomainException.cs @@ -0,0 +1,21 @@ +namespace SocialService.Domain.Exceptions; + +/// +/// EN: Exception for Sample aggregate domain errors. +/// VI: Exception cho các lỗi domain của Sample aggregate. +/// +public class SampleDomainException : DomainException +{ + public SampleDomainException() + { + } + + public SampleDomainException(string message) : base(message) + { + } + + public SampleDomainException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/services/social-service-net/src/SocialService.Domain/Exceptions/SocialDomainException.cs b/services/social-service-net/src/SocialService.Domain/Exceptions/SocialDomainException.cs new file mode 100644 index 00000000..79699f0d --- /dev/null +++ b/services/social-service-net/src/SocialService.Domain/Exceptions/SocialDomainException.cs @@ -0,0 +1,21 @@ +namespace SocialService.Domain.Exceptions; + +/// +/// EN: Exception for Social Service domain errors. +/// VI: Exception cho các lỗi domain của Social Service. +/// +public class SocialDomainException : DomainException +{ + public SocialDomainException() + { + } + + public SocialDomainException(string message) : base(message) + { + } + + public SocialDomainException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/services/social-service-net/src/SocialService.Domain/SeedWork/Entity.cs b/services/social-service-net/src/SocialService.Domain/SeedWork/Entity.cs new file mode 100644 index 00000000..535b749c --- /dev/null +++ b/services/social-service-net/src/SocialService.Domain/SeedWork/Entity.cs @@ -0,0 +1,102 @@ +using MediatR; + +namespace SocialService.Domain.SeedWork; + +/// +/// EN: Base class for all domain entities. +/// VI: Lớp cơ sở cho tất cả các entity trong domain. +/// +public abstract class Entity +{ + private int? _requestedHashCode; + private Guid _id; + private List _domainEvents = new(); + + /// + /// EN: Unique identifier for the entity. + /// VI: Định danh duy nhất cho entity. + /// + public virtual Guid Id + { + get => _id; + protected set => _id = value; + } + + /// + /// EN: Domain events raised by this entity. + /// VI: Các domain event được phát ra bởi entity này. + /// + public IReadOnlyCollection DomainEvents => _domainEvents.AsReadOnly(); + + /// + /// EN: Add a domain event to be dispatched. + /// VI: Thêm một domain event để dispatch. + /// + public void AddDomainEvent(INotification eventItem) + { + _domainEvents.Add(eventItem); + } + + /// + /// EN: Remove a domain event. + /// VI: Xóa một domain event. + /// + public void RemoveDomainEvent(INotification eventItem) + { + _domainEvents.Remove(eventItem); + } + + /// + /// EN: Clear all domain events. + /// VI: Xóa tất cả domain events. + /// + public void ClearDomainEvents() + { + _domainEvents.Clear(); + } + + /// + /// EN: Check if entity is transient (not persisted yet). + /// VI: Kiểm tra xem entity có phải là transient (chưa lưu) không. + /// + public bool IsTransient() + { + return Id == default; + } + + public override bool Equals(object? obj) + { + if (obj is not Entity item) + return false; + + if (ReferenceEquals(this, item)) + return true; + + if (GetType() != item.GetType()) + return false; + + if (item.IsTransient() || IsTransient()) + return false; + + return item.Id == Id; + } + + public override int GetHashCode() + { + if (IsTransient()) + return base.GetHashCode(); + + _requestedHashCode ??= Id.GetHashCode() ^ 31; + return _requestedHashCode.Value; + } + + public static bool operator ==(Entity? left, Entity? right) + { + return left?.Equals(right) ?? right is null; + } + + public static bool operator !=(Entity? left, Entity? right) + { + return !(left == right); + } +} diff --git a/services/social-service-net/src/SocialService.Domain/SeedWork/Enumeration.cs b/services/social-service-net/src/SocialService.Domain/SeedWork/Enumeration.cs new file mode 100644 index 00000000..f14c8e5a --- /dev/null +++ b/services/social-service-net/src/SocialService.Domain/SeedWork/Enumeration.cs @@ -0,0 +1,95 @@ +using System.Reflection; + +namespace SocialService.Domain.SeedWork; + +/// +/// EN: Base class for enumeration classes (type-safe enum pattern). +/// VI: Lớp cơ sở cho các lớp enumeration (pattern enum an toàn kiểu). +/// +/// +/// EN: This provides a type-safe alternative to enums with additional functionality +/// like validation, parsing, and rich behavior. +/// VI: Cung cấp một thay thế an toàn kiểu cho enums với các chức năng bổ sung +/// như validation, parsing, và hành vi phong phú. +/// +public abstract class Enumeration : IComparable +{ + /// + /// EN: The name of the enumeration value. + /// VI: Tên của giá trị enumeration. + /// + public string Name { get; private set; } + + /// + /// EN: The unique identifier of the enumeration value. + /// VI: Định danh duy nhất của giá trị enumeration. + /// + public int Id { get; private set; } + + protected Enumeration(int id, string name) => (Id, Name) = (id, name); + + public override string ToString() => Name; + + /// + /// EN: Get all enumeration values of a given type. + /// VI: Lấy tất cả các giá trị enumeration của một kiểu cho trước. + /// + public static IEnumerable GetAll() where T : Enumeration => + typeof(T).GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly) + .Select(f => f.GetValue(null)) + .Cast(); + + public override bool Equals(object? obj) + { + if (obj is not Enumeration otherValue) + return false; + + var typeMatches = GetType() == obj.GetType(); + var valueMatches = Id.Equals(otherValue.Id); + + return typeMatches && valueMatches; + } + + public override int GetHashCode() => Id.GetHashCode(); + + /// + /// EN: Get absolute difference between two enumeration values. + /// VI: Lấy sự khác biệt tuyệt đối giữa hai giá trị enumeration. + /// + public static int AbsoluteDifference(Enumeration firstValue, Enumeration secondValue) + { + return Math.Abs(firstValue.Id - secondValue.Id); + } + + /// + /// EN: Parse an integer ID to the corresponding enumeration value. + /// VI: Parse một ID integer thành giá trị enumeration tương ứng. + /// + public static T FromValue(int value) where T : Enumeration + { + var matchingItem = Parse(value, "value", item => item.Id == value); + return matchingItem; + } + + /// + /// EN: Parse a display name to the corresponding enumeration value. + /// VI: Parse một tên hiển thị thành giá trị enumeration tương ứng. + /// + public static T FromDisplayName(string displayName) where T : Enumeration + { + var matchingItem = Parse(displayName, "display name", item => item.Name == displayName); + return matchingItem; + } + + private static T Parse(TValue value, string description, Func predicate) where T : Enumeration + { + var matchingItem = GetAll().FirstOrDefault(predicate); + + if (matchingItem is null) + throw new InvalidOperationException($"'{value}' is not a valid {description} in {typeof(T)}"); + + return matchingItem; + } + + public int CompareTo(object? other) => Id.CompareTo(((Enumeration)other!).Id); +} diff --git a/services/social-service-net/src/SocialService.Domain/SeedWork/IAggregateRoot.cs b/services/social-service-net/src/SocialService.Domain/SeedWork/IAggregateRoot.cs new file mode 100644 index 00000000..ec161f3e --- /dev/null +++ b/services/social-service-net/src/SocialService.Domain/SeedWork/IAggregateRoot.cs @@ -0,0 +1,15 @@ +namespace SocialService.Domain.SeedWork; + +/// +/// EN: Marker interface for aggregate roots. +/// VI: Interface đánh dấu cho aggregate roots. +/// +/// +/// EN: Aggregate roots are the entry points to aggregates and are the only objects +/// that outside code should hold references to. +/// VI: Aggregate roots là điểm vào của aggregates và là đối tượng duy nhất +/// mà code bên ngoài nên giữ tham chiếu đến. +/// +public interface IAggregateRoot +{ +} diff --git a/services/social-service-net/src/SocialService.Domain/SeedWork/IRepository.cs b/services/social-service-net/src/SocialService.Domain/SeedWork/IRepository.cs new file mode 100644 index 00000000..ef1b3be3 --- /dev/null +++ b/services/social-service-net/src/SocialService.Domain/SeedWork/IRepository.cs @@ -0,0 +1,15 @@ +namespace SocialService.Domain.SeedWork; + +/// +/// EN: Generic repository interface for aggregate roots. +/// VI: Interface repository generic cho aggregate roots. +/// +/// EN: The aggregate root type / VI: Kiểu aggregate root +public interface IRepository where T : IAggregateRoot +{ + /// + /// EN: The unit of work for this repository. + /// VI: Unit of work cho repository này. + /// + IUnitOfWork UnitOfWork { get; } +} diff --git a/services/social-service-net/src/SocialService.Domain/SeedWork/IUnitOfWork.cs b/services/social-service-net/src/SocialService.Domain/SeedWork/IUnitOfWork.cs new file mode 100644 index 00000000..9513068d --- /dev/null +++ b/services/social-service-net/src/SocialService.Domain/SeedWork/IUnitOfWork.cs @@ -0,0 +1,30 @@ +namespace SocialService.Domain.SeedWork; + +/// +/// EN: Unit of Work pattern interface. +/// VI: Interface cho Unit of Work pattern. +/// +/// +/// EN: Maintains a list of objects affected by a business transaction +/// and coordinates the writing out of changes. +/// VI: Duy trì danh sách các đối tượng bị ảnh hưởng bởi một transaction nghiệp vụ +/// và điều phối việc ghi các thay đổi. +/// +public interface IUnitOfWork : IDisposable +{ + /// + /// EN: Save all changes made in this unit of work. + /// VI: Lưu tất cả các thay đổi được thực hiện trong unit of work này. + /// + /// EN: Cancellation token / VI: Token hủy + /// EN: Number of entities written / VI: Số entity đã ghi + Task SaveChangesAsync(CancellationToken cancellationToken = default); + + /// + /// EN: Save all changes and dispatch domain events. + /// VI: Lưu tất cả thay đổi và dispatch domain events. + /// + /// EN: Cancellation token / VI: Token hủy + /// EN: True if successful / VI: True nếu thành công + Task SaveEntitiesAsync(CancellationToken cancellationToken = default); +} diff --git a/services/social-service-net/src/SocialService.Domain/SeedWork/ValueObject.cs b/services/social-service-net/src/SocialService.Domain/SeedWork/ValueObject.cs new file mode 100644 index 00000000..033b9458 --- /dev/null +++ b/services/social-service-net/src/SocialService.Domain/SeedWork/ValueObject.cs @@ -0,0 +1,53 @@ +namespace SocialService.Domain.SeedWork; + +/// +/// EN: Base class for Value Objects following DDD patterns. +/// VI: Lớp cơ sở cho Value Objects theo mẫu DDD. +/// +/// +/// EN: Value objects are immutable and compared by their values, not identity. +/// VI: Value objects là bất biến và được so sánh theo giá trị, không phải định danh. +/// +public abstract class ValueObject +{ + /// + /// EN: Get the atomic values that make up this value object. + /// VI: Lấy các giá trị nguyên tử tạo nên value object này. + /// + protected abstract IEnumerable GetEqualityComponents(); + + public override bool Equals(object? obj) + { + if (obj is null || obj.GetType() != GetType()) + return false; + + var other = (ValueObject)obj; + return GetEqualityComponents().SequenceEqual(other.GetEqualityComponents()); + } + + public override int GetHashCode() + { + return GetEqualityComponents() + .Select(x => x?.GetHashCode() ?? 0) + .Aggregate((x, y) => x ^ y); + } + + public static bool operator ==(ValueObject? left, ValueObject? right) + { + return left?.Equals(right) ?? right is null; + } + + public static bool operator !=(ValueObject? left, ValueObject? right) + { + return !(left == right); + } + + /// + /// EN: Create a copy of this value object with modifications. + /// VI: Tạo bản sao của value object này với các thay đổi. + /// + protected ValueObject GetCopy() + { + return (ValueObject)MemberwiseClone(); + } +} diff --git a/services/social-service-net/src/SocialService.Domain/Services/IGraphQueryService.cs b/services/social-service-net/src/SocialService.Domain/Services/IGraphQueryService.cs new file mode 100644 index 00000000..728ba061 --- /dev/null +++ b/services/social-service-net/src/SocialService.Domain/Services/IGraphQueryService.cs @@ -0,0 +1,47 @@ +namespace SocialService.Domain.Services; + +/// +/// EN: Service interface for graph-based queries (mutual friends, suggestions). +/// VI: Interface service cho các truy vấn dựa trên đồ thị (bạn chung, gợi ý). +/// +public interface IGraphQueryService +{ + /// + /// EN: Get mutual friends between two users. + /// VI: Lấy bạn chung giữa hai users. + /// + /// EN: First user ID / VI: ID user thứ nhất + /// EN: Second user ID / VI: ID user thứ hai + /// EN: List of mutual friend user IDs / VI: Danh sách ID bạn chung + Task> GetMutualFriendsAsync(Guid userId1, Guid userId2); + + /// + /// EN: Get friend suggestions for a user based on mutual connections. + /// VI: Lấy gợi ý kết bạn cho user dựa trên kết nối chung. + /// + /// EN: User ID / VI: ID user + /// EN: Maximum suggestions / VI: Số lượng gợi ý tối đa + /// EN: List of suggested user IDs with mutual count / VI: Danh sách ID user gợi ý với số bạn chung + Task> GetFriendSuggestionsAsync(Guid userId, int limit = 10); + + /// + /// EN: Get the degree of connection between two users. + /// VI: Lấy bậc kết nối giữa hai users. + /// + /// EN: First user ID / VI: ID user thứ nhất + /// EN: Second user ID / VI: ID user thứ hai + /// EN: Connection degree (-1 if not connected) / VI: Bậc kết nối (-1 nếu không kết nối) + Task GetConnectionDegreeAsync(Guid userId1, Guid userId2); + + /// + /// EN: Get mutual friends count between two users. + /// VI: Lấy số lượng bạn chung giữa hai users. + /// + Task GetMutualFriendsCountAsync(Guid userId1, Guid userId2); +} + +/// +/// EN: DTO for friend suggestion with mutual friends count. +/// VI: DTO cho gợi ý kết bạn với số lượng bạn chung. +/// +public record FriendSuggestion(Guid UserId, int MutualFriendsCount); diff --git a/services/social-service-net/src/SocialService.Domain/SocialService.Domain.csproj b/services/social-service-net/src/SocialService.Domain/SocialService.Domain.csproj new file mode 100644 index 00000000..793c801f --- /dev/null +++ b/services/social-service-net/src/SocialService.Domain/SocialService.Domain.csproj @@ -0,0 +1,14 @@ + + + + SocialService.Domain + SocialService.Domain + Domain layer containing core business logic and entities + + + + + + + + diff --git a/services/social-service-net/src/SocialService.Infrastructure/DependencyInjection.cs b/services/social-service-net/src/SocialService.Infrastructure/DependencyInjection.cs new file mode 100644 index 00000000..b2677eaf --- /dev/null +++ b/services/social-service-net/src/SocialService.Infrastructure/DependencyInjection.cs @@ -0,0 +1,66 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using SocialService.Domain.AggregatesModel.RelationshipAggregate; +using SocialService.Domain.AggregatesModel.UserBlockAggregate; +using SocialService.Domain.AggregatesModel.UserProfileAggregate; +using SocialService.Domain.Services; +using SocialService.Infrastructure.Idempotency; +using SocialService.Infrastructure.Repositories; +using SocialService.Infrastructure.Services; + +namespace SocialService.Infrastructure; + +/// +/// EN: Dependency injection extensions for Infrastructure layer. +/// VI: Extensions dependency injection cho lớp Infrastructure. +/// +public static class DependencyInjection +{ + /// + /// EN: Add infrastructure services to the DI container. + /// VI: Thêm các services infrastructure vào DI container. + /// + public static IServiceCollection AddInfrastructure( + this IServiceCollection services, + IConfiguration configuration) + { + // EN: Add DbContext with PostgreSQL / VI: Thêm DbContext với PostgreSQL + services.AddDbContext(options => + { + var connectionString = configuration.GetConnectionString("DefaultConnection") + ?? configuration["DATABASE_URL"] + ?? throw new InvalidOperationException("Connection string not configured"); + + options.UseNpgsql(connectionString, npgsqlOptions => + { + npgsqlOptions.MigrationsAssembly(typeof(SocialServiceContext).Assembly.FullName); + npgsqlOptions.EnableRetryOnFailure( + maxRetryCount: 5, + maxRetryDelay: TimeSpan.FromSeconds(30), + errorCodesToAdd: null); + }); + + // EN: Enable sensitive data logging in development only + // VI: Chỉ bật sensitive data logging trong development + if (Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == "Development") + { + options.EnableSensitiveDataLogging(); + options.EnableDetailedErrors(); + } + }); + + // EN: Register repositories / VI: Đăng ký repositories + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // EN: Register domain services / VI: Đăng ký domain services + services.AddScoped(); + + // EN: Register idempotency services / VI: Đăng ký idempotency services + services.AddScoped(); + + return services; + } +} diff --git a/services/social-service-net/src/SocialService.Infrastructure/EntityConfigurations/EnumerationEntityTypeConfigurations.cs b/services/social-service-net/src/SocialService.Infrastructure/EntityConfigurations/EnumerationEntityTypeConfigurations.cs new file mode 100644 index 00000000..817bbe17 --- /dev/null +++ b/services/social-service-net/src/SocialService.Infrastructure/EntityConfigurations/EnumerationEntityTypeConfigurations.cs @@ -0,0 +1,67 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SocialService.Domain.AggregatesModel.RelationshipAggregate; + +namespace SocialService.Infrastructure.EntityConfigurations; + +/// +/// EN: EF Core configuration for RelationshipType enumeration. +/// VI: Cấu hình EF Core cho RelationshipType enumeration. +/// +public class RelationshipTypeEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("relationship_types"); + + builder.HasKey(rt => rt.Id); + + builder.Property(rt => rt.Id) + .HasColumnName("id") + .ValueGeneratedNever(); + + builder.Property(rt => rt.Name) + .HasColumnName("name") + .HasMaxLength(50) + .IsRequired(); + + // EN: Seed data for relationship types + // VI: Seed data cho các loại quan hệ + builder.HasData( + RelationshipType.Friendship, + RelationshipType.Following + ); + } +} + +/// +/// EN: EF Core configuration for RelationshipStatus enumeration. +/// VI: Cấu hình EF Core cho RelationshipStatus enumeration. +/// +public class RelationshipStatusEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("relationship_statuses"); + + builder.HasKey(rs => rs.Id); + + builder.Property(rs => rs.Id) + .HasColumnName("id") + .ValueGeneratedNever(); + + builder.Property(rs => rs.Name) + .HasColumnName("name") + .HasMaxLength(50) + .IsRequired(); + + // EN: Seed data for relationship statuses + // VI: Seed data cho các trạng thái quan hệ + builder.HasData( + RelationshipStatus.Pending, + RelationshipStatus.Accepted, + RelationshipStatus.Rejected, + RelationshipStatus.Cancelled + ); + } +} diff --git a/services/social-service-net/src/SocialService.Infrastructure/EntityConfigurations/RelationshipEntityTypeConfiguration.cs b/services/social-service-net/src/SocialService.Infrastructure/EntityConfigurations/RelationshipEntityTypeConfiguration.cs new file mode 100644 index 00000000..01bd8e6a --- /dev/null +++ b/services/social-service-net/src/SocialService.Infrastructure/EntityConfigurations/RelationshipEntityTypeConfiguration.cs @@ -0,0 +1,79 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SocialService.Domain.AggregatesModel.RelationshipAggregate; + +namespace SocialService.Infrastructure.EntityConfigurations; + +/// +/// EN: EF Core configuration for Relationship entity. +/// VI: Cấu hình EF Core cho Relationship entity. +/// +public class RelationshipEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("relationships"); + + builder.HasKey(r => r.Id); + + builder.Property(r => r.Id) + .HasColumnName("id") + .ValueGeneratedNever(); + + builder.Property("_requesterId") + .HasColumnName("requester_id") + .IsRequired(); + + builder.Property("_addresseeId") + .HasColumnName("addressee_id") + .IsRequired(); + + builder.Property(r => r.TypeId) + .HasColumnName("type_id") + .IsRequired(); + + builder.Property(r => r.StatusId) + .HasColumnName("status_id") + .IsRequired(); + + builder.Property("_createdAt") + .HasColumnName("created_at") + .IsRequired(); + + builder.Property("_updatedAt") + .HasColumnName("updated_at"); + + // EN: Relationships with enumeration tables + // VI: Quan hệ với các bảng enumeration + builder.HasOne(r => r.Type) + .WithMany() + .HasForeignKey(r => r.TypeId) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasOne(r => r.Status) + .WithMany() + .HasForeignKey(r => r.StatusId) + .OnDelete(DeleteBehavior.Restrict); + + // EN: Unique constraint to prevent duplicate relationships + // VI: Constraint duy nhất để ngăn quan hệ trùng lặp + builder.HasIndex("_requesterId", "_addresseeId", "TypeId") + .IsUnique() + .HasDatabaseName("ix_relationships_requester_addressee_type"); + + // EN: Indexes for efficient queries + // VI: Indexes cho truy vấn hiệu quả + builder.HasIndex("_requesterId") + .HasDatabaseName("ix_relationships_requester"); + + builder.HasIndex("_addresseeId") + .HasDatabaseName("ix_relationships_addressee"); + + builder.HasIndex("TypeId", "StatusId") + .HasDatabaseName("ix_relationships_type_status"); + + // EN: Ignore navigation properties for DDD encapsulation + // VI: Ignore navigation properties cho đóng gói DDD + builder.Ignore(r => r.DomainEvents); + } +} diff --git a/services/social-service-net/src/SocialService.Infrastructure/EntityConfigurations/UserBlockEntityTypeConfiguration.cs b/services/social-service-net/src/SocialService.Infrastructure/EntityConfigurations/UserBlockEntityTypeConfiguration.cs new file mode 100644 index 00000000..54b25c71 --- /dev/null +++ b/services/social-service-net/src/SocialService.Infrastructure/EntityConfigurations/UserBlockEntityTypeConfiguration.cs @@ -0,0 +1,57 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SocialService.Domain.AggregatesModel.UserBlockAggregate; + +namespace SocialService.Infrastructure.EntityConfigurations; + +/// +/// EN: EF Core configuration for UserBlock entity. +/// VI: Cấu hình EF Core cho UserBlock entity. +/// +public class UserBlockEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("user_blocks"); + + builder.HasKey(ub => ub.Id); + + builder.Property(ub => ub.Id) + .HasColumnName("id") + .ValueGeneratedNever(); + + builder.Property("_blockerId") + .HasColumnName("blocker_id") + .IsRequired(); + + builder.Property("_blockedId") + .HasColumnName("blocked_id") + .IsRequired(); + + builder.Property("_reason") + .HasColumnName("reason") + .HasMaxLength(500); + + builder.Property("_createdAt") + .HasColumnName("created_at") + .IsRequired(); + + // EN: Unique constraint to prevent duplicate blocks + // VI: Constraint duy nhất để ngăn block trùng lặp + builder.HasIndex("_blockerId", "_blockedId") + .IsUnique() + .HasDatabaseName("ix_user_blocks_blocker_blocked"); + + // EN: Index for efficient queries + // VI: Index cho truy vấn hiệu quả + builder.HasIndex("_blockerId") + .HasDatabaseName("ix_user_blocks_blocker"); + + builder.HasIndex("_blockedId") + .HasDatabaseName("ix_user_blocks_blocked"); + + // EN: Ignore navigation properties for DDD encapsulation + // VI: Ignore navigation properties cho đóng gói DDD + builder.Ignore(ub => ub.DomainEvents); + } +} diff --git a/services/social-service-net/src/SocialService.Infrastructure/EntityConfigurations/UserProfileEntityTypeConfiguration.cs b/services/social-service-net/src/SocialService.Infrastructure/EntityConfigurations/UserProfileEntityTypeConfiguration.cs new file mode 100644 index 00000000..c57994e4 --- /dev/null +++ b/services/social-service-net/src/SocialService.Infrastructure/EntityConfigurations/UserProfileEntityTypeConfiguration.cs @@ -0,0 +1,63 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SocialService.Domain.AggregatesModel.UserProfileAggregate; + +namespace SocialService.Infrastructure.EntityConfigurations; + +/// +/// EN: EF Core configuration for UserProfile entity. +/// VI: Cấu hình EF Core cho UserProfile entity. +/// +public class UserProfileEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("user_profiles"); + + builder.HasKey(up => up.Id); + + builder.Property(up => up.Id) + .HasColumnName("id") + .ValueGeneratedNever(); + + builder.Property("_userId") + .HasColumnName("user_id") + .IsRequired(); + + builder.Property("_displayName") + .HasColumnName("display_name") + .HasMaxLength(255) + .IsRequired(); + + builder.Property("_avatarUrl") + .HasColumnName("avatar_url") + .HasMaxLength(500); + + builder.Property("_bio") + .HasColumnName("bio") + .HasMaxLength(500); + + builder.Property("_lastSyncedAt") + .HasColumnName("last_synced_at") + .IsRequired(); + + builder.Property("_createdAt") + .HasColumnName("created_at") + .IsRequired(); + + // EN: Unique constraint on user_id (one profile per IAM user) + // VI: Constraint duy nhất trên user_id (một hồ sơ cho mỗi IAM user) + builder.HasIndex("_userId") + .IsUnique() + .HasDatabaseName("ix_user_profiles_user_id"); + + // EN: Index for display name search + // VI: Index cho tìm kiếm theo tên hiển thị + builder.HasIndex("_displayName") + .HasDatabaseName("ix_user_profiles_display_name"); + + // EN: Ignore navigation properties for DDD encapsulation + // VI: Ignore navigation properties cho đóng gói DDD + builder.Ignore(up => up.DomainEvents); + } +} diff --git a/services/social-service-net/src/SocialService.Infrastructure/Idempotency/ClientRequest.cs b/services/social-service-net/src/SocialService.Infrastructure/Idempotency/ClientRequest.cs new file mode 100644 index 00000000..858c4039 --- /dev/null +++ b/services/social-service-net/src/SocialService.Infrastructure/Idempotency/ClientRequest.cs @@ -0,0 +1,26 @@ +namespace SocialService.Infrastructure.Idempotency; + +/// +/// EN: Entity for tracking client requests to ensure idempotency. +/// VI: Entity để theo dõi các requests từ client đảm bảo idempotency. +/// +public class ClientRequest +{ + /// + /// EN: Unique request identifier. + /// VI: Định danh request duy nhất. + /// + public Guid Id { get; set; } + + /// + /// EN: Name of the command/request type. + /// VI: Tên của loại command/request. + /// + public string Name { get; set; } = null!; + + /// + /// EN: Timestamp when the request was received. + /// VI: Thời điểm request được nhận. + /// + public DateTime Time { get; set; } +} diff --git a/services/social-service-net/src/SocialService.Infrastructure/Idempotency/IRequestManager.cs b/services/social-service-net/src/SocialService.Infrastructure/Idempotency/IRequestManager.cs new file mode 100644 index 00000000..23d4953a --- /dev/null +++ b/services/social-service-net/src/SocialService.Infrastructure/Idempotency/IRequestManager.cs @@ -0,0 +1,24 @@ +namespace SocialService.Infrastructure.Idempotency; + +/// +/// EN: Interface for managing client request idempotency. +/// VI: Interface để quản lý idempotency của client requests. +/// +public interface IRequestManager +{ + /// + /// EN: Check if a request with the given ID exists. + /// VI: Kiểm tra xem request với ID cho trước có tồn tại không. + /// + /// EN: Request ID / VI: ID của request + /// EN: True if exists / VI: True nếu tồn tại + Task ExistAsync(Guid id); + + /// + /// EN: Create a new request record for tracking. + /// VI: Tạo bản ghi request mới để theo dõi. + /// + /// EN: Command type / VI: Loại command + /// EN: Request ID / VI: ID của request + Task CreateRequestForCommandAsync(Guid id); +} diff --git a/services/social-service-net/src/SocialService.Infrastructure/Idempotency/RequestManager.cs b/services/social-service-net/src/SocialService.Infrastructure/Idempotency/RequestManager.cs new file mode 100644 index 00000000..1a2ad23e --- /dev/null +++ b/services/social-service-net/src/SocialService.Infrastructure/Idempotency/RequestManager.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore; + +namespace SocialService.Infrastructure.Idempotency; + +/// +/// EN: Implementation of request manager for idempotency. +/// VI: Triển khai request manager cho idempotency. +/// +public class RequestManager : IRequestManager +{ + private readonly SocialServiceContext _context; + + public RequestManager(SocialServiceContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + /// + public async Task ExistAsync(Guid id) + { + var request = await _context + .FindAsync(id); + + return request != null; + } + + /// + public async Task CreateRequestForCommandAsync(Guid id) + { + var exists = await ExistAsync(id); + + var request = exists + ? throw new InvalidOperationException($"Request with {id} already exists") + : new ClientRequest + { + Id = id, + Name = typeof(T).Name, + Time = DateTime.UtcNow + }; + + _context.Add(request); + + await _context.SaveChangesAsync(); + } +} diff --git a/services/social-service-net/src/SocialService.Infrastructure/Repositories/RelationshipRepository.cs b/services/social-service-net/src/SocialService.Infrastructure/Repositories/RelationshipRepository.cs new file mode 100644 index 00000000..2253b36c --- /dev/null +++ b/services/social-service-net/src/SocialService.Infrastructure/Repositories/RelationshipRepository.cs @@ -0,0 +1,172 @@ +using Microsoft.EntityFrameworkCore; +using SocialService.Domain.AggregatesModel.RelationshipAggregate; +using SocialService.Domain.SeedWork; + +namespace SocialService.Infrastructure.Repositories; + +/// +/// EN: Repository implementation for Relationship aggregate. +/// VI: Implementation repository cho Relationship aggregate. +/// +public class RelationshipRepository : IRelationshipRepository +{ + private readonly SocialServiceContext _context; + + public IUnitOfWork UnitOfWork => _context; + + public RelationshipRepository(SocialServiceContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + public Relationship Add(Relationship relationship) + { + return _context.Relationships.Add(relationship).Entity; + } + + public Relationship Update(Relationship relationship) + { + return _context.Relationships.Update(relationship).Entity; + } + + public async Task GetByIdAsync(Guid id) + { + return await _context.Relationships + .Include(r => r.Type) + .Include(r => r.Status) + .FirstOrDefaultAsync(r => r.Id == id); + } + + public async Task GetByUsersAndTypeAsync(Guid requesterId, Guid addresseeId, RelationshipType type) + { + return await _context.Relationships + .Include(r => r.Type) + .Include(r => r.Status) + .FirstOrDefaultAsync(r => + EF.Property(r, "_requesterId") == requesterId && + EF.Property(r, "_addresseeId") == addresseeId && + r.TypeId == type.Id); + } + + public async Task> GetFriendsAsync(Guid userId, int skip = 0, int take = 20) + { + // EN: Get friendships where user is either requester or addressee + // VI: Lấy các kết bạn mà user là requester hoặc addressee + return await _context.Relationships + .Include(r => r.Type) + .Include(r => r.Status) + .Where(r => r.TypeId == RelationshipType.Friendship.Id && + r.StatusId == RelationshipStatus.Accepted.Id && + (EF.Property(r, "_requesterId") == userId || + EF.Property(r, "_addresseeId") == userId)) + .OrderByDescending(r => EF.Property(r, "_createdAt")) + .Skip(skip) + .Take(take) + .ToListAsync(); + } + + public async Task> GetFollowersAsync(Guid userId, int skip = 0, int take = 20) + { + // EN: Get users who follow this user (addressee is userId) + // VI: Lấy các users theo dõi user này (addressee là userId) + return await _context.Relationships + .Include(r => r.Type) + .Include(r => r.Status) + .Where(r => r.TypeId == RelationshipType.Following.Id && + r.StatusId == RelationshipStatus.Accepted.Id && + EF.Property(r, "_addresseeId") == userId) + .OrderByDescending(r => EF.Property(r, "_createdAt")) + .Skip(skip) + .Take(take) + .ToListAsync(); + } + + public async Task> GetFollowingAsync(Guid userId, int skip = 0, int take = 20) + { + // EN: Get users this user is following (requester is userId) + // VI: Lấy các users mà user này đang theo dõi (requester là userId) + return await _context.Relationships + .Include(r => r.Type) + .Include(r => r.Status) + .Where(r => r.TypeId == RelationshipType.Following.Id && + r.StatusId == RelationshipStatus.Accepted.Id && + EF.Property(r, "_requesterId") == userId) + .OrderByDescending(r => EF.Property(r, "_createdAt")) + .Skip(skip) + .Take(take) + .ToListAsync(); + } + + public async Task> GetPendingRequestsReceivedAsync(Guid userId, int skip = 0, int take = 20) + { + return await _context.Relationships + .Include(r => r.Type) + .Include(r => r.Status) + .Where(r => r.TypeId == RelationshipType.Friendship.Id && + r.StatusId == RelationshipStatus.Pending.Id && + EF.Property(r, "_addresseeId") == userId) + .OrderByDescending(r => EF.Property(r, "_createdAt")) + .Skip(skip) + .Take(take) + .ToListAsync(); + } + + public async Task> GetPendingRequestsSentAsync(Guid userId, int skip = 0, int take = 20) + { + return await _context.Relationships + .Include(r => r.Type) + .Include(r => r.Status) + .Where(r => r.TypeId == RelationshipType.Friendship.Id && + r.StatusId == RelationshipStatus.Pending.Id && + EF.Property(r, "_requesterId") == userId) + .OrderByDescending(r => EF.Property(r, "_createdAt")) + .Skip(skip) + .Take(take) + .ToListAsync(); + } + + public async Task AreFriendsAsync(Guid userId1, Guid userId2) + { + return await _context.Relationships + .AnyAsync(r => r.TypeId == RelationshipType.Friendship.Id && + r.StatusId == RelationshipStatus.Accepted.Id && + ((EF.Property(r, "_requesterId") == userId1 && + EF.Property(r, "_addresseeId") == userId2) || + (EF.Property(r, "_requesterId") == userId2 && + EF.Property(r, "_addresseeId") == userId1))); + } + + public async Task IsFollowingAsync(Guid followerId, Guid followeeId) + { + return await _context.Relationships + .AnyAsync(r => r.TypeId == RelationshipType.Following.Id && + r.StatusId == RelationshipStatus.Accepted.Id && + EF.Property(r, "_requesterId") == followerId && + EF.Property(r, "_addresseeId") == followeeId); + } + + public async Task GetFriendsCountAsync(Guid userId) + { + return await _context.Relationships + .CountAsync(r => r.TypeId == RelationshipType.Friendship.Id && + r.StatusId == RelationshipStatus.Accepted.Id && + (EF.Property(r, "_requesterId") == userId || + EF.Property(r, "_addresseeId") == userId)); + } + + public async Task GetFollowersCountAsync(Guid userId) + { + return await _context.Relationships + .CountAsync(r => r.TypeId == RelationshipType.Following.Id && + r.StatusId == RelationshipStatus.Accepted.Id && + EF.Property(r, "_addresseeId") == userId); + } + + public async Task GetFollowingCountAsync(Guid userId) + { + return await _context.Relationships + .CountAsync(r => r.TypeId == RelationshipType.Following.Id && + r.StatusId == RelationshipStatus.Accepted.Id && + EF.Property(r, "_requesterId") == userId); + } +} diff --git a/services/social-service-net/src/SocialService.Infrastructure/Repositories/UserBlockRepository.cs b/services/social-service-net/src/SocialService.Infrastructure/Repositories/UserBlockRepository.cs new file mode 100644 index 00000000..72d84282 --- /dev/null +++ b/services/social-service-net/src/SocialService.Infrastructure/Repositories/UserBlockRepository.cs @@ -0,0 +1,86 @@ +using Microsoft.EntityFrameworkCore; +using SocialService.Domain.AggregatesModel.UserBlockAggregate; +using SocialService.Domain.SeedWork; + +namespace SocialService.Infrastructure.Repositories; + +/// +/// EN: Repository implementation for UserBlock aggregate. +/// VI: Implementation repository cho UserBlock aggregate. +/// +public class UserBlockRepository : IUserBlockRepository +{ + private readonly SocialServiceContext _context; + + public IUnitOfWork UnitOfWork => _context; + + public UserBlockRepository(SocialServiceContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + public UserBlock Add(UserBlock userBlock) + { + return _context.UserBlocks.Add(userBlock).Entity; + } + + public void Remove(UserBlock userBlock) + { + _context.UserBlocks.Remove(userBlock); + } + + public async Task GetByIdAsync(Guid id) + { + return await _context.UserBlocks.FindAsync(id); + } + + public async Task GetByUsersAsync(Guid blockerId, Guid blockedId) + { + return await _context.UserBlocks + .FirstOrDefaultAsync(ub => + EF.Property(ub, "_blockerId") == blockerId && + EF.Property(ub, "_blockedId") == blockedId); + } + + public async Task> GetBlockedByUserAsync(Guid blockerId, int skip = 0, int take = 20) + { + return await _context.UserBlocks + .Where(ub => EF.Property(ub, "_blockerId") == blockerId) + .OrderByDescending(ub => EF.Property(ub, "_createdAt")) + .Skip(skip) + .Take(take) + .ToListAsync(); + } + + public async Task> GetBlockedUserIdsAsync(Guid blockerId) + { + return await _context.UserBlocks + .Where(ub => EF.Property(ub, "_blockerId") == blockerId) + .Select(ub => EF.Property(ub, "_blockedId")) + .ToListAsync(); + } + + public async Task IsBlockedAsync(Guid blockerId, Guid blockedId) + { + return await _context.UserBlocks + .AnyAsync(ub => + EF.Property(ub, "_blockerId") == blockerId && + EF.Property(ub, "_blockedId") == blockedId); + } + + public async Task HasBlockBetweenAsync(Guid userId1, Guid userId2) + { + return await _context.UserBlocks + .AnyAsync(ub => + (EF.Property(ub, "_blockerId") == userId1 && + EF.Property(ub, "_blockedId") == userId2) || + (EF.Property(ub, "_blockerId") == userId2 && + EF.Property(ub, "_blockedId") == userId1)); + } + + public async Task GetBlockedCountAsync(Guid blockerId) + { + return await _context.UserBlocks + .CountAsync(ub => EF.Property(ub, "_blockerId") == blockerId); + } +} diff --git a/services/social-service-net/src/SocialService.Infrastructure/Repositories/UserProfileRepository.cs b/services/social-service-net/src/SocialService.Infrastructure/Repositories/UserProfileRepository.cs new file mode 100644 index 00000000..82e22b3d --- /dev/null +++ b/services/social-service-net/src/SocialService.Infrastructure/Repositories/UserProfileRepository.cs @@ -0,0 +1,66 @@ +using Microsoft.EntityFrameworkCore; +using SocialService.Domain.AggregatesModel.UserProfileAggregate; +using SocialService.Domain.SeedWork; + +namespace SocialService.Infrastructure.Repositories; + +/// +/// EN: Repository implementation for UserProfile aggregate. +/// VI: Implementation repository cho UserProfile aggregate. +/// +public class UserProfileRepository : IUserProfileRepository +{ + private readonly SocialServiceContext _context; + + public IUnitOfWork UnitOfWork => _context; + + public UserProfileRepository(SocialServiceContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + public UserProfile Add(UserProfile userProfile) + { + return _context.UserProfiles.Add(userProfile).Entity; + } + + public UserProfile Update(UserProfile userProfile) + { + return _context.UserProfiles.Update(userProfile).Entity; + } + + public async Task GetByIdAsync(Guid id) + { + return await _context.UserProfiles.FindAsync(id); + } + + public async Task GetByUserIdAsync(Guid userId) + { + return await _context.UserProfiles + .FirstOrDefaultAsync(up => EF.Property(up, "_userId") == userId); + } + + public async Task> SearchByDisplayNameAsync(string searchTerm, int skip = 0, int take = 20) + { + return await _context.UserProfiles + .Where(up => EF.Property(up, "_displayName").ToLower().Contains(searchTerm.ToLower())) + .OrderBy(up => EF.Property(up, "_displayName")) + .Skip(skip) + .Take(take) + .ToListAsync(); + } + + public async Task> GetByUserIdsAsync(IEnumerable userIds) + { + var userIdList = userIds.ToList(); + return await _context.UserProfiles + .Where(up => userIdList.Contains(EF.Property(up, "_userId"))) + .ToListAsync(); + } + + public async Task ExistsAsync(Guid userId) + { + return await _context.UserProfiles + .AnyAsync(up => EF.Property(up, "_userId") == userId); + } +} diff --git a/services/social-service-net/src/SocialService.Infrastructure/Services/GraphQueryService.cs b/services/social-service-net/src/SocialService.Infrastructure/Services/GraphQueryService.cs new file mode 100644 index 00000000..590bcdc0 --- /dev/null +++ b/services/social-service-net/src/SocialService.Infrastructure/Services/GraphQueryService.cs @@ -0,0 +1,196 @@ +using Microsoft.EntityFrameworkCore; +using SocialService.Domain.AggregatesModel.RelationshipAggregate; +using SocialService.Domain.Services; + +namespace SocialService.Infrastructure.Services; + +/// +/// EN: Graph query service implementation using PostgreSQL Recursive CTEs. +/// VI: Implementation graph query service sử dụng PostgreSQL Recursive CTEs. +/// +public class GraphQueryService : IGraphQueryService +{ + private readonly SocialServiceContext _context; + + public GraphQueryService(SocialServiceContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + /// + /// EN: Get mutual friends between two users. + /// VI: Lấy danh sách bạn chung giữa hai users. + /// + public async Task> GetMutualFriendsAsync(Guid userId1, Guid userId2) + { + // EN: Find users who are friends with both userId1 and userId2 + // VI: Tìm users là bạn của cả userId1 và userId2 + var sql = @" + WITH user1_friends AS ( + SELECT CASE + WHEN requester_id = {0} THEN addressee_id + ELSE requester_id + END AS friend_id + FROM relationships + WHERE type_id = {2} + AND status_id = {3} + AND (requester_id = {0} OR addressee_id = {0}) + ), + user2_friends AS ( + SELECT CASE + WHEN requester_id = {1} THEN addressee_id + ELSE requester_id + END AS friend_id + FROM relationships + WHERE type_id = {2} + AND status_id = {3} + AND (requester_id = {1} OR addressee_id = {1}) + ) + SELECT u1.friend_id + FROM user1_friends u1 + INNER JOIN user2_friends u2 ON u1.friend_id = u2.friend_id"; + + var result = await _context.Database + .SqlQueryRaw(sql, + userId1, + userId2, + RelationshipType.Friendship.Id, + RelationshipStatus.Accepted.Id) + .ToListAsync(); + + return result; + } + + /// + /// EN: Get friend suggestions based on mutual friends (friends of friends). + /// VI: Gợi ý kết bạn dựa trên bạn chung (bạn của bạn). + /// + public async Task> GetFriendSuggestionsAsync(Guid userId, int limit = 10) + { + // EN: Find friends of friends who are not already friends with the user + // VI: Tìm bạn của bạn mà chưa là bạn với user + var sql = @" + WITH my_friends AS ( + SELECT CASE + WHEN requester_id = {0} THEN addressee_id + ELSE requester_id + END AS friend_id + FROM relationships + WHERE type_id = {1} + AND status_id = {2} + AND (requester_id = {0} OR addressee_id = {0}) + ), + friends_of_friends AS ( + SELECT CASE + WHEN r.requester_id = mf.friend_id THEN r.addressee_id + ELSE r.requester_id + END AS suggested_id, + mf.friend_id AS via_friend_id + FROM relationships r + INNER JOIN my_friends mf ON (r.requester_id = mf.friend_id OR r.addressee_id = mf.friend_id) + WHERE r.type_id = {1} + AND r.status_id = {2} + AND CASE + WHEN r.requester_id = mf.friend_id THEN r.addressee_id + ELSE r.requester_id + END != {0} + ), + suggestions AS ( + SELECT + suggested_id, + COUNT(*) AS mutual_count + FROM friends_of_friends fof + WHERE suggested_id NOT IN (SELECT friend_id FROM my_friends) + AND NOT EXISTS ( + SELECT 1 FROM user_blocks ub + WHERE (ub.blocker_id = {0} AND ub.blocked_id = fof.suggested_id) + OR (ub.blocker_id = fof.suggested_id AND ub.blocked_id = {0}) + ) + GROUP BY suggested_id + ) + SELECT suggested_id, mutual_count + FROM suggestions + ORDER BY mutual_count DESC + LIMIT {3}"; + + var result = await _context.Database + .SqlQueryRaw(sql, + userId, + RelationshipType.Friendship.Id, + RelationshipStatus.Accepted.Id, + limit) + .ToListAsync(); + + return result.Select(r => new FriendSuggestion(r.suggested_id, r.mutual_count)); + } + + /// + /// EN: Get the shortest connection path between two users (degrees of separation). + /// VI: Lấy đường dẫn kết nối ngắn nhất giữa hai users (bậc tách biệt). + /// + public async Task GetConnectionDegreeAsync(Guid userId1, Guid userId2) + { + const int maxDepth = 6; + + // EN: Use recursive CTE with limited depth (BFS) + // VI: Sử dụng recursive CTE với độ sâu giới hạn (BFS) + var sql = @" + WITH RECURSIVE connections AS ( + -- Base case: direct friends of userId1 + SELECT + CASE WHEN requester_id = {0} THEN addressee_id ELSE requester_id END AS user_id, + 1 AS degree, + ARRAY[{0}] AS path + FROM relationships + WHERE type_id = {2} + AND status_id = {3} + AND (requester_id = {0} OR addressee_id = {0}) + + UNION ALL + + -- Recursive case: friends of friends + SELECT + CASE WHEN r.requester_id = c.user_id THEN r.addressee_id ELSE r.requester_id END AS user_id, + c.degree + 1 AS degree, + c.path || c.user_id + FROM relationships r + INNER JOIN connections c ON (r.requester_id = c.user_id OR r.addressee_id = c.user_id) + WHERE r.type_id = {2} + AND r.status_id = {3} + AND c.degree < {4} + AND NOT (c.path @> ARRAY[CASE WHEN r.requester_id = c.user_id THEN r.addressee_id ELSE r.requester_id END]) + ) + SELECT COALESCE(MIN(degree), -1) AS min_degree + FROM connections + WHERE user_id = {1}"; + + var result = await _context.Database + .SqlQueryRaw(sql, + userId1, + userId2, + RelationshipType.Friendship.Id, + RelationshipStatus.Accepted.Id, + maxDepth) + .FirstOrDefaultAsync(); + + return result; + } + + /// + /// EN: Get mutual friends count between two users. + /// VI: Lấy số lượng bạn chung giữa hai users. + /// + public async Task GetMutualFriendsCountAsync(Guid userId1, Guid userId2) + { + var mutualFriends = await GetMutualFriendsAsync(userId1, userId2); + return mutualFriends.Count(); + } + + // EN: Internal class for mapping SQL result + // VI: Class nội bộ để map kết quả SQL + private class FriendSuggestionResult + { + public Guid suggested_id { get; set; } + public int mutual_count { get; set; } + } +} diff --git a/services/social-service-net/src/SocialService.Infrastructure/SocialService.Infrastructure.csproj b/services/social-service-net/src/SocialService.Infrastructure/SocialService.Infrastructure.csproj new file mode 100644 index 00000000..b6c56a62 --- /dev/null +++ b/services/social-service-net/src/SocialService.Infrastructure/SocialService.Infrastructure.csproj @@ -0,0 +1,36 @@ + + + + SocialService.Infrastructure + SocialService.Infrastructure + Infrastructure layer for data access and external services + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + + + diff --git a/services/social-service-net/src/SocialService.Infrastructure/SocialServiceContext.cs b/services/social-service-net/src/SocialService.Infrastructure/SocialServiceContext.cs new file mode 100644 index 00000000..0babe96d --- /dev/null +++ b/services/social-service-net/src/SocialService.Infrastructure/SocialServiceContext.cs @@ -0,0 +1,189 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; +using SocialService.Domain.AggregatesModel.RelationshipAggregate; +using SocialService.Domain.AggregatesModel.UserBlockAggregate; +using SocialService.Domain.AggregatesModel.UserProfileAggregate; +using SocialService.Domain.SeedWork; +using SocialService.Infrastructure.EntityConfigurations; + +namespace SocialService.Infrastructure; + +/// +/// EN: EF Core DbContext for SocialService. +/// VI: EF Core DbContext cho SocialService. +/// +public class SocialServiceContext : DbContext, IUnitOfWork +{ + private readonly IMediator _mediator; + private IDbContextTransaction? _currentTransaction; + + /// + /// EN: Relationships table. + /// VI: Bảng Relationships. + /// + public DbSet Relationships => Set(); + + /// + /// EN: Relationship types enumeration table. + /// VI: Bảng enumeration loại quan hệ. + /// + public DbSet RelationshipTypes => Set(); + + /// + /// EN: Relationship statuses enumeration table. + /// VI: Bảng enumeration trạng thái quan hệ. + /// + public DbSet RelationshipStatuses => Set(); + + /// + /// EN: User profiles table (synced from IAM). + /// VI: Bảng hồ sơ user (đồng bộ từ IAM). + /// + public DbSet UserProfiles => Set(); + + /// + /// EN: User blocks table. + /// VI: Bảng user blocks. + /// + public DbSet UserBlocks => Set(); + + /// + /// EN: Read-only access to current transaction. + /// VI: Truy cập chỉ đọc đến transaction hiện tại. + /// + public IDbContextTransaction? CurrentTransaction => _currentTransaction; + + /// + /// EN: Check if there is an active transaction. + /// VI: Kiểm tra xem có transaction đang hoạt động không. + /// + public bool HasActiveTransaction => _currentTransaction != null; + + public SocialServiceContext(DbContextOptions options) : base(options) + { + _mediator = null!; + } + + public SocialServiceContext(DbContextOptions options, IMediator mediator) : base(options) + { + _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); + + System.Diagnostics.Debug.WriteLine("SocialServiceContext::ctor - " + GetHashCode()); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // EN: Apply entity configurations + // VI: Áp dụng các cấu hình entity + modelBuilder.ApplyConfiguration(new RelationshipEntityTypeConfiguration()); + modelBuilder.ApplyConfiguration(new RelationshipTypeEntityTypeConfiguration()); + modelBuilder.ApplyConfiguration(new RelationshipStatusEntityTypeConfiguration()); + modelBuilder.ApplyConfiguration(new UserProfileEntityTypeConfiguration()); + modelBuilder.ApplyConfiguration(new UserBlockEntityTypeConfiguration()); + } + + /// + /// EN: Save entities and dispatch domain events. + /// VI: Lưu entities và dispatch domain events. + /// + public async Task SaveEntitiesAsync(CancellationToken cancellationToken = default) + { + // EN: Dispatch domain events before saving (side effects) + // VI: Dispatch domain events trước khi lưu (side effects) + await DispatchDomainEventsAsync(); + + // EN: Save changes to database + // VI: Lưu thay đổi vào database + await base.SaveChangesAsync(cancellationToken); + + return true; + } + + /// + /// EN: Begin a new transaction if none is active. + /// VI: Bắt đầu một transaction mới nếu không có transaction nào đang hoạt động. + /// + public async Task BeginTransactionAsync() + { + if (_currentTransaction != null) return null; + + _currentTransaction = await Database.BeginTransactionAsync(System.Data.IsolationLevel.ReadCommitted); + + return _currentTransaction; + } + + /// + /// EN: Commit the current transaction. + /// VI: Commit transaction hiện tại. + /// + public async Task CommitTransactionAsync(IDbContextTransaction transaction) + { + ArgumentNullException.ThrowIfNull(transaction); + + if (transaction != _currentTransaction) + throw new InvalidOperationException($"Transaction {transaction.TransactionId} is not current"); + + try + { + await SaveChangesAsync(); + await transaction.CommitAsync(); + } + catch + { + RollbackTransaction(); + throw; + } + finally + { + if (_currentTransaction != null) + { + _currentTransaction.Dispose(); + _currentTransaction = null; + } + } + } + + /// + /// EN: Rollback the current transaction. + /// VI: Rollback transaction hiện tại. + /// + public void RollbackTransaction() + { + try + { + _currentTransaction?.Rollback(); + } + finally + { + if (_currentTransaction != null) + { + _currentTransaction.Dispose(); + _currentTransaction = null; + } + } + } + + /// + /// EN: Dispatch all domain events from tracked entities. + /// VI: Dispatch tất cả domain events từ các entities đang được track. + /// + private async Task DispatchDomainEventsAsync() + { + var domainEntities = ChangeTracker + .Entries() + .Where(x => x.Entity.DomainEvents.Any()) + .ToList(); + + var domainEvents = domainEntities + .SelectMany(x => x.Entity.DomainEvents) + .ToList(); + + domainEntities.ForEach(entity => entity.Entity.ClearDomainEvents()); + + foreach (var domainEvent in domainEvents) + { + await _mediator.Publish(domainEvent); + } + } +} diff --git a/services/social-service-net/tests/SocialService.FunctionalTests/Controllers/SamplesControllerTests.cs b/services/social-service-net/tests/SocialService.FunctionalTests/Controllers/SamplesControllerTests.cs new file mode 100644 index 00000000..b4ee08ac --- /dev/null +++ b/services/social-service-net/tests/SocialService.FunctionalTests/Controllers/SamplesControllerTests.cs @@ -0,0 +1,80 @@ +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using Xunit; + +namespace SocialService.FunctionalTests.Controllers; + +/// +/// EN: Functional tests for Samples API endpoints. +/// VI: Functional tests cho các endpoints API Samples. +/// +public class SamplesControllerTests : IClassFixture +{ + private readonly HttpClient _client; + + public SamplesControllerTests(CustomWebApplicationFactory factory) + { + _client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + } + + [Fact] + public async Task GetSamples_ShouldReturnOkWithEmptyList() + { + // Act + var response = await _client.GetAsync("/api/v1/samples"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadFromJsonAsync>>(); + content?.Success.Should().BeTrue(); + } + + [Fact] + public async Task CreateSample_WithValidData_ShouldReturnCreated() + { + // Arrange + var request = new { Name = "Test Sample", Description = "Test Description" }; + + // Act + var response = await _client.PostAsJsonAsync("/api/v1/samples", request); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Created); + var content = await response.Content.ReadFromJsonAsync>(); + content?.Success.Should().BeTrue(); + content?.Data?.Id.Should().NotBeEmpty(); + } + + [Fact] + public async Task GetSample_WithInvalidId_ShouldReturnNotFound() + { + // Arrange + var invalidId = Guid.NewGuid(); + + // Act + var response = await _client.GetAsync($"/api/v1/samples/{invalidId}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task HealthCheck_ShouldReturnHealthy() + { + // Act + var response = await _client.GetAsync("/health/live"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + // EN: Helper DTOs for deserialization + // VI: Helper DTOs để deserialize + private record ApiResponse(bool Success, T? Data); + private record CreateSampleResult(Guid Id); +} diff --git a/services/social-service-net/tests/SocialService.FunctionalTests/CustomWebApplicationFactory.cs b/services/social-service-net/tests/SocialService.FunctionalTests/CustomWebApplicationFactory.cs new file mode 100644 index 00000000..d7074177 --- /dev/null +++ b/services/social-service-net/tests/SocialService.FunctionalTests/CustomWebApplicationFactory.cs @@ -0,0 +1,56 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SocialService.Infrastructure; + +namespace SocialService.FunctionalTests; + +/// +/// EN: Custom WebApplicationFactory for functional tests. +/// VI: WebApplicationFactory tùy chỉnh cho functional tests. +/// +public class CustomWebApplicationFactory : WebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Testing"); + + builder.ConfigureServices(services => + { + // EN: Remove the existing DbContext registration + // VI: Xóa đăng ký DbContext hiện tại + var descriptor = services.SingleOrDefault( + d => d.ServiceType == typeof(DbContextOptions)); + + if (descriptor != null) + { + services.Remove(descriptor); + } + + // EN: Remove DbContext service + // VI: Xóa DbContext service + var dbContextDescriptor = services.SingleOrDefault( + d => d.ServiceType == typeof(SocialServiceContext)); + + if (dbContextDescriptor != null) + { + services.Remove(dbContextDescriptor); + } + + // EN: Add in-memory database for testing + // VI: Thêm in-memory database để test + services.AddDbContext(options => + { + options.UseInMemoryDatabase("TestDatabase_" + Guid.NewGuid().ToString()); + }); + + // EN: Ensure database is created with seed data + // VI: Đảm bảo database được tạo với seed data + var sp = services.BuildServiceProvider(); + using var scope = sp.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.EnsureCreated(); + }); + } +} diff --git a/services/social-service-net/tests/SocialService.FunctionalTests/SocialService.FunctionalTests.csproj b/services/social-service-net/tests/SocialService.FunctionalTests/SocialService.FunctionalTests.csproj new file mode 100644 index 00000000..a68879a9 --- /dev/null +++ b/services/social-service-net/tests/SocialService.FunctionalTests/SocialService.FunctionalTests.csproj @@ -0,0 +1,38 @@ + + + + SocialService.FunctionalTests + SocialService.FunctionalTests + false + true + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/services/social-service-net/tests/SocialService.UnitTests/SocialService.UnitTests.csproj b/services/social-service-net/tests/SocialService.UnitTests/SocialService.UnitTests.csproj new file mode 100644 index 00000000..9eefaf65 --- /dev/null +++ b/services/social-service-net/tests/SocialService.UnitTests/SocialService.UnitTests.csproj @@ -0,0 +1,35 @@ + + + + SocialService.UnitTests + SocialService.UnitTests + false + true + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/services/storage-service-net/docs/en/ARCHITECTURE.md b/services/storage-service-net/docs/en/ARCHITECTURE.md index 9d80ba57..b3bf5391 100644 --- a/services/storage-service-net/docs/en/ARCHITECTURE.md +++ b/services/storage-service-net/docs/en/ARCHITECTURE.md @@ -1,6 +1,6 @@ -# Architecture Documentation +# Storage Service Architecture -> Detailed architecture documentation for the .NET 10 Microservice Template. +> Detailed architecture documentation for the Storage Service microservice. ## Architecture Overview @@ -11,179 +11,232 @@ graph TB CMD[Commands] Q[Queries] B[Behaviors] - V[Validations] end subgraph "Domain Layer" - AR[Aggregate Roots] - E[Entities] - VO[Value Objects] + SF[StorageFile] + SQ[UserStorageQuota] DE[Domain Events] - DX[Domain Exceptions] + RI[Repository Interfaces] end subgraph "Infrastructure Layer" - DB[(PostgreSQL)] + SP[Storage Providers] + IAM[IAM Client] R[Repositories] CTX[DbContext] - ID[Idempotency] + end + + subgraph "External Services" + MINIO[(MinIO)] + OSS[(Aliyun OSS)] + IAMS[IAM Service] + DB[(PostgreSQL)] end C --> CMD C --> Q - CMD --> B --> V - CMD --> AR + CMD --> B --> SF Q --> R R --> CTX --> DB - AR --> DE - R --> AR + SF --> DE + + CMD --> SP + SP --> MINIO + SP --> OSS + + C --> IAM --> IAMS style C fill:#4a90d9,stroke:#2d5986,color:#fff - style AR fill:#50c878,stroke:#2d8659,color:#fff - style DB fill:#ff6b6b,stroke:#c0392b,color:#fff + style SF fill:#50c878,stroke:#2d8659,color:#fff + style MINIO fill:#c73b3b,stroke:#922b2b,color:#fff + style OSS fill:#ff6b35,stroke:#cc5500,color:#fff + style IAMS fill:#9b59b6,stroke:#7d3c98,color:#fff ``` ## Layer Responsibilities -### 1. Domain Layer (MyService.Domain) +### 1. Domain Layer (StorageService.Domain) -The heart of the application containing pure business logic. This layer: -- Has **ZERO** external dependencies (except MediatR.Contracts for events) -- Contains only POCO classes -- Implements DDD tactical patterns - -#### Components +The heart of the application containing pure business logic. | Component | Purpose | |-----------|---------| -| **SeedWork** | Base classes: Entity, ValueObject, Enumeration, IAggregateRoot | -| **AggregatesModel** | Aggregate roots with their entities and value objects | -| **Events** | Domain events for cross-aggregate communication | -| **Exceptions** | Domain-specific exceptions for business rule violations | +| **StorageFile** | Aggregate root for file metadata and lifecycle | +| **UserStorageQuota** | Aggregate root for user storage limits and usage | +| **StorageProvider** | Enum: MinIO, AliyunOSS | +| **FileAccessLevel** | Enum: Private, Public, Shared | +| **Domain Events** | FileUploadedDomainEvent, FileDeletedDomainEvent, UserQuotaUpdatedDomainEvent | -### 2. Infrastructure Layer (MyService.Infrastructure) +### 2. Infrastructure Layer (StorageService.Infrastructure) -Technical implementations and external concerns: -- Database access (EF Core) -- Repository implementations -- External service integrations +Technical implementations and external integrations: -### 3. API Layer (MyService.API) +| Component | Purpose | +|-----------|---------| +| **MinioStorageProvider** | MinIO S3-compatible storage operations | +| **AliyunOssStorageProvider** | Alibaba Cloud OSS operations | +| **StorageProviderFactory** | Runtime provider selection based on config | +| **HttpIamServiceClient** | Inter-service communication with IAM | +| **FileRepository** | EF Core repository for StorageFile | +| **QuotaRepository** | EF Core repository for UserStorageQuota | + +### 3. API Layer (StorageService.API) Application entry point and CQRS implementation: -- Controllers for HTTP handling -- Commands for write operations -- Queries for read operations -- MediatR behaviors for cross-cutting concerns -## CQRS Flow +| Component | Purpose | +|-----------|---------| +| **FilesController** | File CRUD endpoints | +| **QuotaController** | User quota endpoints | +| **UploadFileCommand** | Handle file uploads | +| **DeleteFileCommand** | Handle file deletions | +| **Query Handlers** | Handle read operations | + +## Storage Provider Architecture + +```mermaid +graph TD + subgraph "Storage Provider Factory" + F[StorageProviderFactory] + C[Configuration] + end + + subgraph "Providers" + MP[MinioStorageProvider] + AP[AliyunOssStorageProvider] + end + + subgraph "Storage Backends" + MINIO[(MinIO)] + OSS[(Aliyun OSS)] + end + + C --> |STORAGE_PROVIDER=minio| F + C --> |STORAGE_PROVIDER=aliyun| F + F --> |GetProvider| MP + F --> |GetProvider| AP + MP --> MINIO + AP --> OSS + + style F fill:#4a90d9,stroke:#2d5986,color:#fff + style MP fill:#c73b3b,stroke:#922b2b,color:#fff + style AP fill:#ff6b35,stroke:#cc5500,color:#fff +``` + +### Storage Provider Interface + +```csharp +public interface IStorageProvider +{ + Task UploadAsync(Stream stream, string objectKey, ...); + Task DownloadAsync(string bucketName, string objectKey); + Task DeleteAsync(string bucketName, string objectKey); + Task ExistsAsync(string bucketName, string objectKey); + Task GetPreSignedDownloadUrlAsync(string bucketName, string objectKey, int expirationSeconds); + Task GetPreSignedUploadUrlAsync(string bucketName, string objectKey, int expirationSeconds); +} +``` + +## Inter-Service Communication ```mermaid sequenceDiagram participant Client - participant Controller - participant MediatR - participant LoggingBehavior - participant ValidatorBehavior - participant TransactionBehavior - participant CommandHandler - participant Repository - participant DbContext + participant Storage as Storage Service + participant Cache as In-Memory Cache + participant IAM as IAM Service - Client->>Controller: HTTP Request - Controller->>MediatR: Send(Command) - MediatR->>LoggingBehavior: Handle - LoggingBehavior->>ValidatorBehavior: Next() - ValidatorBehavior->>TransactionBehavior: Next() - TransactionBehavior->>CommandHandler: Next() - CommandHandler->>Repository: Add/Update/Delete - Repository->>DbContext: SaveEntitiesAsync() - DbContext-->>Repository: Success - Repository-->>CommandHandler: Result - CommandHandler-->>Controller: Response - Controller-->>Client: HTTP Response + Client->>Storage: Upload File (JWT) + Storage->>Cache: Check user cache + alt Cache Hit + Cache-->>Storage: User info + else Cache Miss + Storage->>IAM: GET /api/v1/users/me + Note over Storage,IAM: Headers: Authorization, X-Service-Name + IAM-->>Storage: User info + Storage->>Cache: Store (5 min TTL) + end + Storage->>Storage: Validate quota + Storage->>Storage: Upload to provider + Storage-->>Client: Upload result ``` -## Domain Events +### IAM Client Features -```mermaid -graph LR - AR[Aggregate Root] -->|Raises| DE[Domain Event] - DE -->|Dispatched by| CTX[DbContext] - CTX -->|Publishes to| M[MediatR] - M -->|Handled by| H1[Handler 1] - M -->|Handled by| H2[Handler 2] - - style AR fill:#50c878,stroke:#2d8659,color:#fff - style DE fill:#f39c12,stroke:#d68910,color:#fff - style M fill:#9b59b6,stroke:#7d3c98,color:#fff +| Feature | Description | +|---------|-------------| +| **Caching** | In-memory cache for user info (5 min TTL) | +| **Health Check** | Check IAM availability with caching (1 min TTL) | +| **Polly Resilience** | Retry (3x exponential) + Circuit Breaker | +| **Permission Check** | HasPermissionAsync, HasRoleAsync | + +### Available IAM Client Methods + +```csharp +// User Operations +Task ValidateUserAsync(string accessToken); +Task GetUserByIdAsync(string userId, string accessToken); +Task UserExistsAsync(string userId, string accessToken); +Task> GetUserRolesAsync(string userId, string accessToken); +Task> GetUserPermissionsAsync(string userId, string accessToken); +Task HasPermissionAsync(string userId, string permission, string accessToken); +Task HasRoleAsync(string userId, string role, string accessToken); + +// Health Check +Task CheckHealthAsync(); +Task IsAvailableAsync(); + +// Cache Management +void InvalidateUserCache(string userId); +void ClearCache(); ``` ## Database Schema -### Sample Aggregate - ```mermaid erDiagram - samples { + storage_files { uuid id PK - varchar(200) name - varchar(1000) description - int status_id FK + varchar file_name + varchar bucket_name + varchar object_key + varchar content_type + bigint file_size_bytes + varchar user_id + varchar tenant_id + int provider + int access_level + timestamp uploaded_at + timestamp expires_at + varchar checksum + boolean is_deleted + timestamp deleted_at + } + + user_storage_quotas { + uuid id PK + varchar user_id UK + bigint max_storage_bytes + bigint used_storage_bytes + int max_file_count + int current_file_count + varchar quota_tier + timestamp last_updated_at timestamp created_at - timestamp updated_at } - - sample_statuses { - int id PK - varchar(50) name - } - - samples ||--o{ sample_statuses : has ``` -## MediatR Pipeline +## API Endpoints -``` -Request → LoggingBehavior → ValidatorBehavior → TransactionBehavior → Handler → Response - │ │ │ - ▼ ▼ ▼ - Log start/end Validate Begin/Commit - + timing with Transaction - FluentValidation -``` - -### Behavior Order - -1. **LoggingBehavior** - Logs request handling with timing -2. **ValidatorBehavior** - Validates request using FluentValidation -3. **TransactionBehavior** - Wraps command handlers in database transactions - -## Error Handling - -### Exception Hierarchy - -``` -Exception -└── DomainException - └── SampleDomainException -``` - -### Problem Details (RFC 7807) - -All errors are returned in Problem Details format: - -```json -{ - "type": "https://tools.ietf.org/html/rfc7807", - "title": "Validation Error", - "status": 400, - "detail": "One or more validation errors occurred.", - "errors": { - "Name": ["Name is required"] - } -} -``` +| Method | Endpoint | Description | +|--------|----------|-------------| +| `POST` | `/api/v1/files/upload` | Upload file (max 100MB) | +| `GET` | `/api/v1/files` | List user files with pagination | +| `GET` | `/api/v1/files/{id}` | Get file metadata | +| `GET` | `/api/v1/files/{id}/download-url` | Get pre-signed download URL | +| `DELETE` | `/api/v1/files/{id}` | Delete file (soft delete) | +| `GET` | `/api/v1/quota` | Get user storage quota | ## Health Checks @@ -192,10 +245,10 @@ graph TD HC[Health Check Endpoint] HC --> |/health/live| L[Liveness] HC --> |/health/ready| R[Readiness] - HC --> |/health| F[Full Status] - R --> PG[(PostgreSQL)] - R --> RD[(Redis)] + R --> DB[(PostgreSQL)] + R --> MINIO[(MinIO)] + R --> IAM[IAM Service] style HC fill:#3498db,stroke:#2980b9,color:#fff style L fill:#2ecc71,stroke:#27ae60,color:#fff @@ -208,64 +261,50 @@ graph TD ```yaml services: - myservice-api: + storage-api: build: . - ports: ["5000:8080"] + ports: ["5002:8080"] depends_on: - postgres - redis + - minio + environment: + - Storage__Provider=minio + - Storage__MinIO__Endpoint=minio:9000 - postgres: - image: postgres:16-alpine - - redis: - image: redis:7-alpine + minio: + image: minio/minio:latest + ports: ["9000:9000", "9001:9001"] ``` -### Kubernetes (Production) +### Traefik Integration ```yaml -apiVersion: apps/v1 -kind: Deployment -metadata: - name: myservice-api -spec: - replicas: 3 - template: - spec: - containers: - - name: api - image: myservice:latest - ports: - - containerPort: 8080 - livenessProbe: - httpGet: - path: /health/live - port: 8080 - readinessProbe: - httpGet: - path: /health/ready - port: 8080 +labels: + - "traefik.enable=true" + - "traefik.http.routers.storage-service.rule=PathPrefix(`/api/v1/files`) || PathPrefix(`/api/v1/quota`)" + - "traefik.http.services.storage-service.loadbalancer.server.port=8080" ``` ## Security Considerations -1. **Authentication**: JWT Bearer token (configure in production) -2. **Authorization**: Role-based access control -3. **Input Validation**: FluentValidation on all requests -4. **SQL Injection**: EF Core parameterized queries -5. **Secrets**: Environment variables, never in code +1. **Authentication**: JWT Bearer validation via IAM Service +2. **Authorization**: User ownership check on files +3. **Input Validation**: File size limits, content type validation +4. **Pre-signed URLs**: Time-limited access to files +5. **Soft Delete**: Files are marked deleted, not immediately removed ## Performance Optimization -1. **Connection Pooling**: EF Core with Npgsql connection resilience -2. **Async/Await**: All I/O operations are async -3. **Response Caching**: Add caching headers for queries -4. **Database Indexes**: Configure in EntityConfigurations +1. **Caching**: In-memory cache for IAM user info +2. **Pre-signed URLs**: Direct client-to-storage downloads +3. **Streaming Upload**: Stream-based file handling +4. **Async Operations**: All I/O operations are async +5. **Connection Pooling**: HTTP client with Polly policies ## References -- [eShopOnContainers](https://github.com/dotnet-architecture/eShopOnContainers) -- [.NET Microservices Architecture Guide](https://docs.microsoft.com/en-us/dotnet/architecture/microservices/) -- [Domain-Driven Design](https://martinfowler.com/bliki/DomainDrivenDesign.html) -- [CQRS Pattern](https://docs.microsoft.com/en-us/azure/architecture/patterns/cqrs) +- [MinIO Documentation](https://min.io/docs/minio/) +- [Aliyun OSS Documentation](https://www.alibabacloud.com/help/oss) +- [Polly Resilience](https://github.com/App-vNext/Polly) +- [Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) diff --git a/services/storage-service-net/docs/en/README.md b/services/storage-service-net/docs/en/README.md index 4cb53d44..549703e1 100644 --- a/services/storage-service-net/docs/en/README.md +++ b/services/storage-service-net/docs/en/README.md @@ -1,193 +1,138 @@ -# .NET 10 Microservice Template +# Storage Service -> Enterprise-grade .NET 10 microservice template following DDD, CQRS, and Clean Architecture patterns. +> A .NET 10 microservice for file storage management supporting MinIO and Aliyun OSS. -## Overview +## Features -This template provides a production-ready structure for .NET microservices based on the eShopOnContainers reference architecture with: - -- **Domain-Driven Design (DDD)** - Aggregates, Entities, Value Objects, Domain Events -- **CQRS Pattern** - Separate Commands (write) and Queries (read) with MediatR -- **Clean Architecture** - Domain, Infrastructure, API layered separation -- **EF Core 10** - PostgreSQL with connection resilience -- **FluentValidation** - Request validation -- **API Versioning** - URL segment versioning -- **Health Checks** - Kubernetes-ready probes -- **Structured Logging** - Serilog with console and Seq - -## Prerequisites - -| Requirement | Version | -|-------------|---------| -| .NET SDK | 10.0.101+ | -| Docker | 24.0+ | -| PostgreSQL | 15+ (or use Docker) | - -```bash -# Check .NET version -dotnet --version -# Should output: 10.0.xxx -``` +- **Multi-provider Storage**: MinIO (S3-compatible) and Aliyun OSS +- **Runtime Provider Switching**: Switch between MinIO and Aliyun via environment variable +- **Complete File CRUD**: Upload, download, delete, list files +- **Pre-signed URLs**: Secure time-limited download/upload URLs +- **User Quotas**: Storage capacity and file count limits per user +- **Inter-service Communication**: JWT validation via IAM Service with caching ## Quick Start -### 1. Create New Service +### Prerequisites + +- .NET 10 SDK +- Docker & Docker Compose +- PostgreSQL (or Neon) +- MinIO (or Aliyun OSS account) + +### Run with Docker ```bash -# Copy template to new service -cp -r services/_template_dot_net services/your-service-name - -# Navigate to service directory -cd services/your-service-name - -# Rename all occurrences of "MyService" to "YourService" -find . -type f -name "*.cs" -exec sed -i '' 's/MyService/YourService/g' {} + -find . -type f -name "*.csproj" -exec sed -i '' 's/MyService/YourService/g' {} + -``` - -### 2. Configure Environment - -```bash -# Copy environment template -cp .env.example .env - -# Edit with your configuration -nano .env -``` - -### 3. Run with Docker - -```bash -# Start all services (API + PostgreSQL + Redis) +cd services/storage-service-net docker-compose up -d - -# View logs -docker-compose logs -f myservice-api ``` -### 4. Run Locally +Access: http://localhost:5002/swagger + +### Run Locally ```bash -# Restore dependencies +cd services/storage-service-net + +# Install dependencies dotnet restore -# Build all projects -dotnet build +# Run migrations (first time) +dotnet ef database update --project src/StorageService.Infrastructure --startup-project src/StorageService.API -# Run the API -dotnet run --project src/MyService.API +# Start the service +dotnet run --project src/StorageService.API ``` -## Project Structure +## Configuration -``` -_template_dot_net/ -├── src/ -│ ├── MyService.API/ # Presentation Layer (Controllers, CQRS) -│ │ ├── Controllers/ # API endpoints -│ │ ├── Application/ # CQRS Implementation -│ │ │ ├── Commands/ # Write operations (MediatR) -│ │ │ ├── Queries/ # Read operations -│ │ │ ├── Behaviors/ # MediatR pipeline behaviors -│ │ │ └── Validations/ # FluentValidation validators -│ │ ├── Middleware/ # Custom middleware -│ │ └── Program.cs # Application entry point -│ │ -│ ├── MyService.Domain/ # Domain Layer (Pure business logic) -│ │ ├── AggregatesModel/ # Aggregate roots and entities -│ │ ├── Events/ # Domain events -│ │ ├── Exceptions/ # Domain exceptions -│ │ └── SeedWork/ # Base classes (Entity, ValueObject, etc.) -│ │ -│ └── MyService.Infrastructure/ # Infrastructure Layer (Data access) -│ ├── EntityConfigurations/ # EF Core Fluent API configurations -│ ├── Repositories/ # Repository implementations -│ ├── Idempotency/ # Request idempotency handling -│ └── MyServiceContext.cs # DbContext with Unit of Work -│ -├── tests/ -│ ├── MyService.UnitTests/ # Unit tests (Domain, Application) -│ └── MyService.FunctionalTests/ # Integration tests (API endpoints) -│ -├── Dockerfile # Multi-stage Docker build -├── docker-compose.yml # Local development setup -├── global.json # .NET SDK version pinning -└── Directory.Build.props # Common MSBuild properties -``` +### Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `Storage__Provider` | Provider selection: `minio` or `aliyun` | `minio` | +| `Storage__DefaultBucket` | Default bucket name | `storage` | +| `Storage__MaxFileSizeBytes` | Maximum file size | `104857600` (100MB) | +| `Storage__PreSignedUrlExpirationSeconds` | Pre-signed URL expiration | `3600` | + +### MinIO Configuration + +| Variable | Description | Default | +|----------|-------------|---------| +| `Storage__MinIO__Endpoint` | MinIO server endpoint | `localhost:9000` | +| `Storage__MinIO__AccessKey` | Access key | - | +| `Storage__MinIO__SecretKey` | Secret key | - | +| `Storage__MinIO__UseSSL` | Enable SSL | `false` | + +### Aliyun OSS Configuration + +| Variable | Description | Default | +|----------|-------------|---------| +| `Storage__AliyunOSS__Endpoint` | OSS endpoint | - | +| `Storage__AliyunOSS__AccessKeyId` | Access key ID | - | +| `Storage__AliyunOSS__AccessKeySecret` | Access key secret | - | +| `Storage__AliyunOSS__Region` | OSS region | - | + +### IAM Service Configuration + +| Variable | Description | Default | +|----------|-------------|---------| +| `IamService__BaseUrl` | IAM Service URL | `http://localhost:5001` | +| `IamService__ServiceName` | Service identifier | `storage-service` | +| `IamService__TimeoutSeconds` | Request timeout | `30` | +| `IamService__CacheDurationSeconds` | User info cache TTL | `300` | +| `IamService__HealthCheckCacheDurationSeconds` | Health check cache TTL | `60` | ## API Endpoints +### Files + | Method | Endpoint | Description | |--------|----------|-------------| -| `GET` | `/api/v1/samples` | Get all samples | -| `GET` | `/api/v1/samples/{id}` | Get sample by ID | -| `POST` | `/api/v1/samples` | Create new sample | -| `PUT` | `/api/v1/samples/{id}` | Update sample | -| `DELETE` | `/api/v1/samples/{id}` | Delete sample | -| `PATCH` | `/api/v1/samples/{id}/status` | Change status | +| `POST` | `/api/v1/files/upload` | Upload a file (max 100MB) | +| `GET` | `/api/v1/files` | List user's files with pagination | +| `GET` | `/api/v1/files/{id}` | Get file metadata by ID | +| `GET` | `/api/v1/files/{id}/download-url` | Get pre-signed download URL | +| `DELETE` | `/api/v1/files/{id}` | Delete a file (soft delete) | -### Health Endpoints +### Quota -| Endpoint | Purpose | -|----------|---------| -| `/health` | Full health status | -| `/health/live` | Liveness probe | -| `/health/ready` | Readiness probe | +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/api/v1/quota` | Get current user's storage quota | -## CQRS Pattern +## Upload Example -### Commands (Write Operations) - -```csharp -// Define command -public record CreateSampleCommand(string Name, string? Description) - : IRequest; - -// Handle command -public class CreateSampleCommandHandler : IRequestHandler -{ - public async Task Handle(CreateSampleCommand request, CancellationToken ct) - { - var sample = new Sample(request.Name, request.Description); - _repository.Add(sample); - await _repository.UnitOfWork.SaveEntitiesAsync(ct); - return new CreateSampleCommandResult(sample.Id); - } -} +```bash +curl -X POST "http://localhost:5002/api/v1/files/upload" \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -F "file=@document.pdf" ``` -### Queries (Read Operations) +## Inter-Service Communication -```csharp -// Define query -public record GetSampleQuery(Guid SampleId) : IRequest; -``` +The service communicates with IAM Service for user validation: -## Domain Model +- **Headers**: `Authorization: Bearer `, `X-Service-Name: storage-service` +- **Caching**: User info cached for 5 minutes +- **Resilience**: Polly retry (3x) + circuit breaker +- **Methods**: + - `ValidateUserAsync` - Validate JWT and get user info + - `GetUserRolesAsync` - Get user roles + - `HasPermissionAsync` - Check specific permission -### Aggregate Root +## Database Migrations -```csharp -public class Sample : Entity, IAggregateRoot -{ - public string Name => _name; - public SampleStatus Status => _status; - - public Sample(string name, string? description) { - // Business logic validation - if (string.IsNullOrWhiteSpace(name)) - throw new SampleDomainException("Sample name cannot be empty"); - - // Domain event - AddDomainEvent(new SampleCreatedDomainEvent(this)); - } - - public void Activate() { - if (_status != SampleStatus.Draft) - throw new SampleDomainException("Only draft samples can be activated"); - // State transition - } -} +```bash +# Create new migration +dotnet ef migrations add MigrationName \ + --project src/StorageService.Infrastructure \ + --startup-project src/StorageService.API + +# Apply migrations +dotnet ef database update \ + --project src/StorageService.Infrastructure \ + --startup-project src/StorageService.API ``` ## Testing @@ -197,69 +142,28 @@ public class Sample : Entity, IAggregateRoot dotnet test # Run with coverage -dotnet test /p:CollectCoverage=true /p:CoverageReportFormat=cobertura - -# Run specific test project -dotnet test tests/MyService.UnitTests +dotnet test --collect:"XPlat Code Coverage" ``` -## Configuration +## Project Structure -### Environment Variables - -| Variable | Description | Default | -|----------|-------------|---------| -| `ASPNETCORE_ENVIRONMENT` | Environment name | `Development` | -| `DATABASE_URL` | PostgreSQL connection string | - | -| `REDIS_URL` | Redis connection string | - | -| `JWT_SECRET` | JWT signing secret (min 32 chars) | - | - -### appsettings.json - -```json -{ - "ConnectionStrings": { - "DefaultConnection": "Host=localhost;Database=myservice;Username=postgres;Password=postgres" - }, - "Serilog": { - "MinimumLevel": "Information" - } -} ``` - -## Deployment - -### Docker Build - -```bash -# Build Docker image -docker build -t myservice:latest . - -# Run container -docker run -p 5000:8080 --env-file .env myservice:latest +services/storage-service-net/ +├── src/ +│ ├── StorageService.API/ # Controllers, Commands, Queries +│ ├── StorageService.Domain/ # Entities, Repository interfaces +│ └── StorageService.Infrastructure/# Providers, DbContext, Repositories +├── tests/ +│ ├── StorageService.UnitTests/ +│ └── StorageService.FunctionalTests/ +├── docs/ +│ ├── en/ # English documentation +│ └── vi/ # Vietnamese documentation +├── docker-compose.yml +├── Dockerfile +└── README.md ``` -### Kubernetes - -See [ARCHITECTURE.md](./ARCHITECTURE.md) for Kubernetes deployment manifests. - -## What's New in .NET 10 - -- **C# 14** language features -- Improved **Native AOT** support -- Better **async/await** performance -- Enhanced **JSON serialization** -- Performance improvements across the board -- 3-year **LTS** support (until November 2028) - -## Resources - -- [eShopOnContainers](https://github.com/dotnet-architecture/eShopOnContainers) - Reference architecture -- [.NET 10 Documentation](https://docs.microsoft.com/en-us/dotnet/core/whats-new/dotnet-10) -- [DDD with .NET](https://docs.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/) -- [MediatR](https://github.com/jbogard/MediatR) - CQRS library -- [FluentValidation](https://docs.fluentvalidation.net/) - Validation library - ## License -Proprietary - GoodGo Platform +MIT diff --git a/services/storage-service-net/docs/vi/ARCHITECTURE.md b/services/storage-service-net/docs/vi/ARCHITECTURE.md index 55a5d13b..b5f03491 100644 --- a/services/storage-service-net/docs/vi/ARCHITECTURE.md +++ b/services/storage-service-net/docs/vi/ARCHITECTURE.md @@ -1,189 +1,242 @@ -# Tài Liệu Kiến Trúc +# Kiến Trúc Storage Service -> Tài liệu kiến trúc chi tiết cho Template Microservice .NET 10. +> Tài liệu kiến trúc chi tiết cho microservice Storage Service. ## Tổng Quan Kiến Trúc ```mermaid graph TB - subgraph "Lớp API" + subgraph "API Layer" C[Controllers] CMD[Commands] Q[Queries] B[Behaviors] - V[Validations] end - subgraph "Lớp Domain" - AR[Aggregate Roots] - E[Entities] - VO[Value Objects] + subgraph "Domain Layer" + SF[StorageFile] + SQ[UserStorageQuota] DE[Domain Events] - DX[Domain Exceptions] + RI[Repository Interfaces] end - subgraph "Lớp Infrastructure" - DB[(PostgreSQL)] + subgraph "Infrastructure Layer" + SP[Storage Providers] + IAM[IAM Client] R[Repositories] CTX[DbContext] - ID[Idempotency] + end + + subgraph "External Services" + MINIO[(MinIO)] + OSS[(Aliyun OSS)] + IAMS[IAM Service] + DB[(PostgreSQL)] end C --> CMD C --> Q - CMD --> B --> V - CMD --> AR + CMD --> B --> SF Q --> R R --> CTX --> DB - AR --> DE - R --> AR + SF --> DE + + CMD --> SP + SP --> MINIO + SP --> OSS + + C --> IAM --> IAMS style C fill:#4a90d9,stroke:#2d5986,color:#fff - style AR fill:#50c878,stroke:#2d8659,color:#fff - style DB fill:#ff6b6b,stroke:#c0392b,color:#fff + style SF fill:#50c878,stroke:#2d8659,color:#fff + style MINIO fill:#c73b3b,stroke:#922b2b,color:#fff + style OSS fill:#ff6b35,stroke:#cc5500,color:#fff + style IAMS fill:#9b59b6,stroke:#7d3c98,color:#fff ``` -## Trách Nhiệm Các Lớp +## Trách Nhiệm Từng Layer -### 1. Lớp Domain (MyService.Domain) +### 1. Domain Layer (StorageService.Domain) -Trái tim của ứng dụng chứa business logic thuần túy. Lớp này: -- Có **ZERO** phụ thuộc bên ngoài (ngoại trừ MediatR.Contracts cho events) -- Chỉ chứa các class POCO -- Triển khai các tactical patterns của DDD +Trái tim của ứng dụng chứa business logic thuần túy. -#### Thành Phần +| Component | Mục đích | +|-----------|----------| +| **StorageFile** | Aggregate root cho metadata file và lifecycle | +| **UserStorageQuota** | Aggregate root cho giới hạn và usage storage của user | +| **StorageProvider** | Enum: MinIO, AliyunOSS | +| **FileAccessLevel** | Enum: Private, Public, Shared | +| **Domain Events** | FileUploadedDomainEvent, FileDeletedDomainEvent, UserQuotaUpdatedDomainEvent | -| Thành phần | Mục Đích | -|------------|----------| -| **SeedWork** | Base classes: Entity, ValueObject, Enumeration, IAggregateRoot | -| **AggregatesModel** | Aggregate roots với entities và value objects | -| **Events** | Domain events cho giao tiếp cross-aggregate | -| **Exceptions** | Domain exceptions cho vi phạm business rules | +### 2. Infrastructure Layer (StorageService.Infrastructure) -### 2. Lớp Infrastructure (MyService.Infrastructure) +Triển khai kỹ thuật và tích hợp bên ngoài: -Triển khai kỹ thuật và các mối quan tâm bên ngoài: -- Truy cập database (EF Core) -- Triển khai repositories -- Tích hợp external services +| Component | Mục đích | +|-----------|----------| +| **MinioStorageProvider** | Thao tác storage tương thích MinIO S3 | +| **AliyunOssStorageProvider** | Thao tác Alibaba Cloud OSS | +| **StorageProviderFactory** | Chọn provider runtime dựa trên config | +| **HttpIamServiceClient** | Giao tiếp inter-service với IAM | +| **FileRepository** | EF Core repository cho StorageFile | +| **QuotaRepository** | EF Core repository cho UserStorageQuota | -### 3. Lớp API (MyService.API) +### 3. API Layer (StorageService.API) -Điểm vào ứng dụng và triển khai CQRS: -- Controllers để xử lý HTTP -- Commands cho các thao tác ghi -- Queries cho các thao tác đọc -- MediatR behaviors cho cross-cutting concerns +Entry point ứng dụng và triển khai CQRS: -## Luồng CQRS +| Component | Mục đích | +|-----------|----------| +| **FilesController** | Endpoints CRUD file | +| **QuotaController** | Endpoints quota của user | +| **UploadFileCommand** | Xử lý upload file | +| **DeleteFileCommand** | Xử lý xóa file | +| **Query Handlers** | Xử lý các thao tác đọc | + +## Kiến Trúc Storage Provider + +```mermaid +graph TD + subgraph "Storage Provider Factory" + F[StorageProviderFactory] + C[Configuration] + end + + subgraph "Providers" + MP[MinioStorageProvider] + AP[AliyunOssStorageProvider] + end + + subgraph "Storage Backends" + MINIO[(MinIO)] + OSS[(Aliyun OSS)] + end + + C --> |STORAGE_PROVIDER=minio| F + C --> |STORAGE_PROVIDER=aliyun| F + F --> |GetProvider| MP + F --> |GetProvider| AP + MP --> MINIO + AP --> OSS + + style F fill:#4a90d9,stroke:#2d5986,color:#fff + style MP fill:#c73b3b,stroke:#922b2b,color:#fff + style AP fill:#ff6b35,stroke:#cc5500,color:#fff +``` + +### Interface Storage Provider + +```csharp +public interface IStorageProvider +{ + Task UploadAsync(Stream stream, string objectKey, ...); + Task DownloadAsync(string bucketName, string objectKey); + Task DeleteAsync(string bucketName, string objectKey); + Task ExistsAsync(string bucketName, string objectKey); + Task GetPreSignedDownloadUrlAsync(string bucketName, string objectKey, int expirationSeconds); + Task GetPreSignedUploadUrlAsync(string bucketName, string objectKey, int expirationSeconds); +} +``` + +## Giao Tiếp Inter-Service ```mermaid sequenceDiagram participant Client - participant Controller - participant MediatR - participant LoggingBehavior - participant ValidatorBehavior - participant TransactionBehavior - participant CommandHandler - participant Repository - participant DbContext + participant Storage as Storage Service + participant Cache as In-Memory Cache + participant IAM as IAM Service - Client->>Controller: HTTP Request - Controller->>MediatR: Send(Command) - MediatR->>LoggingBehavior: Handle - LoggingBehavior->>ValidatorBehavior: Next() - ValidatorBehavior->>TransactionBehavior: Next() - TransactionBehavior->>CommandHandler: Next() - CommandHandler->>Repository: Add/Update/Delete - Repository->>DbContext: SaveEntitiesAsync() - DbContext-->>Repository: Success - Repository-->>CommandHandler: Result - CommandHandler-->>Controller: Response - Controller-->>Client: HTTP Response + Client->>Storage: Upload File (JWT) + Storage->>Cache: Kiểm tra cache user + alt Cache Hit + Cache-->>Storage: Thông tin user + else Cache Miss + Storage->>IAM: GET /api/v1/users/me + Note over Storage,IAM: Headers: Authorization, X-Service-Name + IAM-->>Storage: Thông tin user + Storage->>Cache: Lưu (5 phút TTL) + end + Storage->>Storage: Validate quota + Storage->>Storage: Upload lên provider + Storage-->>Client: Kết quả upload ``` -## Domain Events +### Tính Năng IAM Client -```mermaid -graph LR - AR[Aggregate Root] -->|Phát sinh| DE[Domain Event] - DE -->|Dispatch bởi| CTX[DbContext] - CTX -->|Publish tới| M[MediatR] - M -->|Xử lý bởi| H1[Handler 1] - M -->|Xử lý bởi| H2[Handler 2] - - style AR fill:#50c878,stroke:#2d8659,color:#fff - style DE fill:#f39c12,stroke:#d68910,color:#fff - style M fill:#9b59b6,stroke:#7d3c98,color:#fff +| Tính năng | Mô tả | +|-----------|-------| +| **Caching** | In-memory cache cho user info (5 phút TTL) | +| **Health Check** | Kiểm tra IAM availability với caching (1 phút TTL) | +| **Polly Resilience** | Retry (3x exponential) + Circuit Breaker | +| **Permission Check** | HasPermissionAsync, HasRoleAsync | + +### Các Phương Thức IAM Client + +```csharp +// Thao tác User +Task ValidateUserAsync(string accessToken); +Task GetUserByIdAsync(string userId, string accessToken); +Task UserExistsAsync(string userId, string accessToken); +Task> GetUserRolesAsync(string userId, string accessToken); +Task> GetUserPermissionsAsync(string userId, string accessToken); +Task HasPermissionAsync(string userId, string permission, string accessToken); +Task HasRoleAsync(string userId, string role, string accessToken); + +// Health Check +Task CheckHealthAsync(); +Task IsAvailableAsync(); + +// Quản lý Cache +void InvalidateUserCache(string userId); +void ClearCache(); ``` -## Schema Database - -### Sample Aggregate +## Database Schema ```mermaid erDiagram - samples { + storage_files { uuid id PK - varchar(200) name - varchar(1000) description - int status_id FK + varchar file_name + varchar bucket_name + varchar object_key + varchar content_type + bigint file_size_bytes + varchar user_id + varchar tenant_id + int provider + int access_level + timestamp uploaded_at + timestamp expires_at + varchar checksum + boolean is_deleted + timestamp deleted_at + } + + user_storage_quotas { + uuid id PK + varchar user_id UK + bigint max_storage_bytes + bigint used_storage_bytes + int max_file_count + int current_file_count + varchar quota_tier + timestamp last_updated_at timestamp created_at - timestamp updated_at } - - sample_statuses { - int id PK - varchar(50) name - } - - samples ||--o{ sample_statuses : has ``` -## Pipeline MediatR +## API Endpoints -``` -Request → LoggingBehavior → ValidatorBehavior → TransactionBehavior → Handler → Response - │ │ │ - ▼ ▼ ▼ - Log start/end Validate Begin/Commit - + timing với Transaction - FluentValidation -``` - -### Thứ Tự Behaviors - -1. **LoggingBehavior** - Ghi log xử lý request với timing -2. **ValidatorBehavior** - Validate request sử dụng FluentValidation -3. **TransactionBehavior** - Bao bọc command handlers trong database transactions - -## Xử Lý Lỗi - -### Phân Cấp Exceptions - -``` -Exception -└── DomainException - └── SampleDomainException -``` - -### Problem Details (RFC 7807) - -Tất cả lỗi được trả về theo định dạng Problem Details: - -```json -{ - "type": "https://tools.ietf.org/html/rfc7807", - "title": "Lỗi Validation", - "status": 400, - "detail": "Một hoặc nhiều lỗi validation đã xảy ra.", - "errors": { - "Name": ["Tên là bắt buộc"] - } -} -``` +| Method | Endpoint | Mô tả | +|--------|----------|-------| +| `POST` | `/api/v1/files/upload` | Upload file (tối đa 100MB) | +| `GET` | `/api/v1/files` | Danh sách file với phân trang | +| `GET` | `/api/v1/files/{id}` | Lấy metadata file | +| `GET` | `/api/v1/files/{id}/download-url` | Lấy pre-signed download URL | +| `DELETE` | `/api/v1/files/{id}` | Xóa file (soft delete) | +| `GET` | `/api/v1/quota` | Lấy quota storage của user | ## Health Checks @@ -192,10 +245,10 @@ graph TD HC[Health Check Endpoint] HC --> |/health/live| L[Liveness] HC --> |/health/ready| R[Readiness] - HC --> |/health| F[Full Status] - R --> PG[(PostgreSQL)] - R --> RD[(Redis)] + R --> DB[(PostgreSQL)] + R --> MINIO[(MinIO)] + R --> IAM[IAM Service] style HC fill:#3498db,stroke:#2980b9,color:#fff style L fill:#2ecc71,stroke:#27ae60,color:#fff @@ -208,64 +261,50 @@ graph TD ```yaml services: - myservice-api: + storage-api: build: . - ports: ["5000:8080"] + ports: ["5002:8080"] depends_on: - postgres - redis + - minio + environment: + - Storage__Provider=minio + - Storage__MinIO__Endpoint=minio:9000 - postgres: - image: postgres:16-alpine - - redis: - image: redis:7-alpine + minio: + image: minio/minio:latest + ports: ["9000:9000", "9001:9001"] ``` -### Kubernetes (Production) +### Tích Hợp Traefik ```yaml -apiVersion: apps/v1 -kind: Deployment -metadata: - name: myservice-api -spec: - replicas: 3 - template: - spec: - containers: - - name: api - image: myservice:latest - ports: - - containerPort: 8080 - livenessProbe: - httpGet: - path: /health/live - port: 8080 - readinessProbe: - httpGet: - path: /health/ready - port: 8080 +labels: + - "traefik.enable=true" + - "traefik.http.routers.storage-service.rule=PathPrefix(`/api/v1/files`) || PathPrefix(`/api/v1/quota`)" + - "traefik.http.services.storage-service.loadbalancer.server.port=8080" ``` ## Cân Nhắc Bảo Mật -1. **Authentication**: JWT Bearer token (cấu hình trong production) -2. **Authorization**: Role-based access control -3. **Input Validation**: FluentValidation trên tất cả requests -4. **SQL Injection**: EF Core parameterized queries -5. **Secrets**: Biến môi trường, không bao giờ trong code +1. **Authentication**: Xác thực JWT Bearer qua IAM Service +2. **Authorization**: Kiểm tra quyền sở hữu trên files +3. **Input Validation**: Giới hạn kích thước file, xác thực content type +4. **Pre-signed URLs**: Truy cập file có thời hạn +5. **Soft Delete**: Files được đánh dấu xóa, không xóa ngay lập tức -## Tối Ưu Hiệu Năng +## Tối Ưu Hiệu Suất -1. **Connection Pooling**: EF Core với Npgsql connection resilience -2. **Async/Await**: Tất cả I/O operations đều async -3. **Response Caching**: Thêm caching headers cho queries -4. **Database Indexes**: Cấu hình trong EntityConfigurations +1. **Caching**: In-memory cache cho IAM user info +2. **Pre-signed URLs**: Client download trực tiếp từ storage +3. **Streaming Upload**: Xử lý file dựa trên stream +4. **Async Operations**: Tất cả thao tác I/O đều async +5. **Connection Pooling**: HTTP client với Polly policies ## Tài Liệu Tham Khảo -- [eShopOnContainers](https://github.com/dotnet-architecture/eShopOnContainers) -- [Hướng dẫn Kiến trúc .NET Microservices](https://docs.microsoft.com/en-us/dotnet/architecture/microservices/) -- [Domain-Driven Design](https://martinfowler.com/bliki/DomainDrivenDesign.html) -- [CQRS Pattern](https://docs.microsoft.com/en-us/azure/architecture/patterns/cqrs) +- [MinIO Documentation](https://min.io/docs/minio/) +- [Aliyun OSS Documentation](https://www.alibabacloud.com/help/oss) +- [Polly Resilience](https://github.com/App-vNext/Polly) +- [Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) diff --git a/services/storage-service-net/docs/vi/README.md b/services/storage-service-net/docs/vi/README.md index 7d7e48b6..54ca86a1 100644 --- a/services/storage-service-net/docs/vi/README.md +++ b/services/storage-service-net/docs/vi/README.md @@ -1,265 +1,169 @@ -# Template Microservice .NET 10 +# Storage Service -> Template microservice .NET 10 cấp doanh nghiệp theo các pattern DDD, CQRS và Clean Architecture. +> Microservice .NET 10 để quản lý lưu trữ file hỗ trợ MinIO và Aliyun OSS. -## Tổng Quan +## Tính Năng -Template này cung cấp cấu trúc sẵn sàng production cho microservices .NET dựa trên kiến trúc tham chiếu eShopOnContainers với: - -- **Domain-Driven Design (DDD)** - Aggregates, Entities, Value Objects, Domain Events -- **CQRS Pattern** - Tách biệt Commands (ghi) và Queries (đọc) với MediatR -- **Clean Architecture** - Phân tầng Domain, Infrastructure, API -- **EF Core 10** - PostgreSQL với connection resilience -- **FluentValidation** - Validation request -- **API Versioning** - Versioning theo URL segment -- **Health Checks** - Probes sẵn sàng cho Kubernetes -- **Structured Logging** - Serilog với console và Seq - -## Yêu Cầu - -| Yêu cầu | Phiên bản | -|---------|-----------| -| .NET SDK | 10.0.101+ | -| Docker | 24.0+ | -| PostgreSQL | 15+ (hoặc dùng Docker) | - -```bash -# Kiểm tra phiên bản .NET -dotnet --version -# Kết quả nên là: 10.0.xxx -``` +- **Multi-provider Storage**: MinIO (tương thích S3) và Aliyun OSS +- **Chuyển đổi Provider Runtime**: Chuyển đổi giữa MinIO và Aliyun qua biến môi trường +- **CRUD File Đầy Đủ**: Upload, download, delete, list files +- **Pre-signed URLs**: URL download/upload an toàn có thời hạn +- **Quota User**: Giới hạn dung lượng và số file cho mỗi user +- **Giao tiếp Inter-service**: Xác thực JWT qua IAM Service với caching ## Bắt Đầu Nhanh -### 1. Tạo Service Mới +### Yêu Cầu + +- .NET 10 SDK +- Docker & Docker Compose +- PostgreSQL (hoặc Neon) +- MinIO (hoặc tài khoản Aliyun OSS) + +### Chạy với Docker ```bash -# Sao chép template sang service mới -cp -r services/_template_dot_net services/your-service-name - -# Di chuyển đến thư mục service -cd services/your-service-name - -# Đổi tên tất cả "MyService" thành "YourService" -find . -type f -name "*.cs" -exec sed -i '' 's/MyService/YourService/g' {} + -find . -type f -name "*.csproj" -exec sed -i '' 's/MyService/YourService/g' {} + -``` - -### 2. Cấu Hình Môi Trường - -```bash -# Sao chép template môi trường -cp .env.example .env - -# Chỉnh sửa với cấu hình của bạn -nano .env -``` - -### 3. Chạy với Docker - -```bash -# Khởi động tất cả services (API + PostgreSQL + Redis) +cd services/storage-service-net docker-compose up -d - -# Xem logs -docker-compose logs -f myservice-api ``` -### 4. Chạy Local +Truy cập: http://localhost:5002/swagger + +### Chạy Local ```bash -# Khôi phục dependencies +cd services/storage-service-net + +# Cài đặt dependencies dotnet restore -# Build tất cả projects -dotnet build +# Chạy migrations (lần đầu) +dotnet ef database update --project src/StorageService.Infrastructure --startup-project src/StorageService.API -# Chạy API -dotnet run --project src/MyService.API -``` - -## Cấu Trúc Dự Án - -``` -_template_dot_net/ -├── src/ -│ ├── MyService.API/ # Lớp Presentation (Controllers, CQRS) -│ │ ├── Controllers/ # Các API endpoints -│ │ ├── Application/ # Triển khai CQRS -│ │ │ ├── Commands/ # Thao tác ghi (MediatR) -│ │ │ ├── Queries/ # Thao tác đọc -│ │ │ ├── Behaviors/ # MediatR pipeline behaviors -│ │ │ └── Validations/ # FluentValidation validators -│ │ ├── Middleware/ # Custom middleware -│ │ └── Program.cs # Điểm vào ứng dụng -│ │ -│ ├── MyService.Domain/ # Lớp Domain (Business logic thuần túy) -│ │ ├── AggregatesModel/ # Aggregate roots và entities -│ │ ├── Events/ # Domain events -│ │ ├── Exceptions/ # Domain exceptions -│ │ └── SeedWork/ # Base classes (Entity, ValueObject, etc.) -│ │ -│ └── MyService.Infrastructure/ # Lớp Infrastructure (Truy cập dữ liệu) -│ ├── EntityConfigurations/ # Cấu hình EF Core Fluent API -│ ├── Repositories/ # Triển khai repositories -│ ├── Idempotency/ # Xử lý idempotency request -│ └── MyServiceContext.cs # DbContext với Unit of Work -│ -├── tests/ -│ ├── MyService.UnitTests/ # Unit tests (Domain, Application) -│ └── MyService.FunctionalTests/ # Integration tests (API endpoints) -│ -├── Dockerfile # Multi-stage Docker build -├── docker-compose.yml # Thiết lập phát triển local -├── global.json # Pin phiên bản .NET SDK -└── Directory.Build.props # Thuộc tính MSBuild chung -``` - -## Các Endpoint API - -| Method | Endpoint | Mô Tả | -|--------|----------|-------| -| `GET` | `/api/v1/samples` | Lấy tất cả samples | -| `GET` | `/api/v1/samples/{id}` | Lấy sample theo ID | -| `POST` | `/api/v1/samples` | Tạo sample mới | -| `PUT` | `/api/v1/samples/{id}` | Cập nhật sample | -| `DELETE` | `/api/v1/samples/{id}` | Xóa sample | -| `PATCH` | `/api/v1/samples/{id}/status` | Thay đổi trạng thái | - -### Health Endpoints - -| Endpoint | Mục Đích | -|----------|----------| -| `/health` | Trạng thái health đầy đủ | -| `/health/live` | Kiểm tra sống | -| `/health/ready` | Kiểm tra sẵn sàng | - -## Pattern CQRS - -### Commands (Thao Tác Ghi) - -```csharp -// Định nghĩa command -public record CreateSampleCommand(string Name, string? Description) - : IRequest; - -// Xử lý command -public class CreateSampleCommandHandler : IRequestHandler -{ - public async Task Handle(CreateSampleCommand request, CancellationToken ct) - { - var sample = new Sample(request.Name, request.Description); - _repository.Add(sample); - await _repository.UnitOfWork.SaveEntitiesAsync(ct); - return new CreateSampleCommandResult(sample.Id); - } -} -``` - -### Queries (Thao Tác Đọc) - -```csharp -// Định nghĩa query -public record GetSampleQuery(Guid SampleId) : IRequest; -``` - -## Domain Model - -### Aggregate Root - -```csharp -public class Sample : Entity, IAggregateRoot -{ - public string Name => _name; - public SampleStatus Status => _status; - - public Sample(string name, string? description) { - // Validation business logic - if (string.IsNullOrWhiteSpace(name)) - throw new SampleDomainException("Tên sample không được để trống"); - - // Domain event - AddDomainEvent(new SampleCreatedDomainEvent(this)); - } - - public void Activate() { - if (_status != SampleStatus.Draft) - throw new SampleDomainException("Chỉ sample draft mới có thể kích hoạt"); - // Chuyển đổi trạng thái - } -} -``` - -## Kiểm Thử - -```bash -# Chạy tất cả tests -dotnet test - -# Chạy với coverage -dotnet test /p:CollectCoverage=true /p:CoverageReportFormat=cobertura - -# Chạy project test cụ thể -dotnet test tests/MyService.UnitTests +# Khởi động service +dotnet run --project src/StorageService.API ``` ## Cấu Hình ### Biến Môi Trường -| Biến | Mô Tả | Mặc định | +| Biến | Mô tả | Mặc định | |------|-------|----------| -| `ASPNETCORE_ENVIRONMENT` | Tên môi trường | `Development` | -| `DATABASE_URL` | Connection string PostgreSQL | - | -| `REDIS_URL` | Connection string Redis | - | -| `JWT_SECRET` | Secret ký JWT (tối thiểu 32 ký tự) | - | +| `Storage__Provider` | Chọn provider: `minio` hoặc `aliyun` | `minio` | +| `Storage__DefaultBucket` | Tên bucket mặc định | `storage` | +| `Storage__MaxFileSizeBytes` | Kích thước file tối đa | `104857600` (100MB) | +| `Storage__PreSignedUrlExpirationSeconds` | Thời hạn pre-signed URL | `3600` | -### appsettings.json +### Cấu Hình MinIO -```json -{ - "ConnectionStrings": { - "DefaultConnection": "Host=localhost;Database=myservice;Username=postgres;Password=postgres" - }, - "Serilog": { - "MinimumLevel": "Information" - } -} -``` +| Biến | Mô tả | Mặc định | +|------|-------|----------| +| `Storage__MinIO__Endpoint` | Endpoint server MinIO | `localhost:9000` | +| `Storage__MinIO__AccessKey` | Access key | - | +| `Storage__MinIO__SecretKey` | Secret key | - | +| `Storage__MinIO__UseSSL` | Bật SSL | `false` | -## Triển Khai +### Cấu Hình Aliyun OSS -### Docker Build +| Biến | Mô tả | Mặc định | +|------|-------|----------| +| `Storage__AliyunOSS__Endpoint` | OSS endpoint | - | +| `Storage__AliyunOSS__AccessKeyId` | Access key ID | - | +| `Storage__AliyunOSS__AccessKeySecret` | Access key secret | - | +| `Storage__AliyunOSS__Region` | OSS region | - | + +### Cấu Hình IAM Service + +| Biến | Mô tả | Mặc định | +|------|-------|----------| +| `IamService__BaseUrl` | URL IAM Service | `http://localhost:5001` | +| `IamService__ServiceName` | Định danh service | `storage-service` | +| `IamService__TimeoutSeconds` | Timeout request | `30` | +| `IamService__CacheDurationSeconds` | TTL cache user info | `300` | +| `IamService__HealthCheckCacheDurationSeconds` | TTL cache health check | `60` | + +## API Endpoints + +### Files + +| Method | Endpoint | Mô tả | +|--------|----------|-------| +| `POST` | `/api/v1/files/upload` | Upload file (tối đa 100MB) | +| `GET` | `/api/v1/files` | Danh sách files với phân trang | +| `GET` | `/api/v1/files/{id}` | Lấy metadata file theo ID | +| `GET` | `/api/v1/files/{id}/download-url` | Lấy pre-signed download URL | +| `DELETE` | `/api/v1/files/{id}` | Xóa file (soft delete) | + +### Quota + +| Method | Endpoint | Mô tả | +|--------|----------|-------| +| `GET` | `/api/v1/quota` | Lấy quota storage của user hiện tại | + +## Ví Dụ Upload ```bash -# Build Docker image -docker build -t myservice:latest . - -# Chạy container -docker run -p 5000:8080 --env-file .env myservice:latest +curl -X POST "http://localhost:5002/api/v1/files/upload" \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -F "file=@document.pdf" ``` -### Kubernetes +## Giao Tiếp Inter-Service -Xem [ARCHITECTURE.md](./ARCHITECTURE.md) để biết manifests triển khai Kubernetes. +Service giao tiếp với IAM Service để xác thực user: -## Có Gì Mới Trong .NET 10 +- **Headers**: `Authorization: Bearer `, `X-Service-Name: storage-service` +- **Caching**: User info được cache 5 phút +- **Resilience**: Polly retry (3x) + circuit breaker +- **Các phương thức**: + - `ValidateUserAsync` - Xác thực JWT và lấy thông tin user + - `GetUserRolesAsync` - Lấy roles của user + - `HasPermissionAsync` - Kiểm tra permission cụ thể -- Tính năng ngôn ngữ **C# 14** -- Hỗ trợ **Native AOT** được cải thiện -- Hiệu suất **async/await** tốt hơn -- **JSON serialization** được nâng cao -- Cải thiện hiệu suất toàn diện -- Hỗ trợ **LTS** 3 năm (đến tháng 11/2028) +## Database Migrations -## Tài Nguyên +```bash +# Tạo migration mới +dotnet ef migrations add TenMigration \ + --project src/StorageService.Infrastructure \ + --startup-project src/StorageService.API -- [eShopOnContainers](https://github.com/dotnet-architecture/eShopOnContainers) - Kiến trúc tham chiếu -- [Tài liệu .NET 10](https://docs.microsoft.com/en-us/dotnet/core/whats-new/dotnet-10) -- [DDD với .NET](https://docs.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/) -- [MediatR](https://github.com/jbogard/MediatR) - Thư viện CQRS -- [FluentValidation](https://docs.fluentvalidation.net/) - Thư viện validation +# Áp dụng migrations +dotnet ef database update \ + --project src/StorageService.Infrastructure \ + --startup-project src/StorageService.API +``` -## Giấy Phép +## Testing -Độc quyền - GoodGo Platform +```bash +# Chạy tất cả tests +dotnet test + +# Chạy với coverage +dotnet test --collect:"XPlat Code Coverage" +``` + +## Cấu Trúc Dự Án + +``` +services/storage-service-net/ +├── src/ +│ ├── StorageService.API/ # Controllers, Commands, Queries +│ ├── StorageService.Domain/ # Entities, Repository interfaces +│ └── StorageService.Infrastructure/# Providers, DbContext, Repositories +├── tests/ +│ ├── StorageService.UnitTests/ +│ └── StorageService.FunctionalTests/ +├── docs/ +│ ├── en/ # Tài liệu tiếng Anh +│ └── vi/ # Tài liệu tiếng Việt +├── docker-compose.yml +├── Dockerfile +└── README.md +``` + +## License + +MIT diff --git a/services/storage-service-net/src/StorageService.API/appsettings.Development.json b/services/storage-service-net/src/StorageService.API/appsettings.Development.json index e4538820..2ef8110e 100644 --- a/services/storage-service-net/src/StorageService.API/appsettings.Development.json +++ b/services/storage-service-net/src/StorageService.API/appsettings.Development.json @@ -32,6 +32,8 @@ "IamService": { "BaseUrl": "http://localhost:5001", "ServiceName": "storage-service", - "TimeoutSeconds": 30 + "TimeoutSeconds": 30, + "CacheDurationSeconds": 300, + "HealthCheckCacheDurationSeconds": 60 } } \ No newline at end of file diff --git a/services/storage-service-net/src/StorageService.Infrastructure/ExternalServices/HttpIamServiceClient.cs b/services/storage-service-net/src/StorageService.Infrastructure/ExternalServices/HttpIamServiceClient.cs index 8d2f92fe..e3335e58 100644 --- a/services/storage-service-net/src/StorageService.Infrastructure/ExternalServices/HttpIamServiceClient.cs +++ b/services/storage-service-net/src/StorageService.Infrastructure/ExternalServices/HttpIamServiceClient.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Net.Http.Headers; using System.Net.Http.Json; using Microsoft.Extensions.Logging; @@ -6,14 +7,23 @@ using Microsoft.Extensions.Options; namespace StorageService.Infrastructure.ExternalServices; /// -/// EN: HTTP client for communicating with IAM Service. -/// VI: HTTP client để giao tiếp với IAM Service. +/// EN: HTTP client for communicating with IAM Service with caching and health check. +/// VI: HTTP client để giao tiếp với IAM Service với caching và health check. /// public class HttpIamServiceClient : IIamServiceClient { private readonly HttpClient _httpClient; private readonly IamServiceSettings _settings; private readonly ILogger _logger; + + // EN: In-memory cache for user info / VI: Cache in-memory cho user info + private readonly ConcurrentDictionary> _userCache = new(); + private readonly ConcurrentDictionary>> _rolesCache = new(); + private readonly ConcurrentDictionary>> _permissionsCache = new(); + + // EN: Health check cache / VI: Cache health check + private CachedItem? _healthCache; + private readonly object _healthLock = new(); public HttpIamServiceClient( HttpClient httpClient, @@ -28,17 +38,26 @@ public class HttpIamServiceClient : IIamServiceClient _httpClient.Timeout = TimeSpan.FromSeconds(_settings.TimeoutSeconds); } + // ============================================ + // EN: User Operations / VI: Thao tác User + // ============================================ + /// public async Task ValidateUserAsync( string accessToken, CancellationToken cancellationToken = default) { + // EN: Check cache first / VI: Kiểm tra cache trước + var cacheKey = $"token:{accessToken.GetHashCode()}"; + if (TryGetFromCache(_userCache, cacheKey, out var cached)) + { + _logger.LogDebug("User info retrieved from cache"); + return cached; + } + try { - var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/users/me"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); - request.Headers.Add("X-Service-Name", _settings.ServiceName); - + var request = CreateRequest(HttpMethod.Get, "/api/v1/users/me", accessToken); var response = await _httpClient.SendAsync(request, cancellationToken); if (!response.IsSuccessStatusCode) @@ -52,12 +71,18 @@ public class HttpIamServiceClient : IIamServiceClient if (userResponse?.Data == null) return null; - return new IamUserInfo( + var userInfo = new IamUserInfo( userResponse.Data.Id, userResponse.Data.Email, userResponse.Data.DisplayName, true, - userResponse.Data.Roles ?? new List()); + userResponse.Data.Roles ?? new List(), + userResponse.Data.Permissions); + + // EN: Cache the result / VI: Cache kết quả + AddToCache(_userCache, cacheKey, userInfo, _settings.CacheDurationSeconds); + + return userInfo; } catch (Exception ex) { @@ -66,29 +91,343 @@ public class HttpIamServiceClient : IIamServiceClient } } + /// + public async Task GetUserByIdAsync( + string userId, + string accessToken, + CancellationToken cancellationToken = default) + { + // EN: Check cache first / VI: Kiểm tra cache trước + var cacheKey = $"user:{userId}"; + if (TryGetFromCache(_userCache, cacheKey, out var cached)) + { + _logger.LogDebug("User {UserId} retrieved from cache", userId); + return cached; + } + + try + { + var request = CreateRequest(HttpMethod.Get, $"/api/v1/users/{userId}", accessToken); + var response = await _httpClient.SendAsync(request, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("Failed to get user {UserId}. Status: {StatusCode}", userId, response.StatusCode); + return null; + } + + var userResponse = await response.Content.ReadFromJsonAsync(cancellationToken); + + if (userResponse?.Data == null) + return null; + + var userInfo = new IamUserInfo( + userResponse.Data.Id, + userResponse.Data.Email, + userResponse.Data.DisplayName, + userResponse.Data.IsActive, + userResponse.Data.Roles ?? new List(), + userResponse.Data.Permissions); + + // EN: Cache the result / VI: Cache kết quả + AddToCache(_userCache, cacheKey, userInfo, _settings.CacheDurationSeconds); + + return userInfo; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting user {UserId} from IAM Service", userId); + return null; + } + } + /// public async Task UserExistsAsync( string userId, string accessToken, CancellationToken cancellationToken = default) { + var user = await GetUserByIdAsync(userId, accessToken, cancellationToken); + return user != null; + } + + /// + public async Task> GetUserRolesAsync( + string userId, + string accessToken, + CancellationToken cancellationToken = default) + { + // EN: Check cache first / VI: Kiểm tra cache trước + var cacheKey = $"roles:{userId}"; + if (TryGetFromCache(_rolesCache, cacheKey, out var cached)) + { + return cached; + } + try { - var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/users/{userId}"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); - request.Headers.Add("X-Service-Name", _settings.ServiceName); - + var request = CreateRequest(HttpMethod.Get, $"/api/v1/users/{userId}/roles", accessToken); var response = await _httpClient.SendAsync(request, cancellationToken); - return response.IsSuccessStatusCode; + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("Failed to get roles for user {UserId}. Status: {StatusCode}", userId, response.StatusCode); + return Array.Empty(); + } + + var rolesResponse = await response.Content.ReadFromJsonAsync(cancellationToken); + var roles = rolesResponse?.Data ?? Array.Empty(); + + // EN: Cache the result / VI: Cache kết quả + AddToCache(_rolesCache, cacheKey, roles, _settings.CacheDurationSeconds); + + return roles; } catch (Exception ex) { - _logger.LogError(ex, "Error checking user exists in IAM Service: {UserId}", userId); - return false; + _logger.LogError(ex, "Error getting roles for user {UserId}", userId); + return Array.Empty(); } } - // EN: Internal DTOs for IAM Service responses / VI: Internal DTOs cho responses từ IAM Service + /// + public async Task> GetUserPermissionsAsync( + string userId, + string accessToken, + CancellationToken cancellationToken = default) + { + // EN: Check cache first / VI: Kiểm tra cache trước + var cacheKey = $"permissions:{userId}"; + if (TryGetFromCache(_permissionsCache, cacheKey, out var cached)) + { + return cached; + } + + try + { + var request = CreateRequest(HttpMethod.Get, $"/api/v1/users/{userId}/permissions", accessToken); + var response = await _httpClient.SendAsync(request, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("Failed to get permissions for user {UserId}. Status: {StatusCode}", userId, response.StatusCode); + return Array.Empty(); + } + + var permissionsResponse = await response.Content.ReadFromJsonAsync(cancellationToken); + var permissions = permissionsResponse?.Data ?? Array.Empty(); + + // EN: Cache the result / VI: Cache kết quả + AddToCache(_permissionsCache, cacheKey, permissions, _settings.CacheDurationSeconds); + + return permissions; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting permissions for user {UserId}", userId); + return Array.Empty(); + } + } + + /// + public async Task HasPermissionAsync( + string userId, + string permission, + string accessToken, + CancellationToken cancellationToken = default) + { + var permissions = await GetUserPermissionsAsync(userId, accessToken, cancellationToken); + return permissions.Contains(permission, StringComparer.OrdinalIgnoreCase); + } + + /// + public async Task HasRoleAsync( + string userId, + string role, + string accessToken, + CancellationToken cancellationToken = default) + { + var roles = await GetUserRolesAsync(userId, accessToken, cancellationToken); + return roles.Contains(role, StringComparer.OrdinalIgnoreCase); + } + + // ============================================ + // EN: Health Check / VI: Kiểm tra Health + // ============================================ + + /// + public async Task CheckHealthAsync(CancellationToken cancellationToken = default) + { + // EN: Check cache first / VI: Kiểm tra cache trước + lock (_healthLock) + { + if (_healthCache != null && !_healthCache.IsExpired) + { + _logger.LogDebug("Health status retrieved from cache"); + return _healthCache.Value; + } + } + + try + { + var request = new HttpRequestMessage(HttpMethod.Get, "/health"); + var response = await _httpClient.SendAsync(request, cancellationToken); + + var healthStatus = new IamHealthStatus( + response.IsSuccessStatusCode, + response.IsSuccessStatusCode ? "Healthy" : $"Unhealthy ({response.StatusCode})", + DateTime.UtcNow); + + // EN: Cache the result / VI: Cache kết quả + lock (_healthLock) + { + _healthCache = new CachedItem(healthStatus, _settings.HealthCheckCacheDurationSeconds); + } + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("IAM Service health check failed. Status: {StatusCode}", response.StatusCode); + } + + return healthStatus; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking IAM Service health"); + + var unhealthyStatus = new IamHealthStatus(false, $"Error: {ex.Message}", DateTime.UtcNow); + + // EN: Cache unhealthy status for shorter duration / VI: Cache trạng thái unhealthy với thời gian ngắn hơn + lock (_healthLock) + { + _healthCache = new CachedItem(unhealthyStatus, 10); // 10 seconds + } + + return unhealthyStatus; + } + } + + /// + public async Task IsAvailableAsync(CancellationToken cancellationToken = default) + { + var health = await CheckHealthAsync(cancellationToken); + return health.IsHealthy; + } + + // ============================================ + // EN: Cache Management / VI: Quản lý Cache + // ============================================ + + /// + public void InvalidateUserCache(string userId) + { + var keysToRemove = new[] + { + $"user:{userId}", + $"roles:{userId}", + $"permissions:{userId}" + }; + + foreach (var key in keysToRemove) + { + _userCache.TryRemove(key, out _); + _rolesCache.TryRemove(key, out _); + _permissionsCache.TryRemove(key, out _); + } + + _logger.LogDebug("Cache invalidated for user {UserId}", userId); + } + + /// + public void ClearCache() + { + _userCache.Clear(); + _rolesCache.Clear(); + _permissionsCache.Clear(); + + lock (_healthLock) + { + _healthCache = null; + } + + _logger.LogInformation("All IAM client caches cleared"); + } + + // ============================================ + // EN: Helper Methods / VI: Phương thức hỗ trợ + // ============================================ + + private HttpRequestMessage CreateRequest(HttpMethod method, string path, string accessToken) + { + var request = new HttpRequestMessage(method, path); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + request.Headers.Add("X-Service-Name", _settings.ServiceName); + return request; + } + + private static bool TryGetFromCache( + ConcurrentDictionary> cache, + string key, + out T value) + { + if (cache.TryGetValue(key, out var item) && !item.IsExpired) + { + value = item.Value; + return true; + } + + value = default!; + return false; + } + + private static void AddToCache( + ConcurrentDictionary> cache, + string key, + T value, + int durationSeconds) + { + cache[key] = new CachedItem(value, durationSeconds); + } + + // ============================================ + // EN: Internal DTOs / VI: Internal DTOs + // ============================================ + private sealed record UserMeResponse(UserData? Data); - private sealed record UserData(string Id, string Email, string? DisplayName, List? Roles); + private sealed record UserResponse(UserFullData? Data); + private sealed record RolesResponse(IReadOnlyList? Data); + private sealed record PermissionsResponse(IReadOnlyList? Data); + + private sealed record UserData( + string Id, + string Email, + string? DisplayName, + List? Roles, + List? Permissions); + + private sealed record UserFullData( + string Id, + string Email, + string? DisplayName, + bool IsActive, + List? Roles, + List? Permissions); + + /// + /// EN: Generic cached item with expiration. + /// VI: Item cache generic có thời hạn. + /// + private sealed class CachedItem + { + public T Value { get; } + private readonly DateTime _expiresAt; + + public CachedItem(T value, int durationSeconds) + { + Value = value; + _expiresAt = DateTime.UtcNow.AddSeconds(durationSeconds); + } + + public bool IsExpired => DateTime.UtcNow >= _expiresAt; + } } diff --git a/services/storage-service-net/src/StorageService.Infrastructure/ExternalServices/IamServiceClient.cs b/services/storage-service-net/src/StorageService.Infrastructure/ExternalServices/IamServiceClient.cs index c09d4956..1cf27972 100644 --- a/services/storage-service-net/src/StorageService.Infrastructure/ExternalServices/IamServiceClient.cs +++ b/services/storage-service-net/src/StorageService.Infrastructure/ExternalServices/IamServiceClient.cs @@ -16,6 +16,12 @@ public class IamServiceSettings /// EN: Request timeout in seconds / VI: Timeout request (giây) public int TimeoutSeconds { get; set; } = 30; + + /// EN: Cache user info duration in seconds / VI: Thời gian cache user info (giây) + public int CacheDurationSeconds { get; set; } = 300; // 5 minutes + + /// EN: Health check cache duration in seconds / VI: Thời gian cache health check (giây) + public int HealthCheckCacheDurationSeconds { get; set; } = 60; // 1 minute } /// @@ -27,7 +33,27 @@ public record IamUserInfo( string Email, string? DisplayName, bool IsActive, - IReadOnlyList Roles); + IReadOnlyList Roles, + IReadOnlyList? Permissions = null); + +/// +/// EN: Role information from IAM Service. +/// VI: Thông tin role từ IAM Service. +/// +public record IamRoleInfo( + string Id, + string Name, + string? Description, + IReadOnlyList Permissions); + +/// +/// EN: IAM Service health status. +/// VI: Trạng thái health của IAM Service. +/// +public record IamHealthStatus( + bool IsHealthy, + string Status, + DateTime CheckedAt); /// /// EN: Interface for communicating with IAM Service. @@ -35,15 +61,81 @@ public record IamUserInfo( /// public interface IIamServiceClient { + // ============================================ + // EN: User Operations / VI: Thao tác User + // ============================================ + /// - /// EN: Validate user token and get user info. - /// VI: Xác thực token và lấy thông tin user. + /// EN: Validate user token and get user info (with caching). + /// VI: Xác thực token và lấy thông tin user (có cache). /// Task ValidateUserAsync(string accessToken, CancellationToken cancellationToken = default); + /// + /// EN: Get user by ID. + /// VI: Lấy user theo ID. + /// + Task GetUserByIdAsync(string userId, string accessToken, CancellationToken cancellationToken = default); + /// /// EN: Check if user exists. /// VI: Kiểm tra user có tồn tại. /// Task UserExistsAsync(string userId, string accessToken, CancellationToken cancellationToken = default); + + /// + /// EN: Get user's roles. + /// VI: Lấy danh sách roles của user. + /// + Task> GetUserRolesAsync(string userId, string accessToken, CancellationToken cancellationToken = default); + + /// + /// EN: Get user's permissions. + /// VI: Lấy danh sách permissions của user. + /// + Task> GetUserPermissionsAsync(string userId, string accessToken, CancellationToken cancellationToken = default); + + /// + /// EN: Check if user has specific permission. + /// VI: Kiểm tra user có permission cụ thể. + /// + Task HasPermissionAsync(string userId, string permission, string accessToken, CancellationToken cancellationToken = default); + + /// + /// EN: Check if user has specific role. + /// VI: Kiểm tra user có role cụ thể. + /// + Task HasRoleAsync(string userId, string role, string accessToken, CancellationToken cancellationToken = default); + + // ============================================ + // EN: Health Check / VI: Kiểm tra Health + // ============================================ + + /// + /// EN: Check IAM Service health (with caching). + /// VI: Kiểm tra health của IAM Service (có cache). + /// + Task CheckHealthAsync(CancellationToken cancellationToken = default); + + /// + /// EN: Check if IAM Service is available. + /// VI: Kiểm tra IAM Service có sẵn sàng. + /// + Task IsAvailableAsync(CancellationToken cancellationToken = default); + + // ============================================ + // EN: Cache Management / VI: Quản lý Cache + // ============================================ + + /// + /// EN: Invalidate user cache. + /// VI: Xóa cache của user. + /// + void InvalidateUserCache(string userId); + + /// + /// EN: Clear all caches. + /// VI: Xóa toàn bộ cache. + /// + void ClearCache(); } diff --git a/services/wallet-service-net/.env.example b/services/wallet-service-net/.env.example new file mode 100644 index 00000000..77d5daeb --- /dev/null +++ b/services/wallet-service-net/.env.example @@ -0,0 +1,53 @@ +# Environment / Môi Trường +ASPNETCORE_ENVIRONMENT=Development + +# Database / Cơ Sở Dữ Liệu +# PostgreSQL connection string (Neon or local) +DATABASE_URL=Host=localhost;Port=5432;Database=wallet_db;Username=postgres;Password=postgres + +# Redis Cache +REDIS_URL=localhost:6379 +REDIS_PASSWORD= + +# JWT Authentication / Xác Thực JWT +JWT_SECRET=your-secret-key-min-32-characters-long-here +JWT_ISSUER=goodgo-platform +JWT_AUDIENCE=goodgo-services +JWT_ACCESS_TOKEN_EXPIRY_MINUTES=15 +JWT_REFRESH_TOKEN_EXPIRY_DAYS=7 + +# API Configuration / Cấu Hình API +API_PORT=5003 +API_BASE_PATH=/api/v1 + +# IAM Service / Dịch Vụ IAM +IAM_SERVICE_URL=http://localhost:5001 +IAM_SERVICE_NAME=wallet-service + +# Observability / Quan Sát +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 +OTEL_SERVICE_NAME=wallet-service + +# Logging +LOG_LEVEL=Information +SEQ_URL=http://localhost:5341 + +# Feature Flags +FEATURE_SWAGGER_ENABLED=true +FEATURE_DETAILED_ERRORS=true + +# Rate Limiting +RATE_LIMIT_PERMITS_PER_MINUTE=100 +RATE_LIMIT_QUEUE_LIMIT=10 + +# Health Checks +HEALTHCHECK_TIMEOUT_SECONDS=5 + +# Wallet Configuration / Cấu Hình Ví +WALLET_DEFAULT_CURRENCY=VND +WALLET_MAX_BALANCE=1000000000 +WALLET_MIN_TRANSACTION=1000 + +# Points Configuration / Cấu Hình Điểm +POINTS_EXPIRY_MONTHS=12 +POINTS_CONVERSION_RATE=100 diff --git a/services/wallet-service-net/.gitignore b/services/wallet-service-net/.gitignore new file mode 100644 index 00000000..84b02a53 --- /dev/null +++ b/services/wallet-service-net/.gitignore @@ -0,0 +1,75 @@ +# Build results +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio +.vs/ +*.user +*.userosscache +*.suo +*.userprefs +*.sln.docstates + +# Rider +.idea/ +*.sln.iml + +# Visual Studio Code +.vscode/ + +# NuGet +*.nupkg +*.snupkg +.nuget/ +packages/ +project.lock.json +project.fragment.lock.json + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# Coverage +TestResults/ +*.coverage +*.coveragexml +coverage*.json +coverage*.xml + +# Publish output +publish/ +out/ + +# Environment files +.env +.env.local +.env.*.local +*.env + +# Secrets +appsettings.*.json +!appsettings.json +!appsettings.Development.json + +# macOS +.DS_Store + +# Windows +Thumbs.db +ehthumbs.db + +# JetBrains +*.resharper + +# dotnet tools +.config/dotnet-tools.json + +# Migration scripts (only keep structure) +Migrations/ + +# Temp files +*.tmp +*.temp +~$* diff --git a/services/wallet-service-net/Directory.Build.props b/services/wallet-service-net/Directory.Build.props new file mode 100644 index 00000000..c3b74373 --- /dev/null +++ b/services/wallet-service-net/Directory.Build.props @@ -0,0 +1,22 @@ + + + net10.0 + 14.0 + enable + enable + true + true + $(NoWarn);1591;CA2017 + + + + GoodGo Team + GoodGo + © 2026 GoodGo. All rights reserved. + git + + + + + + diff --git a/services/wallet-service-net/Dockerfile b/services/wallet-service-net/Dockerfile new file mode 100644 index 00000000..8d0115ca --- /dev/null +++ b/services/wallet-service-net/Dockerfile @@ -0,0 +1,66 @@ +# Build stage / Giai đoạn build +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +# EN: Copy project files for layer caching +# VI: Sao chép các file project để tận dụng layer caching +COPY ["src/WalletService.API/WalletService.API.csproj", "src/WalletService.API/"] +COPY ["src/WalletService.Domain/WalletService.Domain.csproj", "src/WalletService.Domain/"] +COPY ["src/WalletService.Infrastructure/WalletService.Infrastructure.csproj", "src/WalletService.Infrastructure/"] +COPY ["Directory.Build.props", "./"] + +# EN: Restore dependencies +# VI: Khôi phục dependencies +RUN dotnet restore "src/WalletService.API/WalletService.API.csproj" + +# EN: Copy all source code +# VI: Sao chép toàn bộ source code +COPY src/ ./src/ + +# EN: Build the application +# VI: Build ứng dụng +WORKDIR "/src/src/WalletService.API" +RUN dotnet build "WalletService.API.csproj" -c Release -o /app/build --no-restore + +# Publish stage / Giai đoạn publish +FROM build AS publish +RUN dotnet publish "WalletService.API.csproj" -c Release -o /app/publish /p:UseAppHost=false --no-restore + +# Runtime stage / Giai đoạn runtime +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final +WORKDIR /app + +# EN: Create non-root user for security +# VI: Tạo user non-root cho bảo mật +RUN groupadd -g 1001 dotnetuser && \ + useradd -u 1001 -g dotnetuser -s /bin/sh dotnetuser + +# EN: Copy published application +# VI: Sao chép ứng dụng đã publish +COPY --from=publish /app/publish . + +# EN: Change ownership to non-root user +# VI: Thay đổi quyền sở hữu sang user non-root +RUN chown -R dotnetuser:dotnetuser /app + +# EN: Switch to non-root user +# VI: Chuyển sang user non-root +USER dotnetuser + +# EN: Expose port +# VI: Mở cổng +EXPOSE 8080 + +# EN: Set environment variables +# VI: Thiết lập biến môi trường +ENV ASPNETCORE_URLS=http://+:8080 +ENV ASPNETCORE_ENVIRONMENT=Production + +# EN: Health check +# VI: Kiểm tra health +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD curl -f http://localhost:8080/health/live || exit 1 + +# EN: Start the application +# VI: Khởi động ứng dụng +ENTRYPOINT ["dotnet", "WalletService.API.dll"] diff --git a/services/wallet-service-net/WalletService.slnx b/services/wallet-service-net/WalletService.slnx new file mode 100644 index 00000000..fb57f42a --- /dev/null +++ b/services/wallet-service-net/WalletService.slnx @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/services/wallet-service-net/docker-compose.yml b/services/wallet-service-net/docker-compose.yml new file mode 100644 index 00000000..254ceb12 --- /dev/null +++ b/services/wallet-service-net/docker-compose.yml @@ -0,0 +1,72 @@ +version: '3.8' + +# EN: Docker Compose for local development +# VI: Docker Compose cho phát triển local + +services: + myservice-api: + build: + context: . + dockerfile: Dockerfile + container_name: myservice-api + ports: + - "5000:8080" + environment: + - ASPNETCORE_ENVIRONMENT=Development + - DATABASE_URL=Host=postgres;Port=5432;Database=myservice_db;Username=postgres;Password=postgres + - REDIS_URL=redis:6379 + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + networks: + - myservice-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health/live"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + + postgres: + image: postgres:16-alpine + container_name: myservice-postgres + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: myservice_db + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - myservice-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + container_name: myservice-redis + ports: + - "6379:6379" + volumes: + - redis_data:/data + networks: + - myservice-network + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + postgres_data: + redis_data: + +networks: + myservice-network: + driver: bridge diff --git a/services/wallet-service-net/docs/en/ARCHITECTURE.md b/services/wallet-service-net/docs/en/ARCHITECTURE.md new file mode 100644 index 00000000..2bc38df4 --- /dev/null +++ b/services/wallet-service-net/docs/en/ARCHITECTURE.md @@ -0,0 +1,271 @@ +# Architecture Documentation + +> Detailed architecture documentation for the .NET 10 Microservice Template. + +## Architecture Overview + +```mermaid +graph TB + subgraph "API Layer" + C[Controllers] + CMD[Commands] + Q[Queries] + B[Behaviors] + V[Validations] + end + + subgraph "Domain Layer" + AR[Aggregate Roots] + E[Entities] + VO[Value Objects] + DE[Domain Events] + DX[Domain Exceptions] + end + + subgraph "Infrastructure Layer" + DB[(PostgreSQL)] + R[Repositories] + CTX[DbContext] + ID[Idempotency] + end + + C --> CMD + C --> Q + CMD --> B --> V + CMD --> AR + Q --> R + R --> CTX --> DB + AR --> DE + R --> AR + + style C fill:#4a90d9,stroke:#2d5986,color:#fff + style AR fill:#50c878,stroke:#2d8659,color:#fff + style DB fill:#ff6b6b,stroke:#c0392b,color:#fff +``` + +## Layer Responsibilities + +### 1. Domain Layer (WalletService.Domain) + +The heart of the application containing pure business logic. This layer: +- Has **ZERO** external dependencies (except MediatR.Contracts for events) +- Contains only POCO classes +- Implements DDD tactical patterns + +#### Components + +| Component | Purpose | +|-----------|---------| +| **SeedWork** | Base classes: Entity, ValueObject, Enumeration, IAggregateRoot | +| **AggregatesModel** | Aggregate roots with their entities and value objects | +| **Events** | Domain events for cross-aggregate communication | +| **Exceptions** | Domain-specific exceptions for business rule violations | + +### 2. Infrastructure Layer (WalletService.Infrastructure) + +Technical implementations and external concerns: +- Database access (EF Core) +- Repository implementations +- External service integrations + +### 3. API Layer (WalletService.API) + +Application entry point and CQRS implementation: +- Controllers for HTTP handling +- Commands for write operations +- Queries for read operations +- MediatR behaviors for cross-cutting concerns + +## CQRS Flow + +```mermaid +sequenceDiagram + participant Client + participant Controller + participant MediatR + participant LoggingBehavior + participant ValidatorBehavior + participant TransactionBehavior + participant CommandHandler + participant Repository + participant DbContext + + Client->>Controller: HTTP Request + Controller->>MediatR: Send(Command) + MediatR->>LoggingBehavior: Handle + LoggingBehavior->>ValidatorBehavior: Next() + ValidatorBehavior->>TransactionBehavior: Next() + TransactionBehavior->>CommandHandler: Next() + CommandHandler->>Repository: Add/Update/Delete + Repository->>DbContext: SaveEntitiesAsync() + DbContext-->>Repository: Success + Repository-->>CommandHandler: Result + CommandHandler-->>Controller: Response + Controller-->>Client: HTTP Response +``` + +## Domain Events + +```mermaid +graph LR + AR[Aggregate Root] -->|Raises| DE[Domain Event] + DE -->|Dispatched by| CTX[DbContext] + CTX -->|Publishes to| M[MediatR] + M -->|Handled by| H1[Handler 1] + M -->|Handled by| H2[Handler 2] + + style AR fill:#50c878,stroke:#2d8659,color:#fff + style DE fill:#f39c12,stroke:#d68910,color:#fff + style M fill:#9b59b6,stroke:#7d3c98,color:#fff +``` + +## Database Schema + +### Sample Aggregate + +```mermaid +erDiagram + samples { + uuid id PK + varchar(200) name + varchar(1000) description + int status_id FK + timestamp created_at + timestamp updated_at + } + + sample_statuses { + int id PK + varchar(50) name + } + + samples ||--o{ sample_statuses : has +``` + +## MediatR Pipeline + +``` +Request → LoggingBehavior → ValidatorBehavior → TransactionBehavior → Handler → Response + │ │ │ + ▼ ▼ ▼ + Log start/end Validate Begin/Commit + + timing with Transaction + FluentValidation +``` + +### Behavior Order + +1. **LoggingBehavior** - Logs request handling with timing +2. **ValidatorBehavior** - Validates request using FluentValidation +3. **TransactionBehavior** - Wraps command handlers in database transactions + +## Error Handling + +### Exception Hierarchy + +``` +Exception +└── DomainException + └── SampleDomainException +``` + +### Problem Details (RFC 7807) + +All errors are returned in Problem Details format: + +```json +{ + "type": "https://tools.ietf.org/html/rfc7807", + "title": "Validation Error", + "status": 400, + "detail": "One or more validation errors occurred.", + "errors": { + "Name": ["Name is required"] + } +} +``` + +## Health Checks + +```mermaid +graph TD + HC[Health Check Endpoint] + HC --> |/health/live| L[Liveness] + HC --> |/health/ready| R[Readiness] + HC --> |/health| F[Full Status] + + R --> PG[(PostgreSQL)] + R --> RD[(Redis)] + + style HC fill:#3498db,stroke:#2980b9,color:#fff + style L fill:#2ecc71,stroke:#27ae60,color:#fff + style R fill:#f39c12,stroke:#d68910,color:#fff +``` + +## Deployment Architecture + +### Docker Compose (Local) + +```yaml +services: + myservice-api: + build: . + ports: ["5000:8080"] + depends_on: + - postgres + - redis + + postgres: + image: postgres:16-alpine + + redis: + image: redis:7-alpine +``` + +### Kubernetes (Production) + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: myservice-api +spec: + replicas: 3 + template: + spec: + containers: + - name: api + image: myservice:latest + ports: + - containerPort: 8080 + livenessProbe: + httpGet: + path: /health/live + port: 8080 + readinessProbe: + httpGet: + path: /health/ready + port: 8080 +``` + +## Security Considerations + +1. **Authentication**: JWT Bearer token (configure in production) +2. **Authorization**: Role-based access control +3. **Input Validation**: FluentValidation on all requests +4. **SQL Injection**: EF Core parameterized queries +5. **Secrets**: Environment variables, never in code + +## Performance Optimization + +1. **Connection Pooling**: EF Core with Npgsql connection resilience +2. **Async/Await**: All I/O operations are async +3. **Response Caching**: Add caching headers for queries +4. **Database Indexes**: Configure in EntityConfigurations + +## References + +- [eShopOnContainers](https://github.com/dotnet-architecture/eShopOnContainers) +- [.NET Microservices Architecture Guide](https://docs.microsoft.com/en-us/dotnet/architecture/microservices/) +- [Domain-Driven Design](https://martinfowler.com/bliki/DomainDrivenDesign.html) +- [CQRS Pattern](https://docs.microsoft.com/en-us/azure/architecture/patterns/cqrs) diff --git a/services/wallet-service-net/docs/en/README.md b/services/wallet-service-net/docs/en/README.md new file mode 100644 index 00000000..adc3ea85 --- /dev/null +++ b/services/wallet-service-net/docs/en/README.md @@ -0,0 +1,265 @@ +# .NET 10 Microservice Template + +> Enterprise-grade .NET 10 microservice template following DDD, CQRS, and Clean Architecture patterns. + +## Overview + +This template provides a production-ready structure for .NET microservices based on the eShopOnContainers reference architecture with: + +- **Domain-Driven Design (DDD)** - Aggregates, Entities, Value Objects, Domain Events +- **CQRS Pattern** - Separate Commands (write) and Queries (read) with MediatR +- **Clean Architecture** - Domain, Infrastructure, API layered separation +- **EF Core 10** - PostgreSQL with connection resilience +- **FluentValidation** - Request validation +- **API Versioning** - URL segment versioning +- **Health Checks** - Kubernetes-ready probes +- **Structured Logging** - Serilog with console and Seq + +## Prerequisites + +| Requirement | Version | +|-------------|---------| +| .NET SDK | 10.0.101+ | +| Docker | 24.0+ | +| PostgreSQL | 15+ (or use Docker) | + +```bash +# Check .NET version +dotnet --version +# Should output: 10.0.xxx +``` + +## Quick Start + +### 1. Create New Service + +```bash +# Copy template to new service +cp -r services/_template_dot_net services/your-service-name + +# Navigate to service directory +cd services/your-service-name + +# Rename all occurrences of "WalletService" to "YourService" +find . -type f -name "*.cs" -exec sed -i '' 's/WalletService/YourService/g' {} + +find . -type f -name "*.csproj" -exec sed -i '' 's/WalletService/YourService/g' {} + +``` + +### 2. Configure Environment + +```bash +# Copy environment template +cp .env.example .env + +# Edit with your configuration +nano .env +``` + +### 3. Run with Docker + +```bash +# Start all services (API + PostgreSQL + Redis) +docker-compose up -d + +# View logs +docker-compose logs -f myservice-api +``` + +### 4. Run Locally + +```bash +# Restore dependencies +dotnet restore + +# Build all projects +dotnet build + +# Run the API +dotnet run --project src/WalletService.API +``` + +## Project Structure + +``` +_template_dot_net/ +├── src/ +│ ├── WalletService.API/ # Presentation Layer (Controllers, CQRS) +│ │ ├── Controllers/ # API endpoints +│ │ ├── Application/ # CQRS Implementation +│ │ │ ├── Commands/ # Write operations (MediatR) +│ │ │ ├── Queries/ # Read operations +│ │ │ ├── Behaviors/ # MediatR pipeline behaviors +│ │ │ └── Validations/ # FluentValidation validators +│ │ ├── Middleware/ # Custom middleware +│ │ └── Program.cs # Application entry point +│ │ +│ ├── WalletService.Domain/ # Domain Layer (Pure business logic) +│ │ ├── AggregatesModel/ # Aggregate roots and entities +│ │ ├── Events/ # Domain events +│ │ ├── Exceptions/ # Domain exceptions +│ │ └── SeedWork/ # Base classes (Entity, ValueObject, etc.) +│ │ +│ └── WalletService.Infrastructure/ # Infrastructure Layer (Data access) +│ ├── EntityConfigurations/ # EF Core Fluent API configurations +│ ├── Repositories/ # Repository implementations +│ ├── Idempotency/ # Request idempotency handling +│ └── WalletServiceContext.cs # DbContext with Unit of Work +│ +├── tests/ +│ ├── WalletService.UnitTests/ # Unit tests (Domain, Application) +│ └── WalletService.FunctionalTests/ # Integration tests (API endpoints) +│ +├── Dockerfile # Multi-stage Docker build +├── docker-compose.yml # Local development setup +├── global.json # .NET SDK version pinning +└── Directory.Build.props # Common MSBuild properties +``` + +## API Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/api/v1/samples` | Get all samples | +| `GET` | `/api/v1/samples/{id}` | Get sample by ID | +| `POST` | `/api/v1/samples` | Create new sample | +| `PUT` | `/api/v1/samples/{id}` | Update sample | +| `DELETE` | `/api/v1/samples/{id}` | Delete sample | +| `PATCH` | `/api/v1/samples/{id}/status` | Change status | + +### Health Endpoints + +| Endpoint | Purpose | +|----------|---------| +| `/health` | Full health status | +| `/health/live` | Liveness probe | +| `/health/ready` | Readiness probe | + +## CQRS Pattern + +### Commands (Write Operations) + +```csharp +// Define command +public record CreateSampleCommand(string Name, string? Description) + : IRequest; + +// Handle command +public class CreateSampleCommandHandler : IRequestHandler +{ + public async Task Handle(CreateSampleCommand request, CancellationToken ct) + { + var sample = new Sample(request.Name, request.Description); + _repository.Add(sample); + await _repository.UnitOfWork.SaveEntitiesAsync(ct); + return new CreateSampleCommandResult(sample.Id); + } +} +``` + +### Queries (Read Operations) + +```csharp +// Define query +public record GetSampleQuery(Guid SampleId) : IRequest; +``` + +## Domain Model + +### Aggregate Root + +```csharp +public class Sample : Entity, IAggregateRoot +{ + public string Name => _name; + public SampleStatus Status => _status; + + public Sample(string name, string? description) { + // Business logic validation + if (string.IsNullOrWhiteSpace(name)) + throw new SampleDomainException("Sample name cannot be empty"); + + // Domain event + AddDomainEvent(new SampleCreatedDomainEvent(this)); + } + + public void Activate() { + if (_status != SampleStatus.Draft) + throw new SampleDomainException("Only draft samples can be activated"); + // State transition + } +} +``` + +## Testing + +```bash +# Run all tests +dotnet test + +# Run with coverage +dotnet test /p:CollectCoverage=true /p:CoverageReportFormat=cobertura + +# Run specific test project +dotnet test tests/WalletService.UnitTests +``` + +## Configuration + +### Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `ASPNETCORE_ENVIRONMENT` | Environment name | `Development` | +| `DATABASE_URL` | PostgreSQL connection string | - | +| `REDIS_URL` | Redis connection string | - | +| `JWT_SECRET` | JWT signing secret (min 32 chars) | - | + +### appsettings.json + +```json +{ + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Database=myservice;Username=postgres;Password=postgres" + }, + "Serilog": { + "MinimumLevel": "Information" + } +} +``` + +## Deployment + +### Docker Build + +```bash +# Build Docker image +docker build -t myservice:latest . + +# Run container +docker run -p 5000:8080 --env-file .env myservice:latest +``` + +### Kubernetes + +See [ARCHITECTURE.md](./ARCHITECTURE.md) for Kubernetes deployment manifests. + +## What's New in .NET 10 + +- **C# 14** language features +- Improved **Native AOT** support +- Better **async/await** performance +- Enhanced **JSON serialization** +- Performance improvements across the board +- 3-year **LTS** support (until November 2028) + +## Resources + +- [eShopOnContainers](https://github.com/dotnet-architecture/eShopOnContainers) - Reference architecture +- [.NET 10 Documentation](https://docs.microsoft.com/en-us/dotnet/core/whats-new/dotnet-10) +- [DDD with .NET](https://docs.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/) +- [MediatR](https://github.com/jbogard/MediatR) - CQRS library +- [FluentValidation](https://docs.fluentvalidation.net/) - Validation library + +## License + +Proprietary - GoodGo Platform diff --git a/services/wallet-service-net/docs/vi/ARCHITECTURE.md b/services/wallet-service-net/docs/vi/ARCHITECTURE.md new file mode 100644 index 00000000..1fe99cf8 --- /dev/null +++ b/services/wallet-service-net/docs/vi/ARCHITECTURE.md @@ -0,0 +1,271 @@ +# Tài Liệu Kiến Trúc + +> Tài liệu kiến trúc chi tiết cho Template Microservice .NET 10. + +## Tổng Quan Kiến Trúc + +```mermaid +graph TB + subgraph "Lớp API" + C[Controllers] + CMD[Commands] + Q[Queries] + B[Behaviors] + V[Validations] + end + + subgraph "Lớp Domain" + AR[Aggregate Roots] + E[Entities] + VO[Value Objects] + DE[Domain Events] + DX[Domain Exceptions] + end + + subgraph "Lớp Infrastructure" + DB[(PostgreSQL)] + R[Repositories] + CTX[DbContext] + ID[Idempotency] + end + + C --> CMD + C --> Q + CMD --> B --> V + CMD --> AR + Q --> R + R --> CTX --> DB + AR --> DE + R --> AR + + style C fill:#4a90d9,stroke:#2d5986,color:#fff + style AR fill:#50c878,stroke:#2d8659,color:#fff + style DB fill:#ff6b6b,stroke:#c0392b,color:#fff +``` + +## Trách Nhiệm Các Lớp + +### 1. Lớp Domain (WalletService.Domain) + +Trái tim của ứng dụng chứa business logic thuần túy. Lớp này: +- Có **ZERO** phụ thuộc bên ngoài (ngoại trừ MediatR.Contracts cho events) +- Chỉ chứa các class POCO +- Triển khai các tactical patterns của DDD + +#### Thành Phần + +| Thành phần | Mục Đích | +|------------|----------| +| **SeedWork** | Base classes: Entity, ValueObject, Enumeration, IAggregateRoot | +| **AggregatesModel** | Aggregate roots với entities và value objects | +| **Events** | Domain events cho giao tiếp cross-aggregate | +| **Exceptions** | Domain exceptions cho vi phạm business rules | + +### 2. Lớp Infrastructure (WalletService.Infrastructure) + +Triển khai kỹ thuật và các mối quan tâm bên ngoài: +- Truy cập database (EF Core) +- Triển khai repositories +- Tích hợp external services + +### 3. Lớp API (WalletService.API) + +Điểm vào ứng dụng và triển khai CQRS: +- Controllers để xử lý HTTP +- Commands cho các thao tác ghi +- Queries cho các thao tác đọc +- MediatR behaviors cho cross-cutting concerns + +## Luồng CQRS + +```mermaid +sequenceDiagram + participant Client + participant Controller + participant MediatR + participant LoggingBehavior + participant ValidatorBehavior + participant TransactionBehavior + participant CommandHandler + participant Repository + participant DbContext + + Client->>Controller: HTTP Request + Controller->>MediatR: Send(Command) + MediatR->>LoggingBehavior: Handle + LoggingBehavior->>ValidatorBehavior: Next() + ValidatorBehavior->>TransactionBehavior: Next() + TransactionBehavior->>CommandHandler: Next() + CommandHandler->>Repository: Add/Update/Delete + Repository->>DbContext: SaveEntitiesAsync() + DbContext-->>Repository: Success + Repository-->>CommandHandler: Result + CommandHandler-->>Controller: Response + Controller-->>Client: HTTP Response +``` + +## Domain Events + +```mermaid +graph LR + AR[Aggregate Root] -->|Phát sinh| DE[Domain Event] + DE -->|Dispatch bởi| CTX[DbContext] + CTX -->|Publish tới| M[MediatR] + M -->|Xử lý bởi| H1[Handler 1] + M -->|Xử lý bởi| H2[Handler 2] + + style AR fill:#50c878,stroke:#2d8659,color:#fff + style DE fill:#f39c12,stroke:#d68910,color:#fff + style M fill:#9b59b6,stroke:#7d3c98,color:#fff +``` + +## Schema Database + +### Sample Aggregate + +```mermaid +erDiagram + samples { + uuid id PK + varchar(200) name + varchar(1000) description + int status_id FK + timestamp created_at + timestamp updated_at + } + + sample_statuses { + int id PK + varchar(50) name + } + + samples ||--o{ sample_statuses : has +``` + +## Pipeline MediatR + +``` +Request → LoggingBehavior → ValidatorBehavior → TransactionBehavior → Handler → Response + │ │ │ + ▼ ▼ ▼ + Log start/end Validate Begin/Commit + + timing với Transaction + FluentValidation +``` + +### Thứ Tự Behaviors + +1. **LoggingBehavior** - Ghi log xử lý request với timing +2. **ValidatorBehavior** - Validate request sử dụng FluentValidation +3. **TransactionBehavior** - Bao bọc command handlers trong database transactions + +## Xử Lý Lỗi + +### Phân Cấp Exceptions + +``` +Exception +└── DomainException + └── SampleDomainException +``` + +### Problem Details (RFC 7807) + +Tất cả lỗi được trả về theo định dạng Problem Details: + +```json +{ + "type": "https://tools.ietf.org/html/rfc7807", + "title": "Lỗi Validation", + "status": 400, + "detail": "Một hoặc nhiều lỗi validation đã xảy ra.", + "errors": { + "Name": ["Tên là bắt buộc"] + } +} +``` + +## Health Checks + +```mermaid +graph TD + HC[Health Check Endpoint] + HC --> |/health/live| L[Liveness] + HC --> |/health/ready| R[Readiness] + HC --> |/health| F[Full Status] + + R --> PG[(PostgreSQL)] + R --> RD[(Redis)] + + style HC fill:#3498db,stroke:#2980b9,color:#fff + style L fill:#2ecc71,stroke:#27ae60,color:#fff + style R fill:#f39c12,stroke:#d68910,color:#fff +``` + +## Kiến Trúc Deployment + +### Docker Compose (Local) + +```yaml +services: + myservice-api: + build: . + ports: ["5000:8080"] + depends_on: + - postgres + - redis + + postgres: + image: postgres:16-alpine + + redis: + image: redis:7-alpine +``` + +### Kubernetes (Production) + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: myservice-api +spec: + replicas: 3 + template: + spec: + containers: + - name: api + image: myservice:latest + ports: + - containerPort: 8080 + livenessProbe: + httpGet: + path: /health/live + port: 8080 + readinessProbe: + httpGet: + path: /health/ready + port: 8080 +``` + +## Cân Nhắc Bảo Mật + +1. **Authentication**: JWT Bearer token (cấu hình trong production) +2. **Authorization**: Role-based access control +3. **Input Validation**: FluentValidation trên tất cả requests +4. **SQL Injection**: EF Core parameterized queries +5. **Secrets**: Biến môi trường, không bao giờ trong code + +## Tối Ưu Hiệu Năng + +1. **Connection Pooling**: EF Core với Npgsql connection resilience +2. **Async/Await**: Tất cả I/O operations đều async +3. **Response Caching**: Thêm caching headers cho queries +4. **Database Indexes**: Cấu hình trong EntityConfigurations + +## Tài Liệu Tham Khảo + +- [eShopOnContainers](https://github.com/dotnet-architecture/eShopOnContainers) +- [Hướng dẫn Kiến trúc .NET Microservices](https://docs.microsoft.com/en-us/dotnet/architecture/microservices/) +- [Domain-Driven Design](https://martinfowler.com/bliki/DomainDrivenDesign.html) +- [CQRS Pattern](https://docs.microsoft.com/en-us/azure/architecture/patterns/cqrs) diff --git a/services/wallet-service-net/docs/vi/README.md b/services/wallet-service-net/docs/vi/README.md new file mode 100644 index 00000000..bfb80ac8 --- /dev/null +++ b/services/wallet-service-net/docs/vi/README.md @@ -0,0 +1,265 @@ +# Template Microservice .NET 10 + +> Template microservice .NET 10 cấp doanh nghiệp theo các pattern DDD, CQRS và Clean Architecture. + +## Tổng Quan + +Template này cung cấp cấu trúc sẵn sàng production cho microservices .NET dựa trên kiến trúc tham chiếu eShopOnContainers với: + +- **Domain-Driven Design (DDD)** - Aggregates, Entities, Value Objects, Domain Events +- **CQRS Pattern** - Tách biệt Commands (ghi) và Queries (đọc) với MediatR +- **Clean Architecture** - Phân tầng Domain, Infrastructure, API +- **EF Core 10** - PostgreSQL với connection resilience +- **FluentValidation** - Validation request +- **API Versioning** - Versioning theo URL segment +- **Health Checks** - Probes sẵn sàng cho Kubernetes +- **Structured Logging** - Serilog với console và Seq + +## Yêu Cầu + +| Yêu cầu | Phiên bản | +|---------|-----------| +| .NET SDK | 10.0.101+ | +| Docker | 24.0+ | +| PostgreSQL | 15+ (hoặc dùng Docker) | + +```bash +# Kiểm tra phiên bản .NET +dotnet --version +# Kết quả nên là: 10.0.xxx +``` + +## Bắt Đầu Nhanh + +### 1. Tạo Service Mới + +```bash +# Sao chép template sang service mới +cp -r services/_template_dot_net services/your-service-name + +# Di chuyển đến thư mục service +cd services/your-service-name + +# Đổi tên tất cả "WalletService" thành "YourService" +find . -type f -name "*.cs" -exec sed -i '' 's/WalletService/YourService/g' {} + +find . -type f -name "*.csproj" -exec sed -i '' 's/WalletService/YourService/g' {} + +``` + +### 2. Cấu Hình Môi Trường + +```bash +# Sao chép template môi trường +cp .env.example .env + +# Chỉnh sửa với cấu hình của bạn +nano .env +``` + +### 3. Chạy với Docker + +```bash +# Khởi động tất cả services (API + PostgreSQL + Redis) +docker-compose up -d + +# Xem logs +docker-compose logs -f myservice-api +``` + +### 4. Chạy Local + +```bash +# Khôi phục dependencies +dotnet restore + +# Build tất cả projects +dotnet build + +# Chạy API +dotnet run --project src/WalletService.API +``` + +## Cấu Trúc Dự Án + +``` +_template_dot_net/ +├── src/ +│ ├── WalletService.API/ # Lớp Presentation (Controllers, CQRS) +│ │ ├── Controllers/ # Các API endpoints +│ │ ├── Application/ # Triển khai CQRS +│ │ │ ├── Commands/ # Thao tác ghi (MediatR) +│ │ │ ├── Queries/ # Thao tác đọc +│ │ │ ├── Behaviors/ # MediatR pipeline behaviors +│ │ │ └── Validations/ # FluentValidation validators +│ │ ├── Middleware/ # Custom middleware +│ │ └── Program.cs # Điểm vào ứng dụng +│ │ +│ ├── WalletService.Domain/ # Lớp Domain (Business logic thuần túy) +│ │ ├── AggregatesModel/ # Aggregate roots và entities +│ │ ├── Events/ # Domain events +│ │ ├── Exceptions/ # Domain exceptions +│ │ └── SeedWork/ # Base classes (Entity, ValueObject, etc.) +│ │ +│ └── WalletService.Infrastructure/ # Lớp Infrastructure (Truy cập dữ liệu) +│ ├── EntityConfigurations/ # Cấu hình EF Core Fluent API +│ ├── Repositories/ # Triển khai repositories +│ ├── Idempotency/ # Xử lý idempotency request +│ └── WalletServiceContext.cs # DbContext với Unit of Work +│ +├── tests/ +│ ├── WalletService.UnitTests/ # Unit tests (Domain, Application) +│ └── WalletService.FunctionalTests/ # Integration tests (API endpoints) +│ +├── Dockerfile # Multi-stage Docker build +├── docker-compose.yml # Thiết lập phát triển local +├── global.json # Pin phiên bản .NET SDK +└── Directory.Build.props # Thuộc tính MSBuild chung +``` + +## Các Endpoint API + +| Method | Endpoint | Mô Tả | +|--------|----------|-------| +| `GET` | `/api/v1/samples` | Lấy tất cả samples | +| `GET` | `/api/v1/samples/{id}` | Lấy sample theo ID | +| `POST` | `/api/v1/samples` | Tạo sample mới | +| `PUT` | `/api/v1/samples/{id}` | Cập nhật sample | +| `DELETE` | `/api/v1/samples/{id}` | Xóa sample | +| `PATCH` | `/api/v1/samples/{id}/status` | Thay đổi trạng thái | + +### Health Endpoints + +| Endpoint | Mục Đích | +|----------|----------| +| `/health` | Trạng thái health đầy đủ | +| `/health/live` | Kiểm tra sống | +| `/health/ready` | Kiểm tra sẵn sàng | + +## Pattern CQRS + +### Commands (Thao Tác Ghi) + +```csharp +// Định nghĩa command +public record CreateSampleCommand(string Name, string? Description) + : IRequest; + +// Xử lý command +public class CreateSampleCommandHandler : IRequestHandler +{ + public async Task Handle(CreateSampleCommand request, CancellationToken ct) + { + var sample = new Sample(request.Name, request.Description); + _repository.Add(sample); + await _repository.UnitOfWork.SaveEntitiesAsync(ct); + return new CreateSampleCommandResult(sample.Id); + } +} +``` + +### Queries (Thao Tác Đọc) + +```csharp +// Định nghĩa query +public record GetSampleQuery(Guid SampleId) : IRequest; +``` + +## Domain Model + +### Aggregate Root + +```csharp +public class Sample : Entity, IAggregateRoot +{ + public string Name => _name; + public SampleStatus Status => _status; + + public Sample(string name, string? description) { + // Validation business logic + if (string.IsNullOrWhiteSpace(name)) + throw new SampleDomainException("Tên sample không được để trống"); + + // Domain event + AddDomainEvent(new SampleCreatedDomainEvent(this)); + } + + public void Activate() { + if (_status != SampleStatus.Draft) + throw new SampleDomainException("Chỉ sample draft mới có thể kích hoạt"); + // Chuyển đổi trạng thái + } +} +``` + +## Kiểm Thử + +```bash +# Chạy tất cả tests +dotnet test + +# Chạy với coverage +dotnet test /p:CollectCoverage=true /p:CoverageReportFormat=cobertura + +# Chạy project test cụ thể +dotnet test tests/WalletService.UnitTests +``` + +## Cấu Hình + +### Biến Môi Trường + +| Biến | Mô Tả | Mặc định | +|------|-------|----------| +| `ASPNETCORE_ENVIRONMENT` | Tên môi trường | `Development` | +| `DATABASE_URL` | Connection string PostgreSQL | - | +| `REDIS_URL` | Connection string Redis | - | +| `JWT_SECRET` | Secret ký JWT (tối thiểu 32 ký tự) | - | + +### appsettings.json + +```json +{ + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Database=myservice;Username=postgres;Password=postgres" + }, + "Serilog": { + "MinimumLevel": "Information" + } +} +``` + +## Triển Khai + +### Docker Build + +```bash +# Build Docker image +docker build -t myservice:latest . + +# Chạy container +docker run -p 5000:8080 --env-file .env myservice:latest +``` + +### Kubernetes + +Xem [ARCHITECTURE.md](./ARCHITECTURE.md) để biết manifests triển khai Kubernetes. + +## Có Gì Mới Trong .NET 10 + +- Tính năng ngôn ngữ **C# 14** +- Hỗ trợ **Native AOT** được cải thiện +- Hiệu suất **async/await** tốt hơn +- **JSON serialization** được nâng cao +- Cải thiện hiệu suất toàn diện +- Hỗ trợ **LTS** 3 năm (đến tháng 11/2028) + +## Tài Nguyên + +- [eShopOnContainers](https://github.com/dotnet-architecture/eShopOnContainers) - Kiến trúc tham chiếu +- [Tài liệu .NET 10](https://docs.microsoft.com/en-us/dotnet/core/whats-new/dotnet-10) +- [DDD với .NET](https://docs.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/) +- [MediatR](https://github.com/jbogard/MediatR) - Thư viện CQRS +- [FluentValidation](https://docs.fluentvalidation.net/) - Thư viện validation + +## Giấy Phép + +Độc quyền - GoodGo Platform diff --git a/services/wallet-service-net/global.json b/services/wallet-service-net/global.json new file mode 100644 index 00000000..f78eeaf4 --- /dev/null +++ b/services/wallet-service-net/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "10.0.101", + "rollForward": "latestMinor", + "allowPrerelease": false + } +} \ No newline at end of file diff --git a/services/wallet-service-net/src/WalletService.API/Application/Behaviors/LoggingBehavior.cs b/services/wallet-service-net/src/WalletService.API/Application/Behaviors/LoggingBehavior.cs new file mode 100644 index 00000000..ae73823c --- /dev/null +++ b/services/wallet-service-net/src/WalletService.API/Application/Behaviors/LoggingBehavior.cs @@ -0,0 +1,58 @@ +using System.Diagnostics; +using MediatR; + +namespace WalletService.API.Application.Behaviors; + +/// +/// EN: MediatR behavior for logging request handling. +/// VI: MediatR behavior để logging việc xử lý request. +/// +/// EN: Request type / VI: Loại request +/// EN: Response type / VI: Loại response +public class LoggingBehavior : IPipelineBehavior + where TRequest : IRequest +{ + private readonly ILogger> _logger; + + public LoggingBehavior(ILogger> logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + var requestName = typeof(TRequest).Name; + + _logger.LogInformation( + "Handling {RequestName} / Đang xử lý {RequestName}", + requestName); + + var stopwatch = Stopwatch.StartNew(); + + try + { + var response = await next(); + + stopwatch.Stop(); + + _logger.LogInformation( + "Handled {RequestName} in {ElapsedMs}ms / Đã xử lý {RequestName} trong {ElapsedMs}ms", + requestName, stopwatch.ElapsedMilliseconds); + + return response; + } + catch (Exception ex) + { + stopwatch.Stop(); + + _logger.LogError(ex, + "Error handling {RequestName} after {ElapsedMs}ms / Lỗi xử lý {RequestName} sau {ElapsedMs}ms", + requestName, stopwatch.ElapsedMilliseconds); + + throw; + } + } +} diff --git a/services/wallet-service-net/src/WalletService.API/Application/Behaviors/TransactionBehavior.cs b/services/wallet-service-net/src/WalletService.API/Application/Behaviors/TransactionBehavior.cs new file mode 100644 index 00000000..bf6303dc --- /dev/null +++ b/services/wallet-service-net/src/WalletService.API/Application/Behaviors/TransactionBehavior.cs @@ -0,0 +1,84 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using WalletService.Infrastructure; + +namespace WalletService.API.Application.Behaviors; + +/// +/// EN: MediatR behavior for handling database transactions. +/// VI: MediatR behavior để xử lý database transactions. +/// +/// EN: Request type / VI: Loại request +/// EN: Response type / VI: Loại response +public class TransactionBehavior : IPipelineBehavior + where TRequest : IRequest +{ + private readonly WalletServiceContext _dbContext; + private readonly ILogger> _logger; + + public TransactionBehavior( + WalletServiceContext dbContext, + ILogger> logger) + { + _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + var requestName = typeof(TRequest).Name; + + // EN: Skip transaction for queries (read operations) + // VI: Bỏ qua transaction cho queries (các thao tác đọc) + if (requestName.EndsWith("Query")) + { + return await next(); + } + + // EN: Skip if already in a transaction + // VI: Bỏ qua nếu đã trong transaction + if (_dbContext.HasActiveTransaction) + { + return await next(); + } + + var strategy = _dbContext.Database.CreateExecutionStrategy(); + + return await strategy.ExecuteAsync(async () => + { + await using var transaction = await _dbContext.BeginTransactionAsync(); + + _logger.LogInformation( + "Begin transaction {TransactionId} for {RequestName} / Bắt đầu transaction {TransactionId} cho {RequestName}", + transaction?.TransactionId, requestName); + + try + { + var response = await next(); + + if (transaction != null) + { + await _dbContext.CommitTransactionAsync(transaction); + + _logger.LogInformation( + "Committed transaction {TransactionId} for {RequestName} / Đã commit transaction {TransactionId} cho {RequestName}", + transaction.TransactionId, requestName); + } + + return response; + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error during transaction {TransactionId} for {RequestName} / Lỗi trong transaction {TransactionId} cho {RequestName}", + transaction?.TransactionId, requestName); + + _dbContext.RollbackTransaction(); + throw; + } + }); + } +} diff --git a/services/wallet-service-net/src/WalletService.API/Application/Behaviors/ValidatorBehavior.cs b/services/wallet-service-net/src/WalletService.API/Application/Behaviors/ValidatorBehavior.cs new file mode 100644 index 00000000..d7064ecd --- /dev/null +++ b/services/wallet-service-net/src/WalletService.API/Application/Behaviors/ValidatorBehavior.cs @@ -0,0 +1,63 @@ +using FluentValidation; +using MediatR; + +namespace WalletService.API.Application.Behaviors; + +/// +/// EN: MediatR behavior for FluentValidation integration. +/// VI: MediatR behavior để tích hợp FluentValidation. +/// +/// EN: Request type / VI: Loại request +/// EN: Response type / VI: Loại response +public class ValidatorBehavior : IPipelineBehavior + where TRequest : IRequest +{ + private readonly IEnumerable> _validators; + private readonly ILogger> _logger; + + public ValidatorBehavior( + IEnumerable> validators, + ILogger> logger) + { + _validators = validators ?? throw new ArgumentNullException(nameof(validators)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + var requestName = typeof(TRequest).Name; + + if (!_validators.Any()) + { + return await next(); + } + + _logger.LogDebug( + "Validating {RequestName} / Đang validate {RequestName}", + requestName); + + var context = new ValidationContext(request); + + var validationResults = await Task.WhenAll( + _validators.Select(v => v.ValidateAsync(context, cancellationToken))); + + var failures = validationResults + .SelectMany(r => r.Errors) + .Where(f => f != null) + .ToList(); + + if (failures.Count != 0) + { + _logger.LogWarning( + "Validation failed for {RequestName} with {ErrorCount} errors / Validation thất bại cho {RequestName} với {ErrorCount} lỗi", + requestName, failures.Count); + + throw new ValidationException(failures); + } + + return await next(); + } +} diff --git a/services/wallet-service-net/src/WalletService.API/Application/Commands/CreatePointAccountCommand.cs b/services/wallet-service-net/src/WalletService.API/Application/Commands/CreatePointAccountCommand.cs new file mode 100644 index 00000000..5e464dd7 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.API/Application/Commands/CreatePointAccountCommand.cs @@ -0,0 +1,19 @@ +namespace WalletService.API.Application.Commands; + +using MediatR; + +/// +/// EN: Command to create a new point account for a user +/// VI: Command để tạo tài khoản điểm mới cho người dùng +/// +public record CreatePointAccountCommand( + Guid UserId +) : IRequest; + +public record CreatePointAccountResult( + Guid AccountId, + Guid UserId, + long TotalPoints, + long AvailablePoints, + DateTime CreatedAt +); diff --git a/services/wallet-service-net/src/WalletService.API/Application/Commands/CreatePointAccountCommandHandler.cs b/services/wallet-service-net/src/WalletService.API/Application/Commands/CreatePointAccountCommandHandler.cs new file mode 100644 index 00000000..3d838257 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.API/Application/Commands/CreatePointAccountCommandHandler.cs @@ -0,0 +1,55 @@ +namespace WalletService.API.Application.Commands; + +using MediatR; +using WalletService.Domain.AggregatesModel.PointAccountAggregate; +using WalletService.Domain.Exceptions; + +/// +/// EN: Handler for CreatePointAccountCommand +/// VI: Handler cho CreatePointAccountCommand +/// +public class CreatePointAccountCommandHandler : IRequestHandler +{ + private readonly IPointAccountRepository _pointAccountRepository; + private readonly ILogger _logger; + + public CreatePointAccountCommandHandler( + IPointAccountRepository pointAccountRepository, + ILogger logger) + { + _pointAccountRepository = pointAccountRepository; + _logger = logger; + } + + public async Task Handle( + CreatePointAccountCommand request, + CancellationToken cancellationToken) + { + // EN: Check if user already has a point account + // VI: Kiểm tra xem user đã có tài khoản điểm chưa + var exists = await _pointAccountRepository.ExistsByUserIdAsync(request.UserId); + if (exists) + { + throw new PointsDomainException($"User {request.UserId} already has a point account"); + } + + // EN: Create new point account + // VI: Tạo tài khoản điểm mới + var account = new PointAccount(request.UserId); + + _pointAccountRepository.Add(account); + await _pointAccountRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "Created point account {AccountId} for user {UserId}", + account.Id, request.UserId); + + return new CreatePointAccountResult( + account.Id, + account.UserId, + account.TotalPoints, + account.AvailablePoints, + account.CreatedAt + ); + } +} diff --git a/services/wallet-service-net/src/WalletService.API/Application/Commands/CreateWalletCommand.cs b/services/wallet-service-net/src/WalletService.API/Application/Commands/CreateWalletCommand.cs new file mode 100644 index 00000000..69721c91 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.API/Application/Commands/CreateWalletCommand.cs @@ -0,0 +1,21 @@ +namespace WalletService.API.Application.Commands; + +using MediatR; + +/// +/// EN: Command to create a new wallet for a user +/// VI: Command để tạo ví mới cho người dùng +/// +public record CreateWalletCommand( + Guid UserId, + string Currency = "VND" +) : IRequest; + +public record CreateWalletResult( + Guid WalletId, + Guid UserId, + decimal Balance, + string Currency, + string Status, + DateTime CreatedAt +); diff --git a/services/wallet-service-net/src/WalletService.API/Application/Commands/CreateWalletCommandHandler.cs b/services/wallet-service-net/src/WalletService.API/Application/Commands/CreateWalletCommandHandler.cs new file mode 100644 index 00000000..da458ab6 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.API/Application/Commands/CreateWalletCommandHandler.cs @@ -0,0 +1,56 @@ +namespace WalletService.API.Application.Commands; + +using MediatR; +using WalletService.Domain.AggregatesModel.WalletAggregate; +using WalletService.Domain.Exceptions; + +/// +/// EN: Handler for CreateWalletCommand +/// VI: Handler cho CreateWalletCommand +/// +public class CreateWalletCommandHandler : IRequestHandler +{ + private readonly IWalletRepository _walletRepository; + private readonly ILogger _logger; + + public CreateWalletCommandHandler( + IWalletRepository walletRepository, + ILogger logger) + { + _walletRepository = walletRepository; + _logger = logger; + } + + public async Task Handle( + CreateWalletCommand request, + CancellationToken cancellationToken) + { + // EN: Check if user already has a wallet + // VI: Kiểm tra xem user đã có ví chưa + var existingWallet = await _walletRepository.ExistsByUserIdAsync(request.UserId); + if (existingWallet) + { + throw new WalletDomainException($"User {request.UserId} already has a wallet"); + } + + // EN: Create new wallet + // VI: Tạo ví mới + var wallet = new Wallet(request.UserId, request.Currency); + + _walletRepository.Add(wallet); + await _walletRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "Created wallet {WalletId} for user {UserId}", + wallet.Id, request.UserId); + + return new CreateWalletResult( + wallet.Id, + wallet.UserId, + wallet.Balance.Amount, + wallet.Balance.Currency, + wallet.Status.Name, + wallet.CreatedAt + ); + } +} diff --git a/services/wallet-service-net/src/WalletService.API/Application/Commands/DepositCommand.cs b/services/wallet-service-net/src/WalletService.API/Application/Commands/DepositCommand.cs new file mode 100644 index 00000000..bc3d1103 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.API/Application/Commands/DepositCommand.cs @@ -0,0 +1,24 @@ +namespace WalletService.API.Application.Commands; + +using MediatR; + +/// +/// EN: Command to deposit money into a wallet +/// VI: Command để nạp tiền vào ví +/// +public record DepositCommand( + Guid UserId, + decimal Amount, + string Description, + string? ReferenceId = null +) : IRequest; + +public record TransactionResult( + Guid TransactionId, + Guid WalletId, + decimal Amount, + string Currency, + string Type, + decimal BalanceAfter, + DateTime CreatedAt +); diff --git a/services/wallet-service-net/src/WalletService.API/Application/Commands/DepositCommandHandler.cs b/services/wallet-service-net/src/WalletService.API/Application/Commands/DepositCommandHandler.cs new file mode 100644 index 00000000..00c308ac --- /dev/null +++ b/services/wallet-service-net/src/WalletService.API/Application/Commands/DepositCommandHandler.cs @@ -0,0 +1,62 @@ +namespace WalletService.API.Application.Commands; + +using MediatR; +using WalletService.Domain.AggregatesModel.WalletAggregate; +using WalletService.Domain.Exceptions; + +/// +/// EN: Handler for DepositCommand +/// VI: Handler cho DepositCommand +/// +public class DepositCommandHandler : IRequestHandler +{ + private readonly IWalletRepository _walletRepository; + private readonly ILogger _logger; + + public DepositCommandHandler( + IWalletRepository walletRepository, + ILogger logger) + { + _walletRepository = walletRepository; + _logger = logger; + } + + public async Task Handle( + DepositCommand request, + CancellationToken cancellationToken) + { + // EN: Get wallet by user ID + // VI: Lấy ví theo user ID + var wallet = await _walletRepository.GetByUserIdAsync(request.UserId) + ?? throw new WalletDomainException($"Wallet not found for user {request.UserId}"); + + // EN: Create money value object + // VI: Tạo value object Money + var amount = new Money(request.Amount, wallet.Balance.Currency); + + // EN: Perform deposit + // VI: Thực hiện nạp tiền + wallet.Deposit(amount, request.Description, request.ReferenceId); + + _walletRepository.Update(wallet); + await _walletRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + // EN: Get the last transaction + // VI: Lấy transaction cuối cùng + var transaction = wallet.Transactions.Last(); + + _logger.LogInformation( + "Deposited {Amount} {Currency} to wallet {WalletId}", + request.Amount, wallet.Balance.Currency, wallet.Id); + + return new TransactionResult( + transaction.Id, + wallet.Id, + transaction.Amount.Amount, + transaction.Amount.Currency, + transaction.Type.Name, + transaction.BalanceAfter, + transaction.CreatedAt + ); + } +} diff --git a/services/wallet-service-net/src/WalletService.API/Application/Commands/EarnPointsCommand.cs b/services/wallet-service-net/src/WalletService.API/Application/Commands/EarnPointsCommand.cs new file mode 100644 index 00000000..93c293c7 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.API/Application/Commands/EarnPointsCommand.cs @@ -0,0 +1,25 @@ +namespace WalletService.API.Application.Commands; + +using MediatR; + +/// +/// EN: Command to earn points +/// VI: Command để tích điểm +/// +public record EarnPointsCommand( + Guid UserId, + long Points, + string Source, + string Description, + int? ExpiryMonths = 12 +) : IRequest; + +public record PointTransactionResult( + Guid TransactionId, + Guid AccountId, + long Points, + string Type, + long BalanceAfter, + DateTime CreatedAt, + DateTime? ExpiresAt = null +); diff --git a/services/wallet-service-net/src/WalletService.API/Application/Commands/EarnPointsCommandHandler.cs b/services/wallet-service-net/src/WalletService.API/Application/Commands/EarnPointsCommandHandler.cs new file mode 100644 index 00000000..f7540d3a --- /dev/null +++ b/services/wallet-service-net/src/WalletService.API/Application/Commands/EarnPointsCommandHandler.cs @@ -0,0 +1,60 @@ +namespace WalletService.API.Application.Commands; + +using MediatR; +using WalletService.Domain.AggregatesModel.PointAccountAggregate; +using WalletService.Domain.Exceptions; + +/// +/// EN: Handler for EarnPointsCommand +/// VI: Handler cho EarnPointsCommand +/// +public class EarnPointsCommandHandler : IRequestHandler +{ + private readonly IPointAccountRepository _pointAccountRepository; + private readonly ILogger _logger; + + public EarnPointsCommandHandler( + IPointAccountRepository pointAccountRepository, + ILogger logger) + { + _pointAccountRepository = pointAccountRepository; + _logger = logger; + } + + public async Task Handle( + EarnPointsCommand request, + CancellationToken cancellationToken) + { + var account = await _pointAccountRepository.GetByUserIdAsync(request.UserId) + ?? throw new PointsDomainException($"Point account not found for user {request.UserId}"); + + // EN: Calculate expiry date + // VI: Tính ngày hết hạn + DateTime? expiresAt = request.ExpiryMonths.HasValue + ? DateTime.UtcNow.AddMonths(request.ExpiryMonths.Value) + : null; + + // EN: Earn points + // VI: Tích điểm + account.EarnPoints(request.Points, request.Source, request.Description, expiresAt); + + _pointAccountRepository.Update(account); + await _pointAccountRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + var transaction = account.Transactions.Last(); + + _logger.LogInformation( + "Earned {Points} points for user {UserId}, source: {Source}", + request.Points, request.UserId, request.Source); + + return new PointTransactionResult( + transaction.Id, + account.Id, + transaction.Points, + transaction.Type.Name, + transaction.BalanceAfter, + transaction.CreatedAt, + transaction.ExpiresAt + ); + } +} diff --git a/services/wallet-service-net/src/WalletService.API/Application/Commands/SpendPointsCommand.cs b/services/wallet-service-net/src/WalletService.API/Application/Commands/SpendPointsCommand.cs new file mode 100644 index 00000000..542a637b --- /dev/null +++ b/services/wallet-service-net/src/WalletService.API/Application/Commands/SpendPointsCommand.cs @@ -0,0 +1,14 @@ +namespace WalletService.API.Application.Commands; + +using MediatR; + +/// +/// EN: Command to spend points +/// VI: Command để tiêu điểm +/// +public record SpendPointsCommand( + Guid UserId, + long Points, + string Source, + string Description +) : IRequest; diff --git a/services/wallet-service-net/src/WalletService.API/Application/Commands/SpendPointsCommandHandler.cs b/services/wallet-service-net/src/WalletService.API/Application/Commands/SpendPointsCommandHandler.cs new file mode 100644 index 00000000..a6dbfcd3 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.API/Application/Commands/SpendPointsCommandHandler.cs @@ -0,0 +1,53 @@ +namespace WalletService.API.Application.Commands; + +using MediatR; +using WalletService.Domain.AggregatesModel.PointAccountAggregate; +using WalletService.Domain.Exceptions; + +/// +/// EN: Handler for SpendPointsCommand +/// VI: Handler cho SpendPointsCommand +/// +public class SpendPointsCommandHandler : IRequestHandler +{ + private readonly IPointAccountRepository _pointAccountRepository; + private readonly ILogger _logger; + + public SpendPointsCommandHandler( + IPointAccountRepository pointAccountRepository, + ILogger logger) + { + _pointAccountRepository = pointAccountRepository; + _logger = logger; + } + + public async Task Handle( + SpendPointsCommand request, + CancellationToken cancellationToken) + { + var account = await _pointAccountRepository.GetByUserIdAsync(request.UserId) + ?? throw new PointsDomainException($"Point account not found for user {request.UserId}"); + + // EN: Spend points (may throw InsufficientPointsException) + // VI: Tiêu điểm (có thể throw InsufficientPointsException) + account.SpendPoints(request.Points, request.Source, request.Description); + + _pointAccountRepository.Update(account); + await _pointAccountRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + var transaction = account.Transactions.Last(); + + _logger.LogInformation( + "Spent {Points} points for user {UserId}, source: {Source}", + request.Points, request.UserId, request.Source); + + return new PointTransactionResult( + transaction.Id, + account.Id, + transaction.Points, + transaction.Type.Name, + transaction.BalanceAfter, + transaction.CreatedAt + ); + } +} diff --git a/services/wallet-service-net/src/WalletService.API/Application/Commands/WithdrawCommand.cs b/services/wallet-service-net/src/WalletService.API/Application/Commands/WithdrawCommand.cs new file mode 100644 index 00000000..a36c6c3f --- /dev/null +++ b/services/wallet-service-net/src/WalletService.API/Application/Commands/WithdrawCommand.cs @@ -0,0 +1,14 @@ +namespace WalletService.API.Application.Commands; + +using MediatR; + +/// +/// EN: Command to withdraw money from a wallet +/// VI: Command để rút tiền từ ví +/// +public record WithdrawCommand( + Guid UserId, + decimal Amount, + string Description, + string? ReferenceId = null +) : IRequest; diff --git a/services/wallet-service-net/src/WalletService.API/Application/Commands/WithdrawCommandHandler.cs b/services/wallet-service-net/src/WalletService.API/Application/Commands/WithdrawCommandHandler.cs new file mode 100644 index 00000000..8fd55dc1 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.API/Application/Commands/WithdrawCommandHandler.cs @@ -0,0 +1,56 @@ +namespace WalletService.API.Application.Commands; + +using MediatR; +using WalletService.Domain.AggregatesModel.WalletAggregate; +using WalletService.Domain.Exceptions; + +/// +/// EN: Handler for WithdrawCommand +/// VI: Handler cho WithdrawCommand +/// +public class WithdrawCommandHandler : IRequestHandler +{ + private readonly IWalletRepository _walletRepository; + private readonly ILogger _logger; + + public WithdrawCommandHandler( + IWalletRepository walletRepository, + ILogger logger) + { + _walletRepository = walletRepository; + _logger = logger; + } + + public async Task Handle( + WithdrawCommand request, + CancellationToken cancellationToken) + { + var wallet = await _walletRepository.GetByUserIdAsync(request.UserId) + ?? throw new WalletDomainException($"Wallet not found for user {request.UserId}"); + + var amount = new Money(request.Amount, wallet.Balance.Currency); + + // EN: Perform withdrawal (may throw InsufficientBalanceException) + // VI: Thực hiện rút tiền (có thể throw InsufficientBalanceException) + wallet.Withdraw(amount, request.Description, request.ReferenceId); + + _walletRepository.Update(wallet); + await _walletRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + var transaction = wallet.Transactions.Last(); + + _logger.LogInformation( + "Withdrew {Amount} {Currency} from wallet {WalletId}", + request.Amount, wallet.Balance.Currency, wallet.Id); + + return new TransactionResult( + transaction.Id, + wallet.Id, + transaction.Amount.Amount, + transaction.Amount.Currency, + transaction.Type.Name, + transaction.BalanceAfter, + transaction.CreatedAt + ); + } +} diff --git a/services/wallet-service-net/src/WalletService.API/Application/Queries/GetPointAccountQuery.cs b/services/wallet-service-net/src/WalletService.API/Application/Queries/GetPointAccountQuery.cs new file mode 100644 index 00000000..1fc7e8ff --- /dev/null +++ b/services/wallet-service-net/src/WalletService.API/Application/Queries/GetPointAccountQuery.cs @@ -0,0 +1,18 @@ +namespace WalletService.API.Application.Queries; + +using MediatR; + +/// +/// EN: Query to get point account by user ID +/// VI: Query để lấy tài khoản điểm theo user ID +/// +public record GetPointAccountQuery(Guid UserId) : IRequest; + +public record PointAccountDto( + Guid Id, + Guid UserId, + long TotalPoints, + long AvailablePoints, + DateTime CreatedAt, + DateTime UpdatedAt +); diff --git a/services/wallet-service-net/src/WalletService.API/Application/Queries/GetPointAccountQueryHandler.cs b/services/wallet-service-net/src/WalletService.API/Application/Queries/GetPointAccountQueryHandler.cs new file mode 100644 index 00000000..53bf20f4 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.API/Application/Queries/GetPointAccountQueryHandler.cs @@ -0,0 +1,37 @@ +namespace WalletService.API.Application.Queries; + +using MediatR; +using WalletService.Domain.AggregatesModel.PointAccountAggregate; + +/// +/// EN: Handler for GetPointAccountQuery +/// VI: Handler cho GetPointAccountQuery +/// +public class GetPointAccountQueryHandler : IRequestHandler +{ + private readonly IPointAccountRepository _pointAccountRepository; + + public GetPointAccountQueryHandler(IPointAccountRepository pointAccountRepository) + { + _pointAccountRepository = pointAccountRepository; + } + + public async Task Handle( + GetPointAccountQuery request, + CancellationToken cancellationToken) + { + var account = await _pointAccountRepository.GetByUserIdAsync(request.UserId); + + if (account == null) + return null; + + return new PointAccountDto( + account.Id, + account.UserId, + account.TotalPoints, + account.AvailablePoints, + account.CreatedAt, + account.UpdatedAt + ); + } +} diff --git a/services/wallet-service-net/src/WalletService.API/Application/Queries/GetPointTransactionsQuery.cs b/services/wallet-service-net/src/WalletService.API/Application/Queries/GetPointTransactionsQuery.cs new file mode 100644 index 00000000..da176912 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.API/Application/Queries/GetPointTransactionsQuery.cs @@ -0,0 +1,32 @@ +namespace WalletService.API.Application.Queries; + +using MediatR; + +/// +/// EN: Query to get point transactions with pagination +/// VI: Query để lấy lịch sử giao dịch điểm với phân trang +/// +public record GetPointTransactionsQuery( + Guid UserId, + int Page = 1, + int PageSize = 20 +) : IRequest; + +public record PointTransactionsDto( + IEnumerable Transactions, + int TotalCount, + int Page, + int PageSize, + int TotalPages +); + +public record PointTransactionDto( + Guid Id, + long Points, + string Type, + string Source, + string Description, + long BalanceAfter, + DateTime CreatedAt, + DateTime? ExpiresAt +); diff --git a/services/wallet-service-net/src/WalletService.API/Application/Queries/GetPointTransactionsQueryHandler.cs b/services/wallet-service-net/src/WalletService.API/Application/Queries/GetPointTransactionsQueryHandler.cs new file mode 100644 index 00000000..4d32809d --- /dev/null +++ b/services/wallet-service-net/src/WalletService.API/Application/Queries/GetPointTransactionsQueryHandler.cs @@ -0,0 +1,50 @@ +namespace WalletService.API.Application.Queries; + +using MediatR; +using WalletService.Domain.AggregatesModel.PointAccountAggregate; +using WalletService.Domain.Exceptions; + +/// +/// EN: Handler for GetPointTransactionsQuery +/// VI: Handler cho GetPointTransactionsQuery +/// +public class GetPointTransactionsQueryHandler : IRequestHandler +{ + private readonly IPointAccountRepository _pointAccountRepository; + + public GetPointTransactionsQueryHandler(IPointAccountRepository pointAccountRepository) + { + _pointAccountRepository = pointAccountRepository; + } + + public async Task Handle( + GetPointTransactionsQuery request, + CancellationToken cancellationToken) + { + var account = await _pointAccountRepository.GetByUserIdAsync(request.UserId) + ?? throw new PointsDomainException($"Point account not found for user {request.UserId}"); + + var transactions = await _pointAccountRepository.GetTransactionsAsync( + account.Id, request.Page, request.PageSize); + + var totalCount = await _pointAccountRepository.CountTransactionsAsync(account.Id); + var totalPages = (int)Math.Ceiling(totalCount / (double)request.PageSize); + + return new PointTransactionsDto( + transactions.Select(t => new PointTransactionDto( + t.Id, + t.Points, + t.Type.Name, + t.Source, + t.Description, + t.BalanceAfter, + t.CreatedAt, + t.ExpiresAt + )), + totalCount, + request.Page, + request.PageSize, + totalPages + ); + } +} diff --git a/services/wallet-service-net/src/WalletService.API/Application/Queries/GetWalletQuery.cs b/services/wallet-service-net/src/WalletService.API/Application/Queries/GetWalletQuery.cs new file mode 100644 index 00000000..e4543ebf --- /dev/null +++ b/services/wallet-service-net/src/WalletService.API/Application/Queries/GetWalletQuery.cs @@ -0,0 +1,19 @@ +namespace WalletService.API.Application.Queries; + +using MediatR; + +/// +/// EN: Query to get wallet by user ID +/// VI: Query để lấy ví theo user ID +/// +public record GetWalletQuery(Guid UserId) : IRequest; + +public record WalletDto( + Guid Id, + Guid UserId, + decimal Balance, + string Currency, + string Status, + DateTime CreatedAt, + DateTime UpdatedAt +); diff --git a/services/wallet-service-net/src/WalletService.API/Application/Queries/GetWalletQueryHandler.cs b/services/wallet-service-net/src/WalletService.API/Application/Queries/GetWalletQueryHandler.cs new file mode 100644 index 00000000..8c1ef5e4 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.API/Application/Queries/GetWalletQueryHandler.cs @@ -0,0 +1,36 @@ +namespace WalletService.API.Application.Queries; + +using MediatR; +using WalletService.Domain.AggregatesModel.WalletAggregate; + +/// +/// EN: Handler for GetWalletQuery +/// VI: Handler cho GetWalletQuery +/// +public class GetWalletQueryHandler : IRequestHandler +{ + private readonly IWalletRepository _walletRepository; + + public GetWalletQueryHandler(IWalletRepository walletRepository) + { + _walletRepository = walletRepository; + } + + public async Task Handle(GetWalletQuery request, CancellationToken cancellationToken) + { + var wallet = await _walletRepository.GetByUserIdAsync(request.UserId); + + if (wallet == null) + return null; + + return new WalletDto( + wallet.Id, + wallet.UserId, + wallet.Balance.Amount, + wallet.Balance.Currency, + wallet.Status.Name, + wallet.CreatedAt, + wallet.UpdatedAt + ); + } +} diff --git a/services/wallet-service-net/src/WalletService.API/Application/Queries/GetWalletTransactionsQuery.cs b/services/wallet-service-net/src/WalletService.API/Application/Queries/GetWalletTransactionsQuery.cs new file mode 100644 index 00000000..3d2788a8 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.API/Application/Queries/GetWalletTransactionsQuery.cs @@ -0,0 +1,32 @@ +namespace WalletService.API.Application.Queries; + +using MediatR; + +/// +/// EN: Query to get wallet transactions with pagination +/// VI: Query để lấy lịch sử giao dịch ví với phân trang +/// +public record GetWalletTransactionsQuery( + Guid UserId, + int Page = 1, + int PageSize = 20 +) : IRequest; + +public record WalletTransactionsDto( + IEnumerable Transactions, + int TotalCount, + int Page, + int PageSize, + int TotalPages +); + +public record WalletTransactionDto( + Guid Id, + decimal Amount, + string Currency, + string Type, + string Description, + string? ReferenceId, + decimal BalanceAfter, + DateTime CreatedAt +); diff --git a/services/wallet-service-net/src/WalletService.API/Application/Queries/GetWalletTransactionsQueryHandler.cs b/services/wallet-service-net/src/WalletService.API/Application/Queries/GetWalletTransactionsQueryHandler.cs new file mode 100644 index 00000000..be8777a1 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.API/Application/Queries/GetWalletTransactionsQueryHandler.cs @@ -0,0 +1,50 @@ +namespace WalletService.API.Application.Queries; + +using MediatR; +using WalletService.Domain.AggregatesModel.WalletAggregate; +using WalletService.Domain.Exceptions; + +/// +/// EN: Handler for GetWalletTransactionsQuery +/// VI: Handler cho GetWalletTransactionsQuery +/// +public class GetWalletTransactionsQueryHandler : IRequestHandler +{ + private readonly IWalletRepository _walletRepository; + + public GetWalletTransactionsQueryHandler(IWalletRepository walletRepository) + { + _walletRepository = walletRepository; + } + + public async Task Handle( + GetWalletTransactionsQuery request, + CancellationToken cancellationToken) + { + var wallet = await _walletRepository.GetByUserIdAsync(request.UserId) + ?? throw new WalletDomainException($"Wallet not found for user {request.UserId}"); + + var transactions = await _walletRepository.GetTransactionsAsync( + wallet.Id, request.Page, request.PageSize); + + var totalCount = await _walletRepository.CountTransactionsAsync(wallet.Id); + var totalPages = (int)Math.Ceiling(totalCount / (double)request.PageSize); + + return new WalletTransactionsDto( + transactions.Select(t => new WalletTransactionDto( + t.Id, + t.Amount.Amount, + t.Amount.Currency, + t.Type.Name, + t.Description, + t.ReferenceId, + t.BalanceAfter, + t.CreatedAt + )), + totalCount, + request.Page, + request.PageSize, + totalPages + ); + } +} diff --git a/services/wallet-service-net/src/WalletService.API/Controllers/HealthController.cs b/services/wallet-service-net/src/WalletService.API/Controllers/HealthController.cs new file mode 100644 index 00000000..fcd50fd7 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.API/Controllers/HealthController.cs @@ -0,0 +1,43 @@ +namespace WalletService.API.Controllers; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Swashbuckle.AspNetCore.Annotations; + +/// +/// EN: Controller for health checks +/// VI: Controller cho health checks +/// +[ApiController] +[Route("api/v1/[controller]")] +[AllowAnonymous] +[SwaggerTag("Health check endpoints / Các endpoint kiểm tra sức khỏe")] +public class HealthController : ControllerBase +{ + /// + /// EN: Liveness probe + /// VI: Kiểm tra service còn sống + /// + [HttpGet("live")] + [SwaggerOperation(Summary = "Liveness check", Description = "Check if the service is running")] + [SwaggerResponse(200, "Service is alive")] + public IActionResult Live() + { + return Ok(new { status = "alive", timestamp = DateTime.UtcNow }); + } + + /// + /// EN: Readiness probe + /// VI: Kiểm tra service sẵn sàng + /// + [HttpGet("ready")] + [SwaggerOperation(Summary = "Readiness check", Description = "Check if the service is ready to accept traffic")] + [SwaggerResponse(200, "Service is ready")] + [SwaggerResponse(503, "Service is not ready")] + public IActionResult Ready() + { + // EN: Add additional health checks here (database, redis, etc.) + // VI: Thêm các health checks khác ở đây (database, redis, v.v.) + return Ok(new { status = "ready", timestamp = DateTime.UtcNow }); + } +} diff --git a/services/wallet-service-net/src/WalletService.API/Controllers/PointsController.cs b/services/wallet-service-net/src/WalletService.API/Controllers/PointsController.cs new file mode 100644 index 00000000..80a36b75 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.API/Controllers/PointsController.cs @@ -0,0 +1,132 @@ +namespace WalletService.API.Controllers; + +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Swashbuckle.AspNetCore.Annotations; +using WalletService.API.Application.Commands; +using WalletService.API.Application.Queries; + +/// +/// EN: Controller for points management operations +/// VI: Controller cho các thao tác quản lý điểm +/// +[ApiController] +[Route("api/v1/[controller]")] +[Authorize] +[SwaggerTag("Points management endpoints / Các endpoint quản lý điểm")] +public class PointsController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public PointsController(IMediator mediator, ILogger logger) + { + _mediator = mediator; + _logger = logger; + } + + /// + /// EN: Get point account by user ID + /// VI: Lấy tài khoản điểm theo user ID + /// + [HttpGet("{userId:guid}")] + [SwaggerOperation(Summary = "Get point account", Description = "Get point account by user ID")] + [SwaggerResponse(200, "Success", typeof(ApiResponse))] + [SwaggerResponse(404, "Point account not found")] + public async Task GetPointAccount(Guid userId) + { + var result = await _mediator.Send(new GetPointAccountQuery(userId)); + + if (result == null) + return NotFound(new ApiResponse(false, "Point account not found")); + + return Ok(new ApiResponse(true, "Success", result)); + } + + /// + /// EN: Create a new point account + /// VI: Tạo tài khoản điểm mới + /// + [HttpPost] + [SwaggerOperation(Summary = "Create point account", Description = "Create a new point account for a user")] + [SwaggerResponse(201, "Point account created", typeof(ApiResponse))] + [SwaggerResponse(409, "Point account already exists")] + public async Task CreatePointAccount([FromBody] CreatePointAccountRequest request) + { + var command = new CreatePointAccountCommand(request.UserId); + var result = await _mediator.Send(command); + + return CreatedAtAction( + nameof(GetPointAccount), + new { userId = result.UserId }, + new ApiResponse(true, "Point account created", result)); + } + + /// + /// EN: Earn points + /// VI: Tích điểm + /// + [HttpPost("{userId:guid}/earn")] + [SwaggerOperation(Summary = "Earn points", Description = "Add points to user account")] + [SwaggerResponse(200, "Points earned", typeof(ApiResponse))] + [SwaggerResponse(400, "Invalid request")] + [SwaggerResponse(404, "Point account not found")] + public async Task EarnPoints(Guid userId, [FromBody] EarnPointsRequest request) + { + var command = new EarnPointsCommand( + userId, + request.Points, + request.Source, + request.Description, + request.ExpiryMonths); + + var result = await _mediator.Send(command); + + return Ok(new ApiResponse(true, "Points earned", result)); + } + + /// + /// EN: Spend points + /// VI: Tiêu điểm + /// + [HttpPost("{userId:guid}/spend")] + [SwaggerOperation(Summary = "Spend points", Description = "Spend points from user account")] + [SwaggerResponse(200, "Points spent", typeof(ApiResponse))] + [SwaggerResponse(400, "Insufficient points")] + [SwaggerResponse(404, "Point account not found")] + public async Task SpendPoints(Guid userId, [FromBody] SpendPointsRequest request) + { + var command = new SpendPointsCommand(userId, request.Points, request.Source, request.Description); + var result = await _mediator.Send(command); + + return Ok(new ApiResponse(true, "Points spent", result)); + } + + /// + /// EN: Get point transactions + /// VI: Lấy lịch sử giao dịch điểm + /// + [HttpGet("{userId:guid}/transactions")] + [SwaggerOperation(Summary = "Get point transactions", Description = "Get point transaction history")] + [SwaggerResponse(200, "Success", typeof(ApiResponse))] + [SwaggerResponse(404, "Point account not found")] + public async Task GetTransactions( + Guid userId, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20) + { + var query = new GetPointTransactionsQuery(userId, page, pageSize); + var result = await _mediator.Send(query); + + return Ok(new ApiResponse(true, "Success", result)); + } +} + +#region Request DTOs + +public record CreatePointAccountRequest(Guid UserId); +public record EarnPointsRequest(long Points, string Source, string Description, int? ExpiryMonths = 12); +public record SpendPointsRequest(long Points, string Source, string Description); + +#endregion diff --git a/services/wallet-service-net/src/WalletService.API/Controllers/WalletsController.cs b/services/wallet-service-net/src/WalletService.API/Controllers/WalletsController.cs new file mode 100644 index 00000000..ee846650 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.API/Controllers/WalletsController.cs @@ -0,0 +1,133 @@ +namespace WalletService.API.Controllers; + +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Swashbuckle.AspNetCore.Annotations; +using WalletService.API.Application.Commands; +using WalletService.API.Application.Queries; + +/// +/// EN: Controller for wallet management operations +/// VI: Controller cho các thao tác quản lý ví +/// +[ApiController] +[Route("api/v1/[controller]")] +[Authorize] +[SwaggerTag("Wallet management endpoints / Các endpoint quản lý ví")] +public class WalletsController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public WalletsController(IMediator mediator, ILogger logger) + { + _mediator = mediator; + _logger = logger; + } + + /// + /// EN: Get wallet by user ID + /// VI: Lấy ví theo user ID + /// + [HttpGet("{userId:guid}")] + [SwaggerOperation(Summary = "Get wallet", Description = "Get wallet information by user ID")] + [SwaggerResponse(200, "Success", typeof(ApiResponse))] + [SwaggerResponse(404, "Wallet not found")] + public async Task GetWallet(Guid userId) + { + var result = await _mediator.Send(new GetWalletQuery(userId)); + + if (result == null) + return NotFound(new ApiResponse(false, "Wallet not found")); + + return Ok(new ApiResponse(true, "Success", result)); + } + + /// + /// EN: Create a new wallet + /// VI: Tạo ví mới + /// + [HttpPost] + [SwaggerOperation(Summary = "Create wallet", Description = "Create a new wallet for a user")] + [SwaggerResponse(201, "Wallet created", typeof(ApiResponse))] + [SwaggerResponse(400, "Invalid request")] + [SwaggerResponse(409, "Wallet already exists")] + public async Task CreateWallet([FromBody] CreateWalletRequest request) + { + var command = new CreateWalletCommand(request.UserId, request.Currency ?? "VND"); + var result = await _mediator.Send(command); + + return CreatedAtAction( + nameof(GetWallet), + new { userId = result.UserId }, + new ApiResponse(true, "Wallet created", result)); + } + + /// + /// EN: Deposit money into wallet + /// VI: Nạp tiền vào ví + /// + [HttpPost("{userId:guid}/deposit")] + [SwaggerOperation(Summary = "Deposit", Description = "Deposit money into wallet")] + [SwaggerResponse(200, "Deposit successful", typeof(ApiResponse))] + [SwaggerResponse(400, "Invalid request")] + [SwaggerResponse(404, "Wallet not found")] + public async Task Deposit(Guid userId, [FromBody] DepositRequest request) + { + var command = new DepositCommand(userId, request.Amount, request.Description, request.ReferenceId); + var result = await _mediator.Send(command); + + return Ok(new ApiResponse(true, "Deposit successful", result)); + } + + /// + /// EN: Withdraw money from wallet + /// VI: Rút tiền từ ví + /// + [HttpPost("{userId:guid}/withdraw")] + [SwaggerOperation(Summary = "Withdraw", Description = "Withdraw money from wallet")] + [SwaggerResponse(200, "Withdrawal successful", typeof(ApiResponse))] + [SwaggerResponse(400, "Insufficient balance")] + [SwaggerResponse(404, "Wallet not found")] + public async Task Withdraw(Guid userId, [FromBody] WithdrawRequest request) + { + var command = new WithdrawCommand(userId, request.Amount, request.Description, request.ReferenceId); + var result = await _mediator.Send(command); + + return Ok(new ApiResponse(true, "Withdrawal successful", result)); + } + + /// + /// EN: Get wallet transactions + /// VI: Lấy lịch sử giao dịch ví + /// + [HttpGet("{userId:guid}/transactions")] + [SwaggerOperation(Summary = "Get transactions", Description = "Get wallet transaction history")] + [SwaggerResponse(200, "Success", typeof(ApiResponse))] + [SwaggerResponse(404, "Wallet not found")] + public async Task GetTransactions( + Guid userId, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20) + { + var query = new GetWalletTransactionsQuery(userId, page, pageSize); + var result = await _mediator.Send(query); + + return Ok(new ApiResponse(true, "Success", result)); + } +} + +#region Request DTOs + +public record CreateWalletRequest(Guid UserId, string? Currency = "VND"); +public record DepositRequest(decimal Amount, string Description, string? ReferenceId = null); +public record WithdrawRequest(decimal Amount, string Description, string? ReferenceId = null); + +#endregion + +#region API Response + +public record ApiResponse(bool Success, string Message, T? Data = default); + +#endregion diff --git a/services/wallet-service-net/src/WalletService.API/Program.cs b/services/wallet-service-net/src/WalletService.API/Program.cs new file mode 100644 index 00000000..accd4a60 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.API/Program.cs @@ -0,0 +1,193 @@ +using Asp.Versioning; +using FluentValidation; +using Hellang.Middleware.ProblemDetails; +using Microsoft.OpenApi.Models; +using WalletService.API.Application.Behaviors; +using WalletService.Infrastructure; +using Serilog; + +// EN: Configure Serilog early / VI: Cấu hình Serilog sớm +Log.Logger = new LoggerConfiguration() + .WriteTo.Console() + .CreateBootstrapLogger(); + +try +{ + Log.Information("Starting WalletService API / Khởi động WalletService API"); + + var builder = WebApplication.CreateBuilder(args); + + // EN: Configure Serilog / VI: Cấu hình Serilog + builder.Host.UseSerilog((context, services, configuration) => configuration + .ReadFrom.Configuration(context.Configuration) + .ReadFrom.Services(services) + .Enrich.FromLogContext() + .WriteTo.Console()); + + // EN: Add Infrastructure services / VI: Thêm Infrastructure services + builder.Services.AddInfrastructure(builder.Configuration); + + // EN: Add MediatR with behaviors / VI: Thêm MediatR với behaviors + builder.Services.AddMediatR(cfg => + { + cfg.RegisterServicesFromAssemblyContaining(); + cfg.AddOpenBehavior(typeof(LoggingBehavior<,>)); + cfg.AddOpenBehavior(typeof(ValidatorBehavior<,>)); + cfg.AddOpenBehavior(typeof(TransactionBehavior<,>)); + }); + + // EN: Add FluentValidation / VI: Thêm FluentValidation + builder.Services.AddValidatorsFromAssemblyContaining(); + + // EN: Add API versioning / VI: Thêm API versioning + builder.Services.AddApiVersioning(options => + { + options.DefaultApiVersion = new ApiVersion(1, 0); + options.AssumeDefaultVersionWhenUnspecified = true; + options.ReportApiVersions = true; + options.ApiVersionReader = ApiVersionReader.Combine( + new UrlSegmentApiVersionReader(), + new HeaderApiVersionReader("X-Api-Version")); + }) + .AddApiExplorer(options => + { + options.GroupNameFormat = "'v'VVV"; + options.SubstituteApiVersionInUrl = true; + }); + + // EN: Add controllers / VI: Thêm controllers + builder.Services.AddControllers(); + + // EN: Add ProblemDetails middleware (RFC 7807) / VI: Thêm ProblemDetails middleware + builder.Services.AddProblemDetails(options => + { + options.IncludeExceptionDetails = (ctx, ex) => + builder.Environment.IsDevelopment(); + }); + + // EN: Add Swagger / VI: Thêm Swagger + builder.Services.AddEndpointsApiExplorer(); + builder.Services.AddSwaggerGen(options => + { + options.SwaggerDoc("v1", new() + { + Title = "WalletService API", + Version = "v1", + Description = "WalletService microservice API / API microservice WalletService" + }); + + options.EnableAnnotations(); + + // EN: Add JWT Bearer authentication / VI: Thêm xác thực JWT Bearer + options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme + { + Name = "Authorization", + Type = SecuritySchemeType.Http, + Scheme = "bearer", + BearerFormat = "JWT", + In = ParameterLocation.Header, + Description = "JWT Authorization header using the Bearer scheme" + }); + + options.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "Bearer" + } + }, + Array.Empty() + } + }); + }); + + // EN: Add JWT Bearer authentication / VI: Thêm xác thực JWT Bearer + builder.Services.AddAuthentication("Bearer") + .AddJwtBearer("Bearer", options => + { + options.Authority = builder.Configuration["Jwt:Authority"]; + options.RequireHttpsMetadata = false; + options.TokenValidationParameters = new() + { + ValidateAudience = false, + ValidateIssuer = true, + ValidIssuer = builder.Configuration["Jwt:Issuer"] + }; + }); + + builder.Services.AddAuthorization(); + + // EN: Add health checks / VI: Thêm health checks + builder.Services.AddHealthChecks() + .AddNpgSql( + builder.Configuration.GetConnectionString("DefaultConnection") + ?? builder.Configuration["DATABASE_URL"] + ?? "", + name: "postgresql", + tags: ["db", "postgresql"]); + + // EN: Add CORS / VI: Thêm CORS + builder.Services.AddCors(options => + { + options.AddDefaultPolicy(policy => + { + policy.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); + }); + + var app = builder.Build(); + + // EN: Configure middleware pipeline / VI: Cấu hình middleware pipeline + app.UseSerilogRequestLogging(); + app.UseProblemDetails(); + + if (app.Environment.IsDevelopment()) + { + app.UseSwagger(); + app.UseSwaggerUI(c => + { + c.SwaggerEndpoint("/swagger/v1/swagger.json", "WalletService API v1"); + c.RoutePrefix = "swagger"; + }); + } + + app.UseCors(); + app.UseRouting(); + + // EN: Add authentication and authorization / VI: Thêm xác thực và phân quyền + app.UseAuthentication(); + app.UseAuthorization(); + + // EN: Map health check endpoints / VI: Map health check endpoints + app.MapHealthChecks("/health"); + app.MapHealthChecks("/health/live", new() + { + Predicate = _ => false // EN: Just checks app is running / VI: Chỉ kiểm tra app đang chạy + }); + app.MapHealthChecks("/health/ready"); + + // EN: Map controllers / VI: Map controllers + app.MapControllers(); + + // EN: Run the application / VI: Chạy ứng dụng + app.Run(); +} +catch (Exception ex) +{ + Log.Fatal(ex, "Application terminated unexpectedly / Ứng dụng kết thúc bất ngờ"); + throw; +} +finally +{ + Log.CloseAndFlush(); +} + +// EN: Make Program class accessible for integration tests +// VI: Làm cho class Program có thể truy cập cho integration tests +public partial class Program { } diff --git a/services/wallet-service-net/src/WalletService.API/Properties/launchSettings.json b/services/wallet-service-net/src/WalletService.API/Properties/launchSettings.json new file mode 100644 index 00000000..6355d40b --- /dev/null +++ b/services/wallet-service-net/src/WalletService.API/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} \ No newline at end of file diff --git a/services/wallet-service-net/src/WalletService.API/WalletService.API.csproj b/services/wallet-service-net/src/WalletService.API/WalletService.API.csproj new file mode 100644 index 00000000..7eebab0f --- /dev/null +++ b/services/wallet-service-net/src/WalletService.API/WalletService.API.csproj @@ -0,0 +1,45 @@ + + + + WalletService.API + WalletService.API + Web API layer with CQRS pattern + myservice-api + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/services/wallet-service-net/src/WalletService.API/appsettings.Development.json b/services/wallet-service-net/src/WalletService.API/appsettings.Development.json new file mode 100644 index 00000000..e407ac85 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.API/appsettings.Development.json @@ -0,0 +1,19 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft.AspNetCore": "Information", + "Microsoft.EntityFrameworkCore.Database.Command": "Information" + } + }, + "Serilog": { + "MinimumLevel": { + "Default": "Debug", + "Override": { + "Microsoft": "Information", + "Microsoft.EntityFrameworkCore.Database.Command": "Information", + "System": "Information" + } + } + } +} \ No newline at end of file diff --git a/services/wallet-service-net/src/WalletService.API/appsettings.json b/services/wallet-service-net/src/WalletService.API/appsettings.json new file mode 100644 index 00000000..523dc0fc --- /dev/null +++ b/services/wallet-service-net/src/WalletService.API/appsettings.json @@ -0,0 +1,46 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + }, + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "Microsoft.EntityFrameworkCore": "Warning", + "System": "Warning" + } + }, + "WriteTo": [ + { + "Name": "Console", + "Args": { + "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}" + } + } + ], + "Enrich": [ + "FromLogContext", + "WithMachineName", + "WithThreadId" + ] + }, + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Port=5432;Database=myservice_db;Username=postgres;Password=postgres" + }, + "Redis": { + "ConnectionString": "localhost:6379" + }, + "Jwt": { + "Secret": "your-super-secret-key-min-32-characters", + "Issuer": "goodgo-platform", + "Audience": "goodgo-services", + "AccessTokenExpiryMinutes": 15, + "RefreshTokenExpiryDays": 7 + }, + "AllowedHosts": "*" +} \ No newline at end of file diff --git a/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/PointAccountAggregate/IPointAccountRepository.cs b/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/PointAccountAggregate/IPointAccountRepository.cs new file mode 100644 index 00000000..657e1faa --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/PointAccountAggregate/IPointAccountRepository.cs @@ -0,0 +1,61 @@ +namespace WalletService.Domain.AggregatesModel.PointAccountAggregate; + +using WalletService.Domain.SeedWork; + +/// +/// EN: Repository interface for point account aggregate +/// VI: Interface repository cho aggregate tài khoản điểm +/// +public interface IPointAccountRepository : IRepository +{ + /// + /// EN: Get point account by user ID + /// VI: Lấy tài khoản điểm theo ID người dùng + /// + Task GetByUserIdAsync(Guid userId); + + /// + /// EN: Get point account by ID with transactions + /// VI: Lấy tài khoản điểm theo ID kèm theo giao dịch + /// + Task GetByIdWithTransactionsAsync(Guid accountId); + + /// + /// EN: Get point transactions with pagination + /// VI: Lấy giao dịch điểm với phân trang + /// + Task> GetTransactionsAsync( + Guid accountId, + int page = 1, + int pageSize = 20); + + /// + /// EN: Count total transactions for a point account + /// VI: Đếm tổng số giao dịch của một tài khoản điểm + /// + Task CountTransactionsAsync(Guid accountId); + + /// + /// EN: Check if user already has a point account + /// VI: Kiểm tra xem người dùng đã có tài khoản điểm chưa + /// + Task ExistsByUserIdAsync(Guid userId); + + /// + /// EN: Get accounts with expiring points + /// VI: Lấy các tài khoản có điểm sắp hết hạn + /// + Task> GetAccountsWithExpiringPointsAsync(DateTime expiryDate); + + /// + /// EN: Add a new point account + /// VI: Thêm tài khoản điểm mới + /// + PointAccount Add(PointAccount account); + + /// + /// EN: Update an existing point account + /// VI: Cập nhật tài khoản điểm hiện có + /// + void Update(PointAccount account); +} diff --git a/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/PointAccountAggregate/PointAccount.cs b/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/PointAccountAggregate/PointAccount.cs new file mode 100644 index 00000000..83e006ed --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/PointAccountAggregate/PointAccount.cs @@ -0,0 +1,207 @@ +namespace WalletService.Domain.AggregatesModel.PointAccountAggregate; + +using WalletService.Domain.Events; +using WalletService.Domain.Exceptions; +using WalletService.Domain.SeedWork; + +/// +/// EN: Point account aggregate root representing a user's loyalty points +/// VI: Aggregate root tài khoản điểm đại diện cho điểm thưởng của người dùng +/// +public class PointAccount : Entity, IAggregateRoot +{ + /// + /// EN: User ID from IAM Service + /// VI: ID người dùng từ IAM Service + /// + public Guid UserId { get; private set; } + + /// + /// EN: Total points ever earned (lifetime) + /// VI: Tổng điểm đã từng tích được (suốt đời) + /// + public long TotalPoints { get; private set; } + + /// + /// EN: Currently available points + /// VI: Điểm hiện có thể sử dụng + /// + public long AvailablePoints { get; private set; } + + /// + /// EN: Account creation timestamp + /// VI: Thời điểm tạo tài khoản + /// + public DateTime CreatedAt { get; private set; } + + /// + /// EN: Last update timestamp + /// VI: Thời điểm cập nhật cuối + /// + public DateTime UpdatedAt { get; private set; } + + private readonly List _transactions = new(); + + /// + /// EN: Point transactions + /// VI: Các giao dịch điểm + /// + public IReadOnlyCollection Transactions => _transactions.AsReadOnly(); + + protected PointAccount() { } + + /// + /// EN: Create a new point account for a user + /// VI: Tạo tài khoản điểm mới cho người dùng + /// + public PointAccount(Guid userId) + { + Id = Guid.NewGuid(); + UserId = userId; + TotalPoints = 0; + AvailablePoints = 0; + CreatedAt = DateTime.UtcNow; + UpdatedAt = DateTime.UtcNow; + } + + /// + /// EN: Earn points (from purchases, promotions, etc.) + /// VI: Tích điểm (từ mua hàng, khuyến mãi, v.v.) + /// + /// Number of points to earn + /// Source of points (e.g., order ID) + /// Description of the transaction + /// When the points expire (optional) + public void EarnPoints(long points, string source, string description, DateTime? expiresAt = null) + { + if (points <= 0) + throw new PointsDomainException("Points to earn must be greater than zero"); + + TotalPoints += points; + AvailablePoints += points; + UpdatedAt = DateTime.UtcNow; + + var transaction = new PointTransaction( + Id, + points, + PointTransactionType.Earn, + AvailablePoints, + source, + description, + expiresAt); + + _transactions.Add(transaction); + + AddDomainEvent(new PointsEarnedDomainEvent(Id, UserId, points, AvailablePoints, source)); + } + + /// + /// EN: Spend points (for purchases, rewards, etc.) + /// VI: Tiêu điểm (để mua hàng, đổi thưởng, v.v.) + /// + public void SpendPoints(long points, string source, string description) + { + if (points <= 0) + throw new PointsDomainException("Points to spend must be greater than zero"); + + if (AvailablePoints < points) + throw new InsufficientPointsException(AvailablePoints, points); + + AvailablePoints -= points; + UpdatedAt = DateTime.UtcNow; + + var transaction = new PointTransaction( + Id, + points, + PointTransactionType.Spend, + AvailablePoints, + source, + description); + + _transactions.Add(transaction); + + AddDomainEvent(new PointsSpentDomainEvent(Id, UserId, points, AvailablePoints, source)); + } + + /// + /// EN: Expire points (automatic process for expired points) + /// VI: Hết hạn điểm (quy trình tự động cho điểm hết hạn) + /// + public void ExpirePoints(long points, string description) + { + if (points <= 0) + throw new PointsDomainException("Points to expire must be greater than zero"); + + if (AvailablePoints < points) + points = AvailablePoints; // Expire only available points + + AvailablePoints -= points; + UpdatedAt = DateTime.UtcNow; + + var transaction = new PointTransaction( + Id, + points, + PointTransactionType.Expire, + AvailablePoints, + "SYSTEM", + description); + + _transactions.Add(transaction); + } + + /// + /// EN: Add bonus points (from special promotions) + /// VI: Thêm điểm thưởng (từ khuyến mãi đặc biệt) + /// + public void AddBonusPoints(long points, string source, string description, DateTime? expiresAt = null) + { + if (points <= 0) + throw new PointsDomainException("Bonus points must be greater than zero"); + + TotalPoints += points; + AvailablePoints += points; + UpdatedAt = DateTime.UtcNow; + + var transaction = new PointTransaction( + Id, + points, + PointTransactionType.Bonus, + AvailablePoints, + source, + description, + expiresAt); + + _transactions.Add(transaction); + + AddDomainEvent(new PointsEarnedDomainEvent(Id, UserId, points, AvailablePoints, source)); + } + + /// + /// EN: Adjust points manually (admin operation) + /// VI: Điều chỉnh điểm thủ công (thao tác admin) + /// + public void AdjustPoints(long adjustment, string reason) + { + if (adjustment == 0) + throw new PointsDomainException("Adjustment cannot be zero"); + + if (adjustment < 0 && AvailablePoints < Math.Abs(adjustment)) + throw new InsufficientPointsException(AvailablePoints, Math.Abs(adjustment)); + + AvailablePoints += adjustment; + if (adjustment > 0) + TotalPoints += adjustment; + + UpdatedAt = DateTime.UtcNow; + + var transaction = new PointTransaction( + Id, + Math.Abs(adjustment), + PointTransactionType.Adjust, + AvailablePoints, + "ADMIN", + reason); + + _transactions.Add(transaction); + } +} diff --git a/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/PointAccountAggregate/PointTransaction.cs b/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/PointAccountAggregate/PointTransaction.cs new file mode 100644 index 00000000..4c730b3a --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/PointAccountAggregate/PointTransaction.cs @@ -0,0 +1,87 @@ +namespace WalletService.Domain.AggregatesModel.PointAccountAggregate; + +using WalletService.Domain.SeedWork; + +/// +/// EN: Point transaction entity representing a single point transaction +/// VI: Entity giao dịch điểm đại diện cho một giao dịch điểm đơn lẻ +/// +public class PointTransaction : Entity +{ + /// + /// EN: The point account this transaction belongs to + /// VI: Tài khoản điểm mà giao dịch này thuộc về + /// + public Guid AccountId { get; private set; } + + /// + /// EN: Number of points in this transaction + /// VI: Số điểm trong giao dịch này + /// + public long Points { get; private set; } + + /// + /// EN: Type of point transaction + /// VI: Loại giao dịch điểm + /// + public PointTransactionType Type { get; private set; } = null!; + + /// + /// EN: Transaction type ID for EF Core + /// VI: ID loại giao dịch cho EF Core + /// + public int TypeId { get; private set; } + + /// + /// EN: Source of points (e.g., order ID, promotion code) + /// VI: Nguồn điểm (ví dụ: ID đơn hàng, mã khuyến mãi) + /// + public string Source { get; private set; } = string.Empty; + + /// + /// EN: Transaction description + /// VI: Mô tả giao dịch + /// + public string Description { get; private set; } = string.Empty; + + /// + /// EN: When the points expire (for earned points) + /// VI: Thời điểm điểm hết hạn (cho điểm đã tích) + /// + public DateTime? ExpiresAt { get; private set; } + + /// + /// EN: Balance after this transaction + /// VI: Số dư sau giao dịch này + /// + public long BalanceAfter { get; private set; } + + /// + /// EN: When the transaction was created + /// VI: Thời điểm giao dịch được tạo + /// + public DateTime CreatedAt { get; private set; } + + protected PointTransaction() { } + + public PointTransaction( + Guid accountId, + long points, + PointTransactionType type, + long balanceAfter, + string source, + string description, + DateTime? expiresAt = null) + { + Id = Guid.NewGuid(); + AccountId = accountId; + Points = points; + Type = type; + TypeId = type.Id; + BalanceAfter = balanceAfter; + Source = source; + Description = description; + ExpiresAt = expiresAt; + CreatedAt = DateTime.UtcNow; + } +} diff --git a/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/PointAccountAggregate/PointTransactionType.cs b/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/PointAccountAggregate/PointTransactionType.cs new file mode 100644 index 00000000..fb5af983 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/PointAccountAggregate/PointTransactionType.cs @@ -0,0 +1,42 @@ +namespace WalletService.Domain.AggregatesModel.PointAccountAggregate; + +using WalletService.Domain.SeedWork; + +/// +/// EN: Point transaction type enumeration +/// VI: Enumeration loại giao dịch điểm +/// +public class PointTransactionType : Enumeration +{ + /// + /// EN: Earn - points added from purchases or promotions + /// VI: Tích điểm - điểm được thêm từ mua hàng hoặc khuyến mãi + /// + public static PointTransactionType Earn = new(1, nameof(Earn)); + + /// + /// EN: Spend - points used for purchases or rewards + /// VI: Tiêu điểm - điểm được sử dụng để mua hàng hoặc đổi thưởng + /// + public static PointTransactionType Spend = new(2, nameof(Spend)); + + /// + /// EN: Expire - points expired due to inactivity + /// VI: Hết hạn - điểm hết hạn do không hoạt động + /// + public static PointTransactionType Expire = new(3, nameof(Expire)); + + /// + /// EN: Adjust - manual adjustment by admin + /// VI: Điều chỉnh - điều chỉnh thủ công bởi admin + /// + public static PointTransactionType Adjust = new(4, nameof(Adjust)); + + /// + /// EN: Bonus - bonus points from special promotions + /// VI: Thưởng - điểm thưởng từ các khuyến mãi đặc biệt + /// + public static PointTransactionType Bonus = new(5, nameof(Bonus)); + + public PointTransactionType(int id, string name) : base(id, name) { } +} diff --git a/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/IWalletRepository.cs b/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/IWalletRepository.cs new file mode 100644 index 00000000..e87cc6e4 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/IWalletRepository.cs @@ -0,0 +1,55 @@ +namespace WalletService.Domain.AggregatesModel.WalletAggregate; + +using WalletService.Domain.SeedWork; + +/// +/// EN: Repository interface for wallet aggregate +/// VI: Interface repository cho aggregate ví +/// +public interface IWalletRepository : IRepository +{ + /// + /// EN: Get wallet by user ID + /// VI: Lấy ví theo ID người dùng + /// + Task GetByUserIdAsync(Guid userId); + + /// + /// EN: Get wallet by ID with transactions + /// VI: Lấy ví theo ID kèm theo giao dịch + /// + Task GetByIdWithTransactionsAsync(Guid walletId); + + /// + /// EN: Get wallet transactions with pagination + /// VI: Lấy giao dịch ví với phân trang + /// + Task> GetTransactionsAsync( + Guid walletId, + int page = 1, + int pageSize = 20); + + /// + /// EN: Count total transactions for a wallet + /// VI: Đếm tổng số giao dịch của một ví + /// + Task CountTransactionsAsync(Guid walletId); + + /// + /// EN: Check if user already has a wallet + /// VI: Kiểm tra xem người dùng đã có ví chưa + /// + Task ExistsByUserIdAsync(Guid userId); + + /// + /// EN: Add a new wallet + /// VI: Thêm ví mới + /// + Wallet Add(Wallet wallet); + + /// + /// EN: Update an existing wallet + /// VI: Cập nhật ví hiện có + /// + void Update(Wallet wallet); +} diff --git a/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/Money.cs b/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/Money.cs new file mode 100644 index 00000000..fb8dadd8 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/Money.cs @@ -0,0 +1,93 @@ +namespace WalletService.Domain.AggregatesModel.WalletAggregate; + +using WalletService.Domain.SeedWork; + +/// +/// EN: Money value object representing an amount with currency +/// VI: Value object Money đại diện cho số tiền với loại tiền tệ +/// +public class Money : ValueObject +{ + /// + /// EN: The amount of money + /// VI: Số tiền + /// + public decimal Amount { get; private set; } + + /// + /// EN: The currency code (e.g., VND, USD) + /// VI: Mã tiền tệ (ví dụ: VND, USD) + /// + public string Currency { get; private set; } + + /// + /// EN: Zero money in VND + /// VI: Số không tiền VND + /// + public static Money Zero => new(0, "VND"); + + protected Money() + { + Amount = 0; + Currency = "VND"; + } + + public Money(decimal amount, string currency) + { + if (amount < 0) + throw new ArgumentException("Amount cannot be negative", nameof(amount)); + + if (string.IsNullOrWhiteSpace(currency)) + throw new ArgumentException("Currency is required", nameof(currency)); + + Amount = amount; + Currency = currency.ToUpperInvariant(); + } + + /// + /// EN: Add two money values (must be same currency) + /// VI: Cộng hai giá trị tiền (phải cùng loại tiền) + /// + public Money Add(Money other) + { + if (Currency != other.Currency) + throw new InvalidOperationException($"Cannot add money with different currencies: {Currency} and {other.Currency}"); + + return new Money(Amount + other.Amount, Currency); + } + + /// + /// EN: Subtract money value (must be same currency) + /// VI: Trừ giá trị tiền (phải cùng loại tiền) + /// + public Money Subtract(Money other) + { + if (Currency != other.Currency) + throw new InvalidOperationException($"Cannot subtract money with different currencies: {Currency} and {other.Currency}"); + + if (Amount < other.Amount) + throw new InvalidOperationException("Insufficient balance"); + + return new Money(Amount - other.Amount, Currency); + } + + /// + /// EN: Check if this amount is greater than or equal to another + /// VI: Kiểm tra xem số tiền này có lớn hơn hoặc bằng số khác không + /// + public bool IsGreaterThanOrEqual(Money other) + { + if (Currency != other.Currency) + throw new InvalidOperationException($"Cannot compare money with different currencies: {Currency} and {other.Currency}"); + + return Amount >= other.Amount; + } + + protected override IEnumerable GetEqualityComponents() + { + yield return Amount; + yield return Currency; + } + + public override string ToString() => $"{Amount:N0} {Currency}"; +} diff --git a/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/TransactionType.cs b/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/TransactionType.cs new file mode 100644 index 00000000..1f2ab5db --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/TransactionType.cs @@ -0,0 +1,42 @@ +namespace WalletService.Domain.AggregatesModel.WalletAggregate; + +using WalletService.Domain.SeedWork; + +/// +/// EN: Transaction type enumeration +/// VI: Enumeration loại giao dịch +/// +public class TransactionType : Enumeration +{ + /// + /// EN: Credit - money added to wallet + /// VI: Ghi có - tiền được thêm vào ví + /// + public static TransactionType Credit = new(1, nameof(Credit)); + + /// + /// EN: Debit - money withdrawn from wallet + /// VI: Ghi nợ - tiền được rút khỏi ví + /// + public static TransactionType Debit = new(2, nameof(Debit)); + + /// + /// EN: Transfer out - money sent to another wallet + /// VI: Chuyển đi - tiền được gửi đến ví khác + /// + public static TransactionType TransferOut = new(3, nameof(TransferOut)); + + /// + /// EN: Transfer in - money received from another wallet + /// VI: Chuyển đến - tiền được nhận từ ví khác + /// + public static TransactionType TransferIn = new(4, nameof(TransferIn)); + + /// + /// EN: Refund - money refunded to wallet + /// VI: Hoàn tiền - tiền được hoàn lại vào ví + /// + public static TransactionType Refund = new(5, nameof(Refund)); + + public TransactionType(int id, string name) : base(id, name) { } +} diff --git a/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/Wallet.cs b/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/Wallet.cs new file mode 100644 index 00000000..d8246948 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/Wallet.cs @@ -0,0 +1,260 @@ +namespace WalletService.Domain.AggregatesModel.WalletAggregate; + +using WalletService.Domain.Events; +using WalletService.Domain.Exceptions; +using WalletService.Domain.SeedWork; + +/// +/// EN: Wallet aggregate root representing a user's digital wallet +/// VI: Aggregate root Ví đại diện cho ví điện tử của người dùng +/// +public class Wallet : Entity, IAggregateRoot +{ + /// + /// EN: User ID from IAM Service + /// VI: ID người dùng từ IAM Service + /// + public Guid UserId { get; private set; } + + /// + /// EN: Current wallet balance + /// VI: Số dư ví hiện tại + /// + public Money Balance { get; private set; } = null!; + + /// + /// EN: Wallet status + /// VI: Trạng thái ví + /// + public WalletStatus Status { get; private set; } = null!; + + /// + /// EN: Status ID for EF Core + /// VI: ID trạng thái cho EF Core + /// + public int StatusId { get; private set; } + + /// + /// EN: Wallet creation timestamp + /// VI: Thời điểm tạo ví + /// + public DateTime CreatedAt { get; private set; } + + /// + /// EN: Last update timestamp + /// VI: Thời điểm cập nhật cuối + /// + public DateTime UpdatedAt { get; private set; } + + private readonly List _transactions = new(); + + /// + /// EN: Wallet transactions + /// VI: Các giao dịch của ví + /// + public IReadOnlyCollection Transactions => _transactions.AsReadOnly(); + + protected Wallet() { } + + /// + /// EN: Create a new wallet for a user + /// VI: Tạo ví mới cho người dùng + /// + public Wallet(Guid userId, string currency = "VND") + { + Id = Guid.NewGuid(); + UserId = userId; + Balance = new Money(0, currency); + Status = WalletStatus.Active; + StatusId = Status.Id; + CreatedAt = DateTime.UtcNow; + UpdatedAt = DateTime.UtcNow; + + AddDomainEvent(new WalletCreatedDomainEvent(Id, userId)); + } + + /// + /// EN: Deposit money into the wallet + /// VI: Nạp tiền vào ví + /// + public void Deposit(Money amount, string description, string? referenceId = null) + { + EnsureWalletIsActive(); + + if (amount.Amount <= 0) + throw new WalletDomainException("Deposit amount must be greater than zero"); + + if (amount.Currency != Balance.Currency) + throw new WalletDomainException($"Currency mismatch. Wallet uses {Balance.Currency}"); + + Balance = Balance.Add(amount); + UpdatedAt = DateTime.UtcNow; + + var transaction = new WalletTransaction( + Id, + amount, + TransactionType.Credit, + Balance.Amount, + description, + referenceId); + + _transactions.Add(transaction); + + AddDomainEvent(new WalletBalanceChangedDomainEvent( + Id, + UserId, + TransactionType.Credit.Name, + amount.Amount, + Balance.Amount)); + } + + /// + /// EN: Withdraw money from the wallet + /// VI: Rút tiền khỏi ví + /// + public void Withdraw(Money amount, string description, string? referenceId = null) + { + EnsureWalletIsActive(); + + if (amount.Amount <= 0) + throw new WalletDomainException("Withdrawal amount must be greater than zero"); + + if (amount.Currency != Balance.Currency) + throw new WalletDomainException($"Currency mismatch. Wallet uses {Balance.Currency}"); + + if (!Balance.IsGreaterThanOrEqual(amount)) + throw new InsufficientBalanceException(Balance.Amount, amount.Amount); + + Balance = Balance.Subtract(amount); + UpdatedAt = DateTime.UtcNow; + + var transaction = new WalletTransaction( + Id, + amount, + TransactionType.Debit, + Balance.Amount, + description, + referenceId); + + _transactions.Add(transaction); + + AddDomainEvent(new WalletBalanceChangedDomainEvent( + Id, + UserId, + TransactionType.Debit.Name, + amount.Amount, + Balance.Amount)); + } + + /// + /// EN: Transfer money to another wallet + /// VI: Chuyển tiền đến ví khác + /// + public void TransferOut(Money amount, Guid toWalletId, string description) + { + EnsureWalletIsActive(); + + if (amount.Amount <= 0) + throw new WalletDomainException("Transfer amount must be greater than zero"); + + if (!Balance.IsGreaterThanOrEqual(amount)) + throw new InsufficientBalanceException(Balance.Amount, amount.Amount); + + Balance = Balance.Subtract(amount); + UpdatedAt = DateTime.UtcNow; + + var transaction = new WalletTransaction( + Id, + amount, + TransactionType.TransferOut, + Balance.Amount, + description, + toWalletId.ToString()); + + _transactions.Add(transaction); + + AddDomainEvent(new WalletBalanceChangedDomainEvent( + Id, + UserId, + TransactionType.TransferOut.Name, + amount.Amount, + Balance.Amount)); + } + + /// + /// EN: Receive transfer from another wallet + /// VI: Nhận chuyển khoản từ ví khác + /// + public void TransferIn(Money amount, Guid fromWalletId, string description) + { + EnsureWalletIsActive(); + + Balance = Balance.Add(amount); + UpdatedAt = DateTime.UtcNow; + + var transaction = new WalletTransaction( + Id, + amount, + TransactionType.TransferIn, + Balance.Amount, + description, + fromWalletId.ToString()); + + _transactions.Add(transaction); + + AddDomainEvent(new WalletBalanceChangedDomainEvent( + Id, + UserId, + TransactionType.TransferIn.Name, + amount.Amount, + Balance.Amount)); + } + + /// + /// EN: Freeze the wallet (no transactions allowed) + /// VI: Đóng băng ví (không cho phép giao dịch) + /// + public void Freeze() + { + if (Status == WalletStatus.Closed) + throw new WalletDomainException("Cannot freeze a closed wallet"); + + Status = WalletStatus.Frozen; + StatusId = Status.Id; + UpdatedAt = DateTime.UtcNow; + } + + /// + /// EN: Unfreeze the wallet + /// VI: Mở băng ví + /// + public void Unfreeze() + { + if (Status != WalletStatus.Frozen) + throw new WalletDomainException("Only frozen wallets can be unfrozen"); + + Status = WalletStatus.Active; + StatusId = Status.Id; + UpdatedAt = DateTime.UtcNow; + } + + /// + /// EN: Close the wallet permanently + /// VI: Đóng ví vĩnh viễn + /// + public void Close() + { + if (Balance.Amount > 0) + throw new WalletDomainException("Cannot close wallet with positive balance"); + + Status = WalletStatus.Closed; + StatusId = Status.Id; + UpdatedAt = DateTime.UtcNow; + } + + private void EnsureWalletIsActive() + { + if (Status != WalletStatus.Active) + throw new WalletDomainException($"Wallet is {Status.Name}. Only active wallets can perform transactions"); + } +} diff --git a/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/WalletStatus.cs b/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/WalletStatus.cs new file mode 100644 index 00000000..a625ca18 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/WalletStatus.cs @@ -0,0 +1,30 @@ +namespace WalletService.Domain.AggregatesModel.WalletAggregate; + +using WalletService.Domain.SeedWork; + +/// +/// EN: Wallet status enumeration +/// VI: Enumeration trạng thái ví +/// +public class WalletStatus : Enumeration +{ + /// + /// EN: Wallet is active and can perform transactions + /// VI: Ví đang hoạt động và có thể thực hiện giao dịch + /// + public static WalletStatus Active = new(1, nameof(Active)); + + /// + /// EN: Wallet is frozen and cannot perform transactions + /// VI: Ví bị đóng băng và không thể thực hiện giao dịch + /// + public static WalletStatus Frozen = new(2, nameof(Frozen)); + + /// + /// EN: Wallet is closed permanently + /// VI: Ví đã bị đóng vĩnh viễn + /// + public static WalletStatus Closed = new(3, nameof(Closed)); + + public WalletStatus(int id, string name) : base(id, name) { } +} diff --git a/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/WalletTransaction.cs b/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/WalletTransaction.cs new file mode 100644 index 00000000..5d01ca09 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/WalletTransaction.cs @@ -0,0 +1,79 @@ +namespace WalletService.Domain.AggregatesModel.WalletAggregate; + +using WalletService.Domain.SeedWork; + +/// +/// EN: Wallet transaction entity representing a single transaction in a wallet +/// VI: Entity giao dịch ví đại diện cho một giao dịch đơn lẻ trong ví +/// +public class WalletTransaction : Entity +{ + /// + /// EN: The wallet this transaction belongs to + /// VI: Ví mà giao dịch này thuộc về + /// + public Guid WalletId { get; private set; } + + /// + /// EN: Transaction amount + /// VI: Số tiền giao dịch + /// + public Money Amount { get; private set; } = null!; + + /// + /// EN: Type of transaction + /// VI: Loại giao dịch + /// + public TransactionType Type { get; private set; } = null!; + + /// + /// EN: Transaction type ID for EF Core + /// VI: ID loại giao dịch cho EF Core + /// + public int TypeId { get; private set; } + + /// + /// EN: External reference ID (e.g., order ID, payment ID) + /// VI: ID tham chiếu bên ngoài (ví dụ: ID đơn hàng, ID thanh toán) + /// + public string? ReferenceId { get; private set; } + + /// + /// EN: Transaction description + /// VI: Mô tả giao dịch + /// + public string Description { get; private set; } = string.Empty; + + /// + /// EN: Balance after this transaction + /// VI: Số dư sau giao dịch này + /// + public decimal BalanceAfter { get; private set; } + + /// + /// EN: When the transaction was created + /// VI: Thời điểm giao dịch được tạo + /// + public DateTime CreatedAt { get; private set; } + + protected WalletTransaction() { } + + public WalletTransaction( + Guid walletId, + Money amount, + TransactionType type, + decimal balanceAfter, + string description, + string? referenceId = null) + { + Id = Guid.NewGuid(); + WalletId = walletId; + Amount = amount; + Type = type; + TypeId = type.Id; + BalanceAfter = balanceAfter; + Description = description; + ReferenceId = referenceId; + CreatedAt = DateTime.UtcNow; + } +} diff --git a/services/wallet-service-net/src/WalletService.Domain/Events/PointsEarnedDomainEvent.cs b/services/wallet-service-net/src/WalletService.Domain/Events/PointsEarnedDomainEvent.cs new file mode 100644 index 00000000..71d52a8f --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Domain/Events/PointsEarnedDomainEvent.cs @@ -0,0 +1,32 @@ +namespace WalletService.Domain.Events; + +using MediatR; + +/// +/// EN: Domain event raised when points are earned +/// VI: Domain event được phát ra khi điểm được tích +/// +public class PointsEarnedDomainEvent : INotification +{ + public Guid AccountId { get; } + public Guid UserId { get; } + public long PointsEarned { get; } + public long NewBalance { get; } + public string Source { get; } + public DateTime OccurredAt { get; } + + public PointsEarnedDomainEvent( + Guid accountId, + Guid userId, + long pointsEarned, + long newBalance, + string source) + { + AccountId = accountId; + UserId = userId; + PointsEarned = pointsEarned; + NewBalance = newBalance; + Source = source; + OccurredAt = DateTime.UtcNow; + } +} diff --git a/services/wallet-service-net/src/WalletService.Domain/Events/PointsSpentDomainEvent.cs b/services/wallet-service-net/src/WalletService.Domain/Events/PointsSpentDomainEvent.cs new file mode 100644 index 00000000..d87be424 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Domain/Events/PointsSpentDomainEvent.cs @@ -0,0 +1,32 @@ +namespace WalletService.Domain.Events; + +using MediatR; + +/// +/// EN: Domain event raised when points are spent +/// VI: Domain event được phát ra khi điểm được tiêu +/// +public class PointsSpentDomainEvent : INotification +{ + public Guid AccountId { get; } + public Guid UserId { get; } + public long PointsSpent { get; } + public long NewBalance { get; } + public string Source { get; } + public DateTime OccurredAt { get; } + + public PointsSpentDomainEvent( + Guid accountId, + Guid userId, + long pointsSpent, + long newBalance, + string source) + { + AccountId = accountId; + UserId = userId; + PointsSpent = pointsSpent; + NewBalance = newBalance; + Source = source; + OccurredAt = DateTime.UtcNow; + } +} diff --git a/services/wallet-service-net/src/WalletService.Domain/Events/WalletBalanceChangedDomainEvent.cs b/services/wallet-service-net/src/WalletService.Domain/Events/WalletBalanceChangedDomainEvent.cs new file mode 100644 index 00000000..6574b418 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Domain/Events/WalletBalanceChangedDomainEvent.cs @@ -0,0 +1,32 @@ +namespace WalletService.Domain.Events; + +using MediatR; + +/// +/// EN: Domain event raised when wallet balance changes +/// VI: Domain event được phát ra khi số dư ví thay đổi +/// +public class WalletBalanceChangedDomainEvent : INotification +{ + public Guid WalletId { get; } + public Guid UserId { get; } + public string TransactionType { get; } + public decimal Amount { get; } + public decimal NewBalance { get; } + public DateTime OccurredAt { get; } + + public WalletBalanceChangedDomainEvent( + Guid walletId, + Guid userId, + string transactionType, + decimal amount, + decimal newBalance) + { + WalletId = walletId; + UserId = userId; + TransactionType = transactionType; + Amount = amount; + NewBalance = newBalance; + OccurredAt = DateTime.UtcNow; + } +} diff --git a/services/wallet-service-net/src/WalletService.Domain/Events/WalletCreatedDomainEvent.cs b/services/wallet-service-net/src/WalletService.Domain/Events/WalletCreatedDomainEvent.cs new file mode 100644 index 00000000..700a4c70 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Domain/Events/WalletCreatedDomainEvent.cs @@ -0,0 +1,21 @@ +namespace WalletService.Domain.Events; + +using MediatR; + +/// +/// EN: Domain event raised when a new wallet is created +/// VI: Domain event được phát ra khi một ví mới được tạo +/// +public class WalletCreatedDomainEvent : INotification +{ + public Guid WalletId { get; } + public Guid UserId { get; } + public DateTime OccurredAt { get; } + + public WalletCreatedDomainEvent(Guid walletId, Guid userId) + { + WalletId = walletId; + UserId = userId; + OccurredAt = DateTime.UtcNow; + } +} diff --git a/services/wallet-service-net/src/WalletService.Domain/Exceptions/InsufficientBalanceException.cs b/services/wallet-service-net/src/WalletService.Domain/Exceptions/InsufficientBalanceException.cs new file mode 100644 index 00000000..2c9ff542 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Domain/Exceptions/InsufficientBalanceException.cs @@ -0,0 +1,18 @@ +namespace WalletService.Domain.Exceptions; + +/// +/// EN: Exception thrown when wallet has insufficient balance for a transaction +/// VI: Exception được ném khi ví không đủ số dư cho giao dịch +/// +public class InsufficientBalanceException : WalletDomainException +{ + public decimal CurrentBalance { get; } + public decimal RequestedAmount { get; } + + public InsufficientBalanceException(decimal currentBalance, decimal requestedAmount) + : base($"Insufficient balance. Current: {currentBalance:N0}, Requested: {requestedAmount:N0}") + { + CurrentBalance = currentBalance; + RequestedAmount = requestedAmount; + } +} diff --git a/services/wallet-service-net/src/WalletService.Domain/Exceptions/InsufficientPointsException.cs b/services/wallet-service-net/src/WalletService.Domain/Exceptions/InsufficientPointsException.cs new file mode 100644 index 00000000..9c7dc0b8 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Domain/Exceptions/InsufficientPointsException.cs @@ -0,0 +1,18 @@ +namespace WalletService.Domain.Exceptions; + +/// +/// EN: Exception thrown when account has insufficient points for a transaction +/// VI: Exception được ném khi tài khoản không đủ điểm cho giao dịch +/// +public class InsufficientPointsException : PointsDomainException +{ + public long AvailablePoints { get; } + public long RequestedPoints { get; } + + public InsufficientPointsException(long availablePoints, long requestedPoints) + : base($"Insufficient points. Available: {availablePoints:N0}, Requested: {requestedPoints:N0}") + { + AvailablePoints = availablePoints; + RequestedPoints = requestedPoints; + } +} diff --git a/services/wallet-service-net/src/WalletService.Domain/Exceptions/PointsDomainException.cs b/services/wallet-service-net/src/WalletService.Domain/Exceptions/PointsDomainException.cs new file mode 100644 index 00000000..919979e9 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Domain/Exceptions/PointsDomainException.cs @@ -0,0 +1,15 @@ +namespace WalletService.Domain.Exceptions; + +/// +/// EN: Base exception for points domain errors +/// VI: Exception cơ sở cho các lỗi domain điểm +/// +public class PointsDomainException : Exception +{ + public PointsDomainException() { } + + public PointsDomainException(string message) : base(message) { } + + public PointsDomainException(string message, Exception innerException) + : base(message, innerException) { } +} diff --git a/services/wallet-service-net/src/WalletService.Domain/Exceptions/WalletDomainException.cs b/services/wallet-service-net/src/WalletService.Domain/Exceptions/WalletDomainException.cs new file mode 100644 index 00000000..d74a08c9 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Domain/Exceptions/WalletDomainException.cs @@ -0,0 +1,15 @@ +namespace WalletService.Domain.Exceptions; + +/// +/// EN: Base exception for wallet domain errors +/// VI: Exception cơ sở cho các lỗi domain ví +/// +public class WalletDomainException : Exception +{ + public WalletDomainException() { } + + public WalletDomainException(string message) : base(message) { } + + public WalletDomainException(string message, Exception innerException) + : base(message, innerException) { } +} diff --git a/services/wallet-service-net/src/WalletService.Domain/SeedWork/Entity.cs b/services/wallet-service-net/src/WalletService.Domain/SeedWork/Entity.cs new file mode 100644 index 00000000..b0f3514c --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Domain/SeedWork/Entity.cs @@ -0,0 +1,102 @@ +using MediatR; + +namespace WalletService.Domain.SeedWork; + +/// +/// EN: Base class for all domain entities. +/// VI: Lớp cơ sở cho tất cả các entity trong domain. +/// +public abstract class Entity +{ + private int? _requestedHashCode; + private Guid _id; + private List _domainEvents = new(); + + /// + /// EN: Unique identifier for the entity. + /// VI: Định danh duy nhất cho entity. + /// + public virtual Guid Id + { + get => _id; + protected set => _id = value; + } + + /// + /// EN: Domain events raised by this entity. + /// VI: Các domain event được phát ra bởi entity này. + /// + public IReadOnlyCollection DomainEvents => _domainEvents.AsReadOnly(); + + /// + /// EN: Add a domain event to be dispatched. + /// VI: Thêm một domain event để dispatch. + /// + public void AddDomainEvent(INotification eventItem) + { + _domainEvents.Add(eventItem); + } + + /// + /// EN: Remove a domain event. + /// VI: Xóa một domain event. + /// + public void RemoveDomainEvent(INotification eventItem) + { + _domainEvents.Remove(eventItem); + } + + /// + /// EN: Clear all domain events. + /// VI: Xóa tất cả domain events. + /// + public void ClearDomainEvents() + { + _domainEvents.Clear(); + } + + /// + /// EN: Check if entity is transient (not persisted yet). + /// VI: Kiểm tra xem entity có phải là transient (chưa lưu) không. + /// + public bool IsTransient() + { + return Id == default; + } + + public override bool Equals(object? obj) + { + if (obj is not Entity item) + return false; + + if (ReferenceEquals(this, item)) + return true; + + if (GetType() != item.GetType()) + return false; + + if (item.IsTransient() || IsTransient()) + return false; + + return item.Id == Id; + } + + public override int GetHashCode() + { + if (IsTransient()) + return base.GetHashCode(); + + _requestedHashCode ??= Id.GetHashCode() ^ 31; + return _requestedHashCode.Value; + } + + public static bool operator ==(Entity? left, Entity? right) + { + return left?.Equals(right) ?? right is null; + } + + public static bool operator !=(Entity? left, Entity? right) + { + return !(left == right); + } +} diff --git a/services/wallet-service-net/src/WalletService.Domain/SeedWork/Enumeration.cs b/services/wallet-service-net/src/WalletService.Domain/SeedWork/Enumeration.cs new file mode 100644 index 00000000..d10dcdc7 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Domain/SeedWork/Enumeration.cs @@ -0,0 +1,95 @@ +using System.Reflection; + +namespace WalletService.Domain.SeedWork; + +/// +/// EN: Base class for enumeration classes (type-safe enum pattern). +/// VI: Lớp cơ sở cho các lớp enumeration (pattern enum an toàn kiểu). +/// +/// +/// EN: This provides a type-safe alternative to enums with additional functionality +/// like validation, parsing, and rich behavior. +/// VI: Cung cấp một thay thế an toàn kiểu cho enums với các chức năng bổ sung +/// như validation, parsing, và hành vi phong phú. +/// +public abstract class Enumeration : IComparable +{ + /// + /// EN: The name of the enumeration value. + /// VI: Tên của giá trị enumeration. + /// + public string Name { get; private set; } + + /// + /// EN: The unique identifier of the enumeration value. + /// VI: Định danh duy nhất của giá trị enumeration. + /// + public int Id { get; private set; } + + protected Enumeration(int id, string name) => (Id, Name) = (id, name); + + public override string ToString() => Name; + + /// + /// EN: Get all enumeration values of a given type. + /// VI: Lấy tất cả các giá trị enumeration của một kiểu cho trước. + /// + public static IEnumerable GetAll() where T : Enumeration => + typeof(T).GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly) + .Select(f => f.GetValue(null)) + .Cast(); + + public override bool Equals(object? obj) + { + if (obj is not Enumeration otherValue) + return false; + + var typeMatches = GetType() == obj.GetType(); + var valueMatches = Id.Equals(otherValue.Id); + + return typeMatches && valueMatches; + } + + public override int GetHashCode() => Id.GetHashCode(); + + /// + /// EN: Get absolute difference between two enumeration values. + /// VI: Lấy sự khác biệt tuyệt đối giữa hai giá trị enumeration. + /// + public static int AbsoluteDifference(Enumeration firstValue, Enumeration secondValue) + { + return Math.Abs(firstValue.Id - secondValue.Id); + } + + /// + /// EN: Parse an integer ID to the corresponding enumeration value. + /// VI: Parse một ID integer thành giá trị enumeration tương ứng. + /// + public static T FromValue(int value) where T : Enumeration + { + var matchingItem = Parse(value, "value", item => item.Id == value); + return matchingItem; + } + + /// + /// EN: Parse a display name to the corresponding enumeration value. + /// VI: Parse một tên hiển thị thành giá trị enumeration tương ứng. + /// + public static T FromDisplayName(string displayName) where T : Enumeration + { + var matchingItem = Parse(displayName, "display name", item => item.Name == displayName); + return matchingItem; + } + + private static T Parse(TValue value, string description, Func predicate) where T : Enumeration + { + var matchingItem = GetAll().FirstOrDefault(predicate); + + if (matchingItem is null) + throw new InvalidOperationException($"'{value}' is not a valid {description} in {typeof(T)}"); + + return matchingItem; + } + + public int CompareTo(object? other) => Id.CompareTo(((Enumeration)other!).Id); +} diff --git a/services/wallet-service-net/src/WalletService.Domain/SeedWork/IAggregateRoot.cs b/services/wallet-service-net/src/WalletService.Domain/SeedWork/IAggregateRoot.cs new file mode 100644 index 00000000..468eec64 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Domain/SeedWork/IAggregateRoot.cs @@ -0,0 +1,15 @@ +namespace WalletService.Domain.SeedWork; + +/// +/// EN: Marker interface for aggregate roots. +/// VI: Interface đánh dấu cho aggregate roots. +/// +/// +/// EN: Aggregate roots are the entry points to aggregates and are the only objects +/// that outside code should hold references to. +/// VI: Aggregate roots là điểm vào của aggregates và là đối tượng duy nhất +/// mà code bên ngoài nên giữ tham chiếu đến. +/// +public interface IAggregateRoot +{ +} diff --git a/services/wallet-service-net/src/WalletService.Domain/SeedWork/IRepository.cs b/services/wallet-service-net/src/WalletService.Domain/SeedWork/IRepository.cs new file mode 100644 index 00000000..59db882b --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Domain/SeedWork/IRepository.cs @@ -0,0 +1,15 @@ +namespace WalletService.Domain.SeedWork; + +/// +/// EN: Generic repository interface for aggregate roots. +/// VI: Interface repository generic cho aggregate roots. +/// +/// EN: The aggregate root type / VI: Kiểu aggregate root +public interface IRepository where T : IAggregateRoot +{ + /// + /// EN: The unit of work for this repository. + /// VI: Unit of work cho repository này. + /// + IUnitOfWork UnitOfWork { get; } +} diff --git a/services/wallet-service-net/src/WalletService.Domain/SeedWork/IUnitOfWork.cs b/services/wallet-service-net/src/WalletService.Domain/SeedWork/IUnitOfWork.cs new file mode 100644 index 00000000..873adede --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Domain/SeedWork/IUnitOfWork.cs @@ -0,0 +1,30 @@ +namespace WalletService.Domain.SeedWork; + +/// +/// EN: Unit of Work pattern interface. +/// VI: Interface cho Unit of Work pattern. +/// +/// +/// EN: Maintains a list of objects affected by a business transaction +/// and coordinates the writing out of changes. +/// VI: Duy trì danh sách các đối tượng bị ảnh hưởng bởi một transaction nghiệp vụ +/// và điều phối việc ghi các thay đổi. +/// +public interface IUnitOfWork : IDisposable +{ + /// + /// EN: Save all changes made in this unit of work. + /// VI: Lưu tất cả các thay đổi được thực hiện trong unit of work này. + /// + /// EN: Cancellation token / VI: Token hủy + /// EN: Number of entities written / VI: Số entity đã ghi + Task SaveChangesAsync(CancellationToken cancellationToken = default); + + /// + /// EN: Save all changes and dispatch domain events. + /// VI: Lưu tất cả thay đổi và dispatch domain events. + /// + /// EN: Cancellation token / VI: Token hủy + /// EN: True if successful / VI: True nếu thành công + Task SaveEntitiesAsync(CancellationToken cancellationToken = default); +} diff --git a/services/wallet-service-net/src/WalletService.Domain/SeedWork/ValueObject.cs b/services/wallet-service-net/src/WalletService.Domain/SeedWork/ValueObject.cs new file mode 100644 index 00000000..b8f54a47 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Domain/SeedWork/ValueObject.cs @@ -0,0 +1,53 @@ +namespace WalletService.Domain.SeedWork; + +/// +/// EN: Base class for Value Objects following DDD patterns. +/// VI: Lớp cơ sở cho Value Objects theo mẫu DDD. +/// +/// +/// EN: Value objects are immutable and compared by their values, not identity. +/// VI: Value objects là bất biến và được so sánh theo giá trị, không phải định danh. +/// +public abstract class ValueObject +{ + /// + /// EN: Get the atomic values that make up this value object. + /// VI: Lấy các giá trị nguyên tử tạo nên value object này. + /// + protected abstract IEnumerable GetEqualityComponents(); + + public override bool Equals(object? obj) + { + if (obj is null || obj.GetType() != GetType()) + return false; + + var other = (ValueObject)obj; + return GetEqualityComponents().SequenceEqual(other.GetEqualityComponents()); + } + + public override int GetHashCode() + { + return GetEqualityComponents() + .Select(x => x?.GetHashCode() ?? 0) + .Aggregate((x, y) => x ^ y); + } + + public static bool operator ==(ValueObject? left, ValueObject? right) + { + return left?.Equals(right) ?? right is null; + } + + public static bool operator !=(ValueObject? left, ValueObject? right) + { + return !(left == right); + } + + /// + /// EN: Create a copy of this value object with modifications. + /// VI: Tạo bản sao của value object này với các thay đổi. + /// + protected ValueObject GetCopy() + { + return (ValueObject)MemberwiseClone(); + } +} diff --git a/services/wallet-service-net/src/WalletService.Domain/WalletService.Domain.csproj b/services/wallet-service-net/src/WalletService.Domain/WalletService.Domain.csproj new file mode 100644 index 00000000..fd217d89 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Domain/WalletService.Domain.csproj @@ -0,0 +1,14 @@ + + + + WalletService.Domain + WalletService.Domain + Domain layer containing core business logic and entities + + + + + + + + diff --git a/services/wallet-service-net/src/WalletService.Infrastructure/DependencyInjection.cs b/services/wallet-service-net/src/WalletService.Infrastructure/DependencyInjection.cs new file mode 100644 index 00000000..b5d8f4a7 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Infrastructure/DependencyInjection.cs @@ -0,0 +1,44 @@ +namespace WalletService.Infrastructure; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using WalletService.Domain.AggregatesModel.PointAccountAggregate; +using WalletService.Domain.AggregatesModel.WalletAggregate; +using WalletService.Infrastructure.Repositories; + +/// +/// EN: Extension methods for registering Infrastructure services +/// VI: Extension methods để đăng ký các Infrastructure services +/// +public static class DependencyInjection +{ + public static IServiceCollection AddInfrastructure( + this IServiceCollection services, + IConfiguration configuration) + { + // EN: Register DbContext with PostgreSQL + // VI: Đăng ký DbContext với PostgreSQL + var connectionString = configuration.GetConnectionString("DefaultConnection") + ?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found."); + + services.AddDbContext(options => + { + options.UseNpgsql(connectionString, npgsqlOptions => + { + npgsqlOptions.MigrationsAssembly(typeof(WalletServiceContext).Assembly.FullName); + npgsqlOptions.EnableRetryOnFailure( + maxRetryCount: 5, + maxRetryDelay: TimeSpan.FromSeconds(30), + errorCodesToAdd: null); + }); + }); + + // EN: Register repositories + // VI: Đăng ký repositories + services.AddScoped(); + services.AddScoped(); + + return services; + } +} diff --git a/services/wallet-service-net/src/WalletService.Infrastructure/EntityConfigurations/PointAccountEntityTypeConfiguration.cs b/services/wallet-service-net/src/WalletService.Infrastructure/EntityConfigurations/PointAccountEntityTypeConfiguration.cs new file mode 100644 index 00000000..8dca684b --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Infrastructure/EntityConfigurations/PointAccountEntityTypeConfiguration.cs @@ -0,0 +1,56 @@ +namespace WalletService.Infrastructure.EntityConfigurations; + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using WalletService.Domain.AggregatesModel.PointAccountAggregate; + +/// +/// EN: EF Core configuration for PointAccount aggregate +/// VI: Cấu hình EF Core cho aggregate PointAccount +/// +public class PointAccountEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("point_accounts"); + + builder.HasKey(p => p.Id); + + builder.Property(p => p.Id) + .HasColumnName("id") + .ValueGeneratedNever(); + + builder.Property(p => p.UserId) + .HasColumnName("user_id") + .IsRequired(); + + builder.HasIndex(p => p.UserId) + .IsUnique() + .HasDatabaseName("ix_point_accounts_user_id"); + + builder.Property(p => p.TotalPoints) + .HasColumnName("total_points") + .IsRequired(); + + builder.Property(p => p.AvailablePoints) + .HasColumnName("available_points") + .IsRequired(); + + builder.Property(p => p.CreatedAt) + .HasColumnName("created_at") + .IsRequired(); + + builder.Property(p => p.UpdatedAt) + .HasColumnName("updated_at") + .IsRequired(); + + // EN: Configure navigation to transactions + // VI: Cấu hình navigation đến transactions + builder.HasMany(p => p.Transactions) + .WithOne() + .HasForeignKey(t => t.AccountId) + .OnDelete(DeleteBehavior.Cascade); + + builder.Ignore(p => p.DomainEvents); + } +} diff --git a/services/wallet-service-net/src/WalletService.Infrastructure/EntityConfigurations/PointTransactionEntityTypeConfiguration.cs b/services/wallet-service-net/src/WalletService.Infrastructure/EntityConfigurations/PointTransactionEntityTypeConfiguration.cs new file mode 100644 index 00000000..5d3b50d8 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Infrastructure/EntityConfigurations/PointTransactionEntityTypeConfiguration.cs @@ -0,0 +1,69 @@ +namespace WalletService.Infrastructure.EntityConfigurations; + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using WalletService.Domain.AggregatesModel.PointAccountAggregate; + +/// +/// EN: EF Core configuration for PointTransaction entity +/// VI: Cấu hình EF Core cho entity PointTransaction +/// +public class PointTransactionEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("point_transactions"); + + builder.HasKey(t => t.Id); + + builder.Property(t => t.Id) + .HasColumnName("id") + .ValueGeneratedNever(); + + builder.Property(t => t.AccountId) + .HasColumnName("account_id") + .IsRequired(); + + builder.HasIndex(t => t.AccountId) + .HasDatabaseName("ix_point_transactions_account_id"); + + builder.Property(t => t.Points) + .HasColumnName("points") + .IsRequired(); + + builder.Property(t => t.TypeId) + .HasColumnName("type_id") + .IsRequired(); + + builder.Ignore(t => t.Type); + + builder.Property(t => t.Source) + .HasColumnName("source") + .HasMaxLength(100) + .IsRequired(); + + builder.Property(t => t.Description) + .HasColumnName("description") + .HasMaxLength(500) + .IsRequired(); + + builder.Property(t => t.ExpiresAt) + .HasColumnName("expires_at"); + + builder.Property(t => t.BalanceAfter) + .HasColumnName("balance_after") + .IsRequired(); + + builder.Property(t => t.CreatedAt) + .HasColumnName("created_at") + .IsRequired(); + + builder.HasIndex(t => t.CreatedAt) + .HasDatabaseName("ix_point_transactions_created_at"); + + builder.HasIndex(t => t.ExpiresAt) + .HasDatabaseName("ix_point_transactions_expires_at"); + + builder.Ignore(t => t.DomainEvents); + } +} diff --git a/services/wallet-service-net/src/WalletService.Infrastructure/EntityConfigurations/WalletEntityTypeConfiguration.cs b/services/wallet-service-net/src/WalletService.Infrastructure/EntityConfigurations/WalletEntityTypeConfiguration.cs new file mode 100644 index 00000000..5027d492 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Infrastructure/EntityConfigurations/WalletEntityTypeConfiguration.cs @@ -0,0 +1,69 @@ +namespace WalletService.Infrastructure.EntityConfigurations; + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using WalletService.Domain.AggregatesModel.WalletAggregate; + +/// +/// EN: EF Core configuration for Wallet aggregate +/// VI: Cấu hình EF Core cho aggregate Wallet +/// +public class WalletEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("wallets"); + + builder.HasKey(w => w.Id); + + builder.Property(w => w.Id) + .HasColumnName("id") + .ValueGeneratedNever(); + + builder.Property(w => w.UserId) + .HasColumnName("user_id") + .IsRequired(); + + builder.HasIndex(w => w.UserId) + .IsUnique() + .HasDatabaseName("ix_wallets_user_id"); + + // EN: Configure Money value object + // VI: Cấu hình value object Money + builder.OwnsOne(w => w.Balance, balance => + { + balance.Property(m => m.Amount) + .HasColumnName("balance") + .HasColumnType("decimal(18,2)") + .IsRequired(); + + balance.Property(m => m.Currency) + .HasColumnName("currency") + .HasMaxLength(3) + .IsRequired(); + }); + + builder.Property(w => w.StatusId) + .HasColumnName("status_id") + .IsRequired(); + + builder.Ignore(w => w.Status); + + builder.Property(w => w.CreatedAt) + .HasColumnName("created_at") + .IsRequired(); + + builder.Property(w => w.UpdatedAt) + .HasColumnName("updated_at") + .IsRequired(); + + // EN: Configure navigation to transactions + // VI: Cấu hình navigation đến transactions + builder.HasMany(w => w.Transactions) + .WithOne() + .HasForeignKey(t => t.WalletId) + .OnDelete(DeleteBehavior.Cascade); + + builder.Ignore(w => w.DomainEvents); + } +} diff --git a/services/wallet-service-net/src/WalletService.Infrastructure/EntityConfigurations/WalletTransactionEntityTypeConfiguration.cs b/services/wallet-service-net/src/WalletService.Infrastructure/EntityConfigurations/WalletTransactionEntityTypeConfiguration.cs new file mode 100644 index 00000000..8ad082c7 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Infrastructure/EntityConfigurations/WalletTransactionEntityTypeConfiguration.cs @@ -0,0 +1,74 @@ +namespace WalletService.Infrastructure.EntityConfigurations; + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using WalletService.Domain.AggregatesModel.WalletAggregate; + +/// +/// EN: EF Core configuration for WalletTransaction entity +/// VI: Cấu hình EF Core cho entity WalletTransaction +/// +public class WalletTransactionEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("wallet_transactions"); + + builder.HasKey(t => t.Id); + + builder.Property(t => t.Id) + .HasColumnName("id") + .ValueGeneratedNever(); + + builder.Property(t => t.WalletId) + .HasColumnName("wallet_id") + .IsRequired(); + + builder.HasIndex(t => t.WalletId) + .HasDatabaseName("ix_wallet_transactions_wallet_id"); + + // EN: Configure Money value object for Amount + // VI: Cấu hình value object Money cho Amount + builder.OwnsOne(t => t.Amount, amount => + { + amount.Property(m => m.Amount) + .HasColumnName("amount") + .HasColumnType("decimal(18,2)") + .IsRequired(); + + amount.Property(m => m.Currency) + .HasColumnName("currency") + .HasMaxLength(3) + .IsRequired(); + }); + + builder.Property(t => t.TypeId) + .HasColumnName("type_id") + .IsRequired(); + + builder.Ignore(t => t.Type); + + builder.Property(t => t.ReferenceId) + .HasColumnName("reference_id") + .HasMaxLength(100); + + builder.Property(t => t.Description) + .HasColumnName("description") + .HasMaxLength(500) + .IsRequired(); + + builder.Property(t => t.BalanceAfter) + .HasColumnName("balance_after") + .HasColumnType("decimal(18,2)") + .IsRequired(); + + builder.Property(t => t.CreatedAt) + .HasColumnName("created_at") + .IsRequired(); + + builder.HasIndex(t => t.CreatedAt) + .HasDatabaseName("ix_wallet_transactions_created_at"); + + builder.Ignore(t => t.DomainEvents); + } +} diff --git a/services/wallet-service-net/src/WalletService.Infrastructure/Idempotency/ClientRequest.cs b/services/wallet-service-net/src/WalletService.Infrastructure/Idempotency/ClientRequest.cs new file mode 100644 index 00000000..d56160c1 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Infrastructure/Idempotency/ClientRequest.cs @@ -0,0 +1,26 @@ +namespace WalletService.Infrastructure.Idempotency; + +/// +/// EN: Entity for tracking client requests to ensure idempotency. +/// VI: Entity để theo dõi các requests từ client đảm bảo idempotency. +/// +public class ClientRequest +{ + /// + /// EN: Unique request identifier. + /// VI: Định danh request duy nhất. + /// + public Guid Id { get; set; } + + /// + /// EN: Name of the command/request type. + /// VI: Tên của loại command/request. + /// + public string Name { get; set; } = null!; + + /// + /// EN: Timestamp when the request was received. + /// VI: Thời điểm request được nhận. + /// + public DateTime Time { get; set; } +} diff --git a/services/wallet-service-net/src/WalletService.Infrastructure/Idempotency/IRequestManager.cs b/services/wallet-service-net/src/WalletService.Infrastructure/Idempotency/IRequestManager.cs new file mode 100644 index 00000000..1a710c1e --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Infrastructure/Idempotency/IRequestManager.cs @@ -0,0 +1,24 @@ +namespace WalletService.Infrastructure.Idempotency; + +/// +/// EN: Interface for managing client request idempotency. +/// VI: Interface để quản lý idempotency của client requests. +/// +public interface IRequestManager +{ + /// + /// EN: Check if a request with the given ID exists. + /// VI: Kiểm tra xem request với ID cho trước có tồn tại không. + /// + /// EN: Request ID / VI: ID của request + /// EN: True if exists / VI: True nếu tồn tại + Task ExistAsync(Guid id); + + /// + /// EN: Create a new request record for tracking. + /// VI: Tạo bản ghi request mới để theo dõi. + /// + /// EN: Command type / VI: Loại command + /// EN: Request ID / VI: ID của request + Task CreateRequestForCommandAsync(Guid id); +} diff --git a/services/wallet-service-net/src/WalletService.Infrastructure/Idempotency/RequestManager.cs b/services/wallet-service-net/src/WalletService.Infrastructure/Idempotency/RequestManager.cs new file mode 100644 index 00000000..558f9a51 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Infrastructure/Idempotency/RequestManager.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore; + +namespace WalletService.Infrastructure.Idempotency; + +/// +/// EN: Implementation of request manager for idempotency. +/// VI: Triển khai request manager cho idempotency. +/// +public class RequestManager : IRequestManager +{ + private readonly WalletServiceContext _context; + + public RequestManager(WalletServiceContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + /// + public async Task ExistAsync(Guid id) + { + var request = await _context + .FindAsync(id); + + return request != null; + } + + /// + public async Task CreateRequestForCommandAsync(Guid id) + { + var exists = await ExistAsync(id); + + var request = exists + ? throw new InvalidOperationException($"Request with {id} already exists") + : new ClientRequest + { + Id = id, + Name = typeof(T).Name, + Time = DateTime.UtcNow + }; + + _context.Add(request); + + await _context.SaveChangesAsync(); + } +} diff --git a/services/wallet-service-net/src/WalletService.Infrastructure/Repositories/PointAccountRepository.cs b/services/wallet-service-net/src/WalletService.Infrastructure/Repositories/PointAccountRepository.cs new file mode 100644 index 00000000..29d0ce7b --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Infrastructure/Repositories/PointAccountRepository.cs @@ -0,0 +1,82 @@ +namespace WalletService.Infrastructure.Repositories; + +using Microsoft.EntityFrameworkCore; +using WalletService.Domain.AggregatesModel.PointAccountAggregate; +using WalletService.Domain.SeedWork; + +/// +/// EN: Repository implementation for PointAccount aggregate +/// VI: Repository implementation cho aggregate PointAccount +/// +public class PointAccountRepository : IPointAccountRepository +{ + private readonly WalletServiceContext _context; + + public IUnitOfWork UnitOfWork => _context; + + public PointAccountRepository(WalletServiceContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + public async Task GetByUserIdAsync(Guid userId) + { + return await _context.PointAccounts + .FirstOrDefaultAsync(p => p.UserId == userId); + } + + public async Task GetByIdWithTransactionsAsync(Guid accountId) + { + return await _context.PointAccounts + .Include(p => p.Transactions) + .FirstOrDefaultAsync(p => p.Id == accountId); + } + + public async Task> GetTransactionsAsync( + Guid accountId, + int page = 1, + int pageSize = 20) + { + return await _context.PointTransactions + .Where(t => t.AccountId == accountId) + .OrderByDescending(t => t.CreatedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + } + + public async Task CountTransactionsAsync(Guid accountId) + { + return await _context.PointTransactions + .CountAsync(t => t.AccountId == accountId); + } + + public async Task ExistsByUserIdAsync(Guid userId) + { + return await _context.PointAccounts + .AnyAsync(p => p.UserId == userId); + } + + public async Task> GetAccountsWithExpiringPointsAsync(DateTime expiryDate) + { + var accountIds = await _context.PointTransactions + .Where(t => t.ExpiresAt != null && t.ExpiresAt <= expiryDate) + .Select(t => t.AccountId) + .Distinct() + .ToListAsync(); + + return await _context.PointAccounts + .Where(p => accountIds.Contains(p.Id)) + .ToListAsync(); + } + + public PointAccount Add(PointAccount account) + { + return _context.PointAccounts.Add(account).Entity; + } + + public void Update(PointAccount account) + { + _context.Entry(account).State = EntityState.Modified; + } +} diff --git a/services/wallet-service-net/src/WalletService.Infrastructure/Repositories/WalletRepository.cs b/services/wallet-service-net/src/WalletService.Infrastructure/Repositories/WalletRepository.cs new file mode 100644 index 00000000..c6f34fba --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Infrastructure/Repositories/WalletRepository.cs @@ -0,0 +1,69 @@ +namespace WalletService.Infrastructure.Repositories; + +using Microsoft.EntityFrameworkCore; +using WalletService.Domain.AggregatesModel.WalletAggregate; +using WalletService.Domain.SeedWork; + +/// +/// EN: Repository implementation for Wallet aggregate +/// VI: Repository implementation cho aggregate Wallet +/// +public class WalletRepository : IWalletRepository +{ + private readonly WalletServiceContext _context; + + public IUnitOfWork UnitOfWork => _context; + + public WalletRepository(WalletServiceContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + public async Task GetByUserIdAsync(Guid userId) + { + return await _context.Wallets + .FirstOrDefaultAsync(w => w.UserId == userId); + } + + public async Task GetByIdWithTransactionsAsync(Guid walletId) + { + return await _context.Wallets + .Include(w => w.Transactions) + .FirstOrDefaultAsync(w => w.Id == walletId); + } + + public async Task> GetTransactionsAsync( + Guid walletId, + int page = 1, + int pageSize = 20) + { + return await _context.WalletTransactions + .Where(t => t.WalletId == walletId) + .OrderByDescending(t => t.CreatedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + } + + public async Task CountTransactionsAsync(Guid walletId) + { + return await _context.WalletTransactions + .CountAsync(t => t.WalletId == walletId); + } + + public async Task ExistsByUserIdAsync(Guid userId) + { + return await _context.Wallets + .AnyAsync(w => w.UserId == userId); + } + + public Wallet Add(Wallet wallet) + { + return _context.Wallets.Add(wallet).Entity; + } + + public void Update(Wallet wallet) + { + _context.Entry(wallet).State = EntityState.Modified; + } +} diff --git a/services/wallet-service-net/src/WalletService.Infrastructure/WalletService.Infrastructure.csproj b/services/wallet-service-net/src/WalletService.Infrastructure/WalletService.Infrastructure.csproj new file mode 100644 index 00000000..1291810c --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Infrastructure/WalletService.Infrastructure.csproj @@ -0,0 +1,36 @@ + + + + WalletService.Infrastructure + WalletService.Infrastructure + Infrastructure layer for data access and external services + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + + + diff --git a/services/wallet-service-net/src/WalletService.Infrastructure/WalletServiceContext.cs b/services/wallet-service-net/src/WalletService.Infrastructure/WalletServiceContext.cs new file mode 100644 index 00000000..9840d833 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Infrastructure/WalletServiceContext.cs @@ -0,0 +1,135 @@ +namespace WalletService.Infrastructure; + +using MediatR; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; +using WalletService.Domain.AggregatesModel.PointAccountAggregate; +using WalletService.Domain.AggregatesModel.WalletAggregate; +using WalletService.Domain.SeedWork; + +/// +/// EN: Database context for Wallet Service with Unit of Work pattern +/// VI: Database context cho Wallet Service với pattern Unit of Work +/// +public class WalletServiceContext : DbContext, IUnitOfWork +{ + private readonly IMediator _mediator; + private IDbContextTransaction? _currentTransaction; + + public DbSet Wallets { get; set; } = null!; + public DbSet WalletTransactions { get; set; } = null!; + public DbSet PointAccounts { get; set; } = null!; + public DbSet PointTransactions { get; set; } = null!; + + public bool HasActiveTransaction => _currentTransaction != null; + + public WalletServiceContext(DbContextOptions options, IMediator mediator) + : base(options) + { + _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // EN: Apply all entity configurations + // VI: Áp dụng tất cả các cấu hình entity + modelBuilder.ApplyConfigurationsFromAssembly(typeof(WalletServiceContext).Assembly); + } + + /// + /// EN: Save changes and dispatch domain events + /// VI: Lưu thay đổi và dispatch domain events + /// + public async Task SaveEntitiesAsync(CancellationToken cancellationToken = default) + { + // EN: Dispatch domain events after saving + // VI: Dispatch domain events sau khi lưu + await DispatchDomainEventsAsync(); + + // EN: Save changes to database + // VI: Lưu thay đổi vào database + var result = await base.SaveChangesAsync(cancellationToken); + return result > 0; + } + + /// + /// EN: Begin a new transaction + /// VI: Bắt đầu transaction mới + /// + public async Task BeginTransactionAsync() + { + if (_currentTransaction != null) return null; + + _currentTransaction = await Database.BeginTransactionAsync(); + return _currentTransaction; + } + + /// + /// EN: Commit the current transaction + /// VI: Commit transaction hiện tại + /// + public async Task CommitTransactionAsync(IDbContextTransaction transaction) + { + if (transaction == null) throw new ArgumentNullException(nameof(transaction)); + if (transaction != _currentTransaction) + throw new InvalidOperationException($"Transaction {transaction.TransactionId} is not current"); + + try + { + await SaveChangesAsync(); + await transaction.CommitAsync(); + } + catch + { + RollbackTransaction(); + throw; + } + finally + { + if (_currentTransaction != null) + { + _currentTransaction.Dispose(); + _currentTransaction = null; + } + } + } + + /// + /// EN: Rollback the current transaction + /// VI: Rollback transaction hiện tại + /// + public void RollbackTransaction() + { + try + { + _currentTransaction?.Rollback(); + } + finally + { + if (_currentTransaction != null) + { + _currentTransaction.Dispose(); + _currentTransaction = null; + } + } + } + + private async Task DispatchDomainEventsAsync() + { + var domainEntities = ChangeTracker + .Entries() + .Where(x => x.Entity.DomainEvents.Any()) + .ToList(); + + var domainEvents = domainEntities + .SelectMany(x => x.Entity.DomainEvents) + .ToList(); + + domainEntities.ForEach(entity => entity.Entity.ClearDomainEvents()); + + foreach (var domainEvent in domainEvents) + { + await _mediator.Publish(domainEvent); + } + } +} diff --git a/services/wallet-service-net/tests/WalletService.FunctionalTests/Controllers/HealthControllerTests.cs b/services/wallet-service-net/tests/WalletService.FunctionalTests/Controllers/HealthControllerTests.cs new file mode 100644 index 00000000..2c4a9661 --- /dev/null +++ b/services/wallet-service-net/tests/WalletService.FunctionalTests/Controllers/HealthControllerTests.cs @@ -0,0 +1,53 @@ +using System.Net; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using Xunit; + +namespace WalletService.FunctionalTests.Controllers; + +/// +/// EN: Functional tests for Health API endpoints. +/// VI: Functional tests cho các endpoints API Health. +/// +public class HealthControllerTests : IClassFixture +{ + private readonly HttpClient _client; + + public HealthControllerTests(CustomWebApplicationFactory factory) + { + _client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + } + + [Fact] + public async Task HealthLive_ShouldReturnOk() + { + // Act + var response = await _client.GetAsync("/health/live"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task HealthReady_ShouldReturnOk() + { + // Act + var response = await _client.GetAsync("/health/ready"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Health_ShouldReturnOk() + { + // Act + var response = await _client.GetAsync("/health"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + } +} diff --git a/services/wallet-service-net/tests/WalletService.FunctionalTests/CustomWebApplicationFactory.cs b/services/wallet-service-net/tests/WalletService.FunctionalTests/CustomWebApplicationFactory.cs new file mode 100644 index 00000000..94e39b62 --- /dev/null +++ b/services/wallet-service-net/tests/WalletService.FunctionalTests/CustomWebApplicationFactory.cs @@ -0,0 +1,110 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using WalletService.Infrastructure; + +namespace WalletService.FunctionalTests; + +/// +/// EN: Custom WebApplicationFactory for functional tests. +/// VI: WebApplicationFactory tùy chỉnh cho functional tests. +/// +public class CustomWebApplicationFactory : WebApplicationFactory +{ + private readonly string _databaseName = "TestDatabase_" + Guid.NewGuid().ToString(); + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Testing"); + + // EN: Configure services BEFORE the app configures itself + // VI: Cấu hình services TRƯỚC KHI app tự cấu hình + builder.ConfigureServices(services => + { + // EN: Remove ALL existing DbContext registrations + // VI: Xóa TẤT CẢ các đăng ký DbContext hiện có + RemoveExistingDbContextRegistrations(services); + + // EN: Remove PostgreSQL health check registrations + // VI: Xóa các đăng ký health check PostgreSQL + RemoveHealthCheckRegistrations(services); + + // EN: Add in-memory database for testing + // VI: Thêm in-memory database để test + services.AddDbContext(options => + { + options.UseInMemoryDatabase(_databaseName); + options.EnableSensitiveDataLogging(); + }); + + // EN: Add simple health checks for testing (no external dependencies) + // VI: Thêm health checks đơn giản cho testing (không có external dependencies) + services.AddHealthChecks(); + + // EN: Set logging level for debugging tests + // VI: Đặt mức logging để debug tests + services.AddLogging(logging => + { + logging.SetMinimumLevel(LogLevel.Warning); + logging.AddFilter("Microsoft.EntityFrameworkCore", LogLevel.Warning); + }); + }); + } + + private static void RemoveExistingDbContextRegistrations(IServiceCollection services) + { + // EN: Remove all DbContext-related registrations + // VI: Xóa tất cả các đăng ký liên quan đến DbContext + var descriptorsToRemove = services.Where(d => + d.ServiceType == typeof(DbContextOptions) || + d.ServiceType == typeof(WalletServiceContext) || + d.ServiceType == typeof(DbContextOptions) || + d.ServiceType.FullName?.Contains("EntityFrameworkCore") == true || + d.ImplementationType?.FullName?.Contains("Npgsql") == true) + .ToList(); + + foreach (var descriptor in descriptorsToRemove) + { + services.Remove(descriptor); + } + + services.RemoveAll(typeof(DbContextOptions)); + services.RemoveAll(typeof(DbContextOptions)); + } + + private static void RemoveHealthCheckRegistrations(IServiceCollection services) + { + // EN: Remove health check registrations that depend on external services + // VI: Xóa các health check registrations phụ thuộc vào external services + var healthCheckDescriptors = services.Where(d => + d.ServiceType == typeof(HealthCheckService) || + d.ServiceType.FullName?.Contains("HealthCheck") == true || + d.ImplementationType?.FullName?.Contains("NpgSql") == true || + d.ImplementationType?.FullName?.Contains("Npgsql") == true) + .ToList(); + + foreach (var descriptor in healthCheckDescriptors) + { + services.Remove(descriptor); + } + } + + /// + /// EN: Ensure database is created after host is built + /// VI: Đảm bảo database được tạo sau khi host được build + /// + protected override void ConfigureClient(HttpClient client) + { + base.ConfigureClient(client); + + // EN: Create the database when the first client is created + // VI: Tạo database khi client đầu tiên được tạo + using var scope = Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.EnsureCreated(); + } +} diff --git a/services/wallet-service-net/tests/WalletService.FunctionalTests/WalletService.FunctionalTests.csproj b/services/wallet-service-net/tests/WalletService.FunctionalTests/WalletService.FunctionalTests.csproj new file mode 100644 index 00000000..9d4c5d4c --- /dev/null +++ b/services/wallet-service-net/tests/WalletService.FunctionalTests/WalletService.FunctionalTests.csproj @@ -0,0 +1,38 @@ + + + + WalletService.FunctionalTests + WalletService.FunctionalTests + false + true + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/services/wallet-service-net/tests/WalletService.UnitTests/Domain/PointAccountTests.cs b/services/wallet-service-net/tests/WalletService.UnitTests/Domain/PointAccountTests.cs new file mode 100644 index 00000000..9c2bf523 --- /dev/null +++ b/services/wallet-service-net/tests/WalletService.UnitTests/Domain/PointAccountTests.cs @@ -0,0 +1,153 @@ +// EN: Unit tests for PointAccount aggregate +// VI: Unit tests cho PointAccount aggregate +using WalletService.Domain.AggregatesModel.PointAccountAggregate; +using WalletService.Domain.Exceptions; +using Xunit; + +namespace WalletService.UnitTests.Domain; + +public class PointAccountTests +{ + private static readonly Guid TestUserId = Guid.NewGuid(); + + [Fact] + public void Constructor_ShouldCreatePointAccount_WithZeroBalance() + { + // Arrange & Act + var account = new PointAccount(TestUserId); + + // Assert + Assert.NotNull(account); + Assert.Equal(TestUserId, account.UserId); + Assert.Equal(0, account.TotalPoints); + Assert.Equal(0, account.AvailablePoints); + } + + [Fact] + public void EarnPoints_ShouldIncreasePoints() + { + // Arrange + var account = new PointAccount(TestUserId); + var earnPoints = 100L; + + // Act + account.EarnPoints(earnPoints, "ORDER001", "Test earn", null); + + // Assert + Assert.Equal(earnPoints, account.TotalPoints); + Assert.Equal(earnPoints, account.AvailablePoints); + Assert.Single(account.Transactions); + Assert.Equal(PointTransactionType.Earn, account.Transactions.First().Type); + } + + [Fact] + public void EarnPoints_ShouldThrow_WhenPointsAreNegative() + { + // Arrange + var account = new PointAccount(TestUserId); + + // Act & Assert + Assert.Throws(() => + account.EarnPoints(-100, "ORDER001", "Invalid", null)); + } + + [Fact] + public void SpendPoints_ShouldDecreaseAvailablePoints() + { + // Arrange + var account = new PointAccount(TestUserId); + account.EarnPoints(100, "ORDER001", "Initial earn", null); + + // Act + account.SpendPoints(50, "REWARD001", "Test spend"); + + // Assert + Assert.Equal(100, account.TotalPoints); // Total remains same + Assert.Equal(50, account.AvailablePoints); // Available decreases + Assert.Equal(2, account.Transactions.Count); + } + + [Fact] + public void SpendPoints_ShouldThrow_WhenInsufficientPoints() + { + // Arrange + var account = new PointAccount(TestUserId); + account.EarnPoints(50, "ORDER001", "Initial earn", null); + + // Act & Assert + Assert.Throws(() => + account.SpendPoints(100, "REWARD001", "Invalid spend")); + } + + [Fact] + public void ExpirePoints_ShouldDecreaseAvailablePoints() + { + // Arrange + var account = new PointAccount(TestUserId); + account.EarnPoints(100, "ORDER001", "Initial earn", null); + + // Act + account.ExpirePoints(30, "Points expired"); + + // Assert + Assert.Equal(100, account.TotalPoints); // Total remains same + Assert.Equal(70, account.AvailablePoints); // Available decreases + } + + [Fact] + public void AddBonusPoints_ShouldIncreasePoints() + { + // Arrange + var account = new PointAccount(TestUserId); + + // Act + account.AddBonusPoints(50, "PROMO001", "Bonus points", null); + + // Assert + Assert.Equal(50, account.TotalPoints); + Assert.Equal(50, account.AvailablePoints); + Assert.Equal(PointTransactionType.Bonus, account.Transactions.First().Type); + } + + [Fact] + public void AdjustPoints_ShouldAdjustPositive() + { + // Arrange + var account = new PointAccount(TestUserId); + + // Act + account.AdjustPoints(100, "Admin adjustment"); + + // Assert + Assert.Equal(100, account.TotalPoints); + Assert.Equal(100, account.AvailablePoints); + Assert.Equal(PointTransactionType.Adjust, account.Transactions.First().Type); + } + + [Fact] + public void AdjustPoints_ShouldAdjustNegative() + { + // Arrange + var account = new PointAccount(TestUserId); + account.EarnPoints(100, "ORDER001", "Initial earn", null); + + // Act + account.AdjustPoints(-30, "Admin deduction"); + + // Assert + Assert.Equal(100, account.TotalPoints); // Total doesn't decrease + Assert.Equal(70, account.AvailablePoints); + } + + [Fact] + public void AdjustPoints_ShouldThrow_WhenInsufficientForNegativeAdjustment() + { + // Arrange + var account = new PointAccount(TestUserId); + account.EarnPoints(50, "ORDER001", "Initial earn", null); + + // Act & Assert + Assert.Throws(() => + account.AdjustPoints(-100, "Invalid deduction")); + } +} diff --git a/services/wallet-service-net/tests/WalletService.UnitTests/Domain/WalletTests.cs b/services/wallet-service-net/tests/WalletService.UnitTests/Domain/WalletTests.cs new file mode 100644 index 00000000..8011bd76 --- /dev/null +++ b/services/wallet-service-net/tests/WalletService.UnitTests/Domain/WalletTests.cs @@ -0,0 +1,141 @@ +// EN: Unit tests for Wallet aggregate +// VI: Unit tests cho Wallet aggregate +using WalletService.Domain.AggregatesModel.WalletAggregate; +using WalletService.Domain.Exceptions; +using Xunit; + +namespace WalletService.UnitTests.Domain; + +public class WalletTests +{ + private static readonly Guid TestUserId = Guid.NewGuid(); + private const string TestCurrency = "VND"; + + [Fact] + public void Constructor_ShouldCreateWallet_WithZeroBalance() + { + // Arrange & Act + var wallet = new Wallet(TestUserId, TestCurrency); + + // Assert + Assert.NotNull(wallet); + Assert.Equal(TestUserId, wallet.UserId); + Assert.Equal(TestCurrency, wallet.Balance.Currency); + Assert.Equal(0, wallet.Balance.Amount); + Assert.Equal(WalletStatus.Active, wallet.Status); + } + + [Fact] + public void Constructor_ShouldDefaultToVND_WhenNoCurrencyProvided() + { + // Arrange & Act + var wallet = new Wallet(TestUserId); + + // Assert + Assert.Equal("VND", wallet.Balance.Currency); + } + + [Fact] + public void Deposit_ShouldIncreaseBalance() + { + // Arrange + var wallet = new Wallet(TestUserId, TestCurrency); + var depositAmount = new Money(100000m, TestCurrency); + + // Act + wallet.Deposit(depositAmount, "Test deposit", "REF001"); + + // Assert + Assert.Equal(100000m, wallet.Balance.Amount); + Assert.Single(wallet.Transactions); + Assert.Equal(TransactionType.Credit, wallet.Transactions.First().Type); + } + + [Fact] + public void CreateMoney_ShouldThrow_WhenAmountIsNegative() + { + // Arrange & Act & Assert + // EN: Money constructor throws ArgumentException for negative amounts + // VI: Money constructor ném ArgumentException cho số tiền âm + Assert.Throws(() => + new Money(-100, TestCurrency)); + } + + [Fact] + public void Withdraw_ShouldDecreaseBalance() + { + // Arrange + var wallet = new Wallet(TestUserId, TestCurrency); + wallet.Deposit(new Money(100000m, TestCurrency), "Initial deposit", "REF001"); + + // Act + wallet.Withdraw(new Money(50000m, TestCurrency), "Test withdrawal", "REF002"); + + // Assert + Assert.Equal(50000m, wallet.Balance.Amount); + Assert.Equal(2, wallet.Transactions.Count); + } + + [Fact] + public void Withdraw_ShouldThrow_WhenInsufficientBalance() + { + // Arrange + var wallet = new Wallet(TestUserId, TestCurrency); + wallet.Deposit(new Money(50000m, TestCurrency), "Initial deposit", "REF001"); + + // Act & Assert + Assert.Throws(() => + wallet.Withdraw(new Money(100000m, TestCurrency), "Invalid withdrawal")); + } + + [Fact] + public void Freeze_ShouldSetStatusToFrozen() + { + // Arrange + var wallet = new Wallet(TestUserId, TestCurrency); + + // Act + wallet.Freeze(); + + // Assert + Assert.Equal(WalletStatus.Frozen, wallet.Status); + } + + [Fact] + public void Unfreeze_ShouldSetStatusToActive() + { + // Arrange + var wallet = new Wallet(TestUserId, TestCurrency); + wallet.Freeze(); + + // Act + wallet.Unfreeze(); + + // Assert + Assert.Equal(WalletStatus.Active, wallet.Status); + } + + [Fact] + public void Close_ShouldSetStatusToClosed() + { + // Arrange + var wallet = new Wallet(TestUserId, TestCurrency); + + // Act + wallet.Close(); + + // Assert + Assert.Equal(WalletStatus.Closed, wallet.Status); + } + + [Fact] + public void Close_ShouldThrow_WhenBalanceIsPositive() + { + // Arrange + var wallet = new Wallet(TestUserId, TestCurrency); + wallet.Deposit(new Money(100000m, TestCurrency), "Deposit", "REF001"); + + // Act & Assert + Assert.Throws(() => wallet.Close()); + } +} diff --git a/services/wallet-service-net/tests/WalletService.UnitTests/WalletService.UnitTests.csproj b/services/wallet-service-net/tests/WalletService.UnitTests/WalletService.UnitTests.csproj new file mode 100644 index 00000000..e486846f --- /dev/null +++ b/services/wallet-service-net/tests/WalletService.UnitTests/WalletService.UnitTests.csproj @@ -0,0 +1,35 @@ + + + + WalletService.UnitTests + WalletService.UnitTests + false + true + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + +