From 2d731dbdb6f76d1956e96e871ece72281e0582b5 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Mon, 19 Jan 2026 01:14:46 +0700 Subject: [PATCH] feat: Remove sample-related code from Facebook and Zalo services while introducing new AI, chatbot, and conversation management features across Facebook, WhatsApp, and X services. --- .../Commands/ChangeSampleStatusCommand.cs | 14 - .../ChangeSampleStatusCommandHandler.cs | 70 --- .../Commands/CreateChatbotFlowCommand.cs | 25 ++ .../CreateChatbotFlowCommandHandler.cs | 77 ++++ .../Commands/CreateCustomerCommand.cs | 33 ++ .../Commands/CreateCustomerCommandHandler.cs | 79 ++++ .../Commands/CreateSampleCommand.cs | 21 - .../Commands/CreateSampleCommandHandler.cs | 46 -- .../Commands/DeleteSampleCommand.cs | 10 - .../Commands/DeleteSampleCommandHandler.cs | 54 --- .../Commands/ProcessIncomingMessageCommand.cs | 35 ++ .../Commands/ProcessIncomingMessageHandler.cs | 98 +++++ .../Commands/UpdateCustomerCommand.cs | 25 ++ .../Commands/UpdateCustomerCommandHandler.cs | 72 +++ .../Commands/UpdateSampleCommand.cs | 16 - .../Commands/UpdateSampleCommandHandler.cs | 54 --- .../Commands/UpsertAIChatbotConfigCommand.cs | 29 ++ .../UpsertAIChatbotConfigCommandHandler.cs | 101 +++++ .../Application/Dtos/ChatbotDtos.cs | 60 +++ .../Application/Dtos/ConversationDtos.cs | 48 ++ .../Application/Dtos/CustomerDtos.cs | 33 ++ .../Queries/AIChatbotConfigQueries.cs | 11 + .../Queries/AIChatbotConfigQueryHandlers.cs | 44 ++ .../Application/Queries/ChatbotFlowQueries.cs | 22 + .../Queries/ChatbotFlowQueryHandlers.cs | 97 ++++ .../Queries/ConversationQueries.cs | 39 ++ .../Queries/ConversationQueryHandlers.cs | 78 ++++ .../Application/Queries/CustomerQueries.cs | 44 ++ .../Queries/CustomerQueryHandlers.cs | 92 ++++ .../Application/Queries/GetSampleQuery.cs | 23 - .../Queries/GetSampleQueryHandler.cs | 39 -- .../Application/Queries/GetSamplesQuery.cs | 9 - .../Queries/GetSamplesQueryHandler.cs | 34 -- .../Validations/ChatbotValidators.cs | 104 +++++ .../CreateCustomerCommandValidator.cs | 37 ++ .../CreateSampleCommandValidator.cs | 25 -- .../UpdateSampleCommandValidator.cs | 29 -- .../Controllers/ChatbotsController.cs | 183 ++++++++ .../Controllers/ConversationsController.cs | 71 +++ .../Controllers/CustomersController.cs | 143 ++++++ .../Controllers/SamplesController.cs | 200 --------- .../Controllers/WebhooksController.cs | 229 ++++++++++ .../SampleAggregate/ISampleRepository.cs | 61 --- .../AggregatesModel/SampleAggregate/Sample.cs | 158 ------- .../SampleAggregate/SampleStatus.cs | 77 ---- .../Events/SampleCreatedDomainEvent.cs | 22 - .../Events/SampleStatusChangedDomainEvent.cs | 39 -- .../Exceptions/SampleDomainException.cs | 21 - .../DependencyInjection.cs | 33 ++ .../SampleEntityTypeConfiguration.cs | 61 --- .../SampleStatusEntityTypeConfiguration.cs | 39 -- .../FacebookMessengerClient.cs | 272 ++++++++++++ .../ExternalServices/IAIClient.cs | 51 +++ .../IFacebookMessengerClient.cs | 113 +++++ .../ExternalServices/OpenAIClients.cs | 223 ++++++++++ .../CreateSampleCommandHandlerTests.cs | 65 --- .../Domain/SampleAggregateTests.cs | 151 ------- .../Commands/ConnectWhatsAppAccountCommand.cs | 23 + .../ConnectWhatsAppAccountCommandHandler.cs | 68 +++ .../Commands/ProcessIncomingMessageCommand.cs | 30 ++ .../ProcessIncomingMessageCommandHandler.cs | 98 +++++ .../Commands/SendMessageCommand.cs | 26 ++ .../Commands/SendMessageCommandHandler.cs | 120 +++++ .../Queries/GetConversationsQuery.cs | 36 ++ .../AutomationTriggerHandler.cs | 61 +++ .../BackgroundJobs/ConversationExpiryJob.cs | 67 +++ .../Controllers/ConversationsController.cs | 170 +++++++ .../Controllers/CustomersController.cs | 173 ++++++++ .../Controllers/WebhooksController.cs | 188 ++++++++ .../Controllers/WhatsAppAccountsController.cs | 113 +++++ .../AIAgentEntityTypeConfiguration.cs | 50 +++ .../AutomationFlowEntityTypeConfiguration.cs | 82 ++++ .../ConversationEntityTypeConfiguration.cs | 99 +++++ .../CustomerEntityTypeConfiguration.cs | 51 +++ .../WhatsAppAccountEntityTypeConfiguration.cs | 57 +++ .../ExternalServices/ILlmService.cs | 64 +++ .../IWhatsAppCloudApiClient.cs | 85 ++++ .../ExternalServices/OpenAILlmService.cs | 152 +++++++ .../WhatsAppCloudApiClient.cs | 238 ++++++++++ .../MyServiceContext.cs | 49 ++- .../Repositories/AIAgentRepository.cs | 54 +++ .../Repositories/AutomationFlowRepository.cs | 65 +++ .../Repositories/ConversationRepository.cs | 81 ++++ .../Repositories/CustomerRepository.cs | 65 +++ .../Repositories/WhatsAppAccountRepository.cs | 53 +++ .../WhatsAppService.Infrastructure.csproj | 1 + ...versationSessionEntityTypeConfiguration.cs | 97 ++++ .../AutomationFlowEntityTypeConfiguration.cs | 218 +++++++++ .../CampaignEntityTypeConfiguration.cs | 173 ++++++++ .../ContactEntityTypeConfiguration.cs | 148 +++++++ .../ConversationEntityTypeConfiguration.cs | 194 ++++++++ .../SegmentEntityTypeConfiguration.cs | 96 ++++ .../TemplateEntityTypeConfiguration.cs | 79 ++++ .../TwitterAccountEntityTypeConfiguration.cs | 160 +++++++ .../OpenAI/IAIServiceClient.cs | 175 ++++++++ .../OpenAI/OpenAIServiceClient.cs | 399 +++++++++++++++++ .../Twitter/ITwitterApiClient.cs | 183 ++++++++ .../Twitter/TwitterApiClient.cs | 415 ++++++++++++++++++ ...erviceContext.cs => MktXServiceContext.cs} | 81 +++- .../AIConversationSessionRepository.cs | 50 +++ .../Repositories/AutomationFlowRepository.cs | 88 ++++ .../Repositories/CampaignRepository.cs | 102 +++++ .../Repositories/ContactRepository.cs | 99 +++++ .../Repositories/ConversationRepository.cs | 96 ++++ .../Repositories/SegmentRepository.cs | 65 +++ .../Repositories/TemplateRepository.cs | 68 +++ .../Repositories/TwitterAccountRepository.cs | 63 +++ services/mkt-zalo-service-net/.env.example | 9 + .../Commands/ChangeSampleStatusCommand.cs | 14 - .../ChangeSampleStatusCommandHandler.cs | 70 --- .../Commands/CreateSampleCommand.cs | 21 - .../Commands/CreateSampleCommandHandler.cs | 46 -- .../Commands/DeleteSampleCommand.cs | 10 - .../Commands/DeleteSampleCommandHandler.cs | 54 --- .../Commands/UpdateSampleCommand.cs | 16 - .../Commands/UpdateSampleCommandHandler.cs | 54 --- .../Application/Queries/GetSampleQuery.cs | 23 - .../Queries/GetSampleQueryHandler.cs | 39 -- .../Application/Queries/GetSamplesQuery.cs | 9 - .../Queries/GetSamplesQueryHandler.cs | 34 -- .../CreateSampleCommandValidator.cs | 25 -- .../UpdateSampleCommandValidator.cs | 29 -- .../Controllers/SamplesController.cs | 200 --------- .../MktZaloService.API/MyService.API.csproj | 10 +- .../Caching/ChatbotRuleCacheService.cs | 99 +++++ .../Caching/ConversationCacheService.cs | 199 +++++++++ .../DependencyInjection.cs | 86 +++- .../ChatbotRuleEntityTypeConfiguration.cs | 121 +++++ .../ConversationEntityTypeConfiguration.cs | 109 +++++ .../MessageTemplateEntityTypeConfiguration.cs | 99 +++++ .../SampleEntityTypeConfiguration.cs | 61 --- .../SampleStatusEntityTypeConfiguration.cs | 39 -- .../ZaloCustomerEntityTypeConfiguration.cs | 118 +++++ ...iceContext.cs => MktZaloServiceContext.cs} | 65 ++- .../MyService.Infrastructure.csproj | 3 +- .../Repositories/ChatbotRuleRepository.cs | 86 ++++ .../Repositories/ConversationRepository.cs | 92 ++++ .../Repositories/CustomerRepository.cs | 89 ++++ .../Repositories/MessageTemplateRepository.cs | 89 ++++ .../Repositories/SampleRepository.cs | 72 --- .../SeedData/DbSeeder.cs | 176 ++++++++ .../Zalo/Models/ZaloModels.cs | 174 ++++++++ .../Zalo/ZaloOAOptions.cs | 52 +++ .../Zalo/ZaloOfficialAccountClient.cs | 191 ++++++++ .../Zalo/ZaloWebhookVerifier.cs | 59 +++ 145 files changed, 9966 insertions(+), 2183 deletions(-) delete mode 100644 services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/ChangeSampleStatusCommand.cs delete mode 100644 services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/ChangeSampleStatusCommandHandler.cs create mode 100644 services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/CreateChatbotFlowCommand.cs create mode 100644 services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/CreateChatbotFlowCommandHandler.cs create mode 100644 services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/CreateCustomerCommand.cs create mode 100644 services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/CreateCustomerCommandHandler.cs delete mode 100644 services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/CreateSampleCommand.cs delete mode 100644 services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/CreateSampleCommandHandler.cs delete mode 100644 services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/DeleteSampleCommand.cs delete mode 100644 services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/DeleteSampleCommandHandler.cs create mode 100644 services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/ProcessIncomingMessageCommand.cs create mode 100644 services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/ProcessIncomingMessageHandler.cs create mode 100644 services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/UpdateCustomerCommand.cs create mode 100644 services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/UpdateCustomerCommandHandler.cs delete mode 100644 services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/UpdateSampleCommand.cs delete mode 100644 services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/UpdateSampleCommandHandler.cs create mode 100644 services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/UpsertAIChatbotConfigCommand.cs create mode 100644 services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/UpsertAIChatbotConfigCommandHandler.cs create mode 100644 services/mkt-facebook-service-net/src/FacebookService.API/Application/Dtos/ChatbotDtos.cs create mode 100644 services/mkt-facebook-service-net/src/FacebookService.API/Application/Dtos/ConversationDtos.cs create mode 100644 services/mkt-facebook-service-net/src/FacebookService.API/Application/Dtos/CustomerDtos.cs create mode 100644 services/mkt-facebook-service-net/src/FacebookService.API/Application/Queries/AIChatbotConfigQueries.cs create mode 100644 services/mkt-facebook-service-net/src/FacebookService.API/Application/Queries/AIChatbotConfigQueryHandlers.cs create mode 100644 services/mkt-facebook-service-net/src/FacebookService.API/Application/Queries/ChatbotFlowQueries.cs create mode 100644 services/mkt-facebook-service-net/src/FacebookService.API/Application/Queries/ChatbotFlowQueryHandlers.cs create mode 100644 services/mkt-facebook-service-net/src/FacebookService.API/Application/Queries/ConversationQueries.cs create mode 100644 services/mkt-facebook-service-net/src/FacebookService.API/Application/Queries/ConversationQueryHandlers.cs create mode 100644 services/mkt-facebook-service-net/src/FacebookService.API/Application/Queries/CustomerQueries.cs create mode 100644 services/mkt-facebook-service-net/src/FacebookService.API/Application/Queries/CustomerQueryHandlers.cs delete mode 100644 services/mkt-facebook-service-net/src/FacebookService.API/Application/Queries/GetSampleQuery.cs delete mode 100644 services/mkt-facebook-service-net/src/FacebookService.API/Application/Queries/GetSampleQueryHandler.cs delete mode 100644 services/mkt-facebook-service-net/src/FacebookService.API/Application/Queries/GetSamplesQuery.cs delete mode 100644 services/mkt-facebook-service-net/src/FacebookService.API/Application/Queries/GetSamplesQueryHandler.cs create mode 100644 services/mkt-facebook-service-net/src/FacebookService.API/Application/Validations/ChatbotValidators.cs create mode 100644 services/mkt-facebook-service-net/src/FacebookService.API/Application/Validations/CreateCustomerCommandValidator.cs delete mode 100644 services/mkt-facebook-service-net/src/FacebookService.API/Application/Validations/CreateSampleCommandValidator.cs delete mode 100644 services/mkt-facebook-service-net/src/FacebookService.API/Application/Validations/UpdateSampleCommandValidator.cs create mode 100644 services/mkt-facebook-service-net/src/FacebookService.API/Controllers/ChatbotsController.cs create mode 100644 services/mkt-facebook-service-net/src/FacebookService.API/Controllers/ConversationsController.cs create mode 100644 services/mkt-facebook-service-net/src/FacebookService.API/Controllers/CustomersController.cs delete mode 100644 services/mkt-facebook-service-net/src/FacebookService.API/Controllers/SamplesController.cs create mode 100644 services/mkt-facebook-service-net/src/FacebookService.API/Controllers/WebhooksController.cs delete mode 100644 services/mkt-facebook-service-net/src/FacebookService.Domain/AggregatesModel/SampleAggregate/ISampleRepository.cs delete mode 100644 services/mkt-facebook-service-net/src/FacebookService.Domain/AggregatesModel/SampleAggregate/Sample.cs delete mode 100644 services/mkt-facebook-service-net/src/FacebookService.Domain/AggregatesModel/SampleAggregate/SampleStatus.cs delete mode 100644 services/mkt-facebook-service-net/src/FacebookService.Domain/Events/SampleCreatedDomainEvent.cs delete mode 100644 services/mkt-facebook-service-net/src/FacebookService.Domain/Events/SampleStatusChangedDomainEvent.cs delete mode 100644 services/mkt-facebook-service-net/src/FacebookService.Domain/Exceptions/SampleDomainException.cs delete mode 100644 services/mkt-facebook-service-net/src/FacebookService.Infrastructure/EntityConfigurations/SampleEntityTypeConfiguration.cs delete mode 100644 services/mkt-facebook-service-net/src/FacebookService.Infrastructure/EntityConfigurations/SampleStatusEntityTypeConfiguration.cs create mode 100644 services/mkt-facebook-service-net/src/FacebookService.Infrastructure/ExternalServices/FacebookMessengerClient.cs create mode 100644 services/mkt-facebook-service-net/src/FacebookService.Infrastructure/ExternalServices/IAIClient.cs create mode 100644 services/mkt-facebook-service-net/src/FacebookService.Infrastructure/ExternalServices/IFacebookMessengerClient.cs create mode 100644 services/mkt-facebook-service-net/src/FacebookService.Infrastructure/ExternalServices/OpenAIClients.cs delete mode 100644 services/mkt-facebook-service-net/tests/FacebookService.UnitTests/Application/CreateSampleCommandHandlerTests.cs delete mode 100644 services/mkt-facebook-service-net/tests/FacebookService.UnitTests/Domain/SampleAggregateTests.cs create mode 100644 services/mkt-whatsapp-service-net/src/WhatsAppService.API/Application/Commands/ConnectWhatsAppAccountCommand.cs create mode 100644 services/mkt-whatsapp-service-net/src/WhatsAppService.API/Application/Commands/ConnectWhatsAppAccountCommandHandler.cs create mode 100644 services/mkt-whatsapp-service-net/src/WhatsAppService.API/Application/Commands/ProcessIncomingMessageCommand.cs create mode 100644 services/mkt-whatsapp-service-net/src/WhatsAppService.API/Application/Commands/ProcessIncomingMessageCommandHandler.cs create mode 100644 services/mkt-whatsapp-service-net/src/WhatsAppService.API/Application/Commands/SendMessageCommand.cs create mode 100644 services/mkt-whatsapp-service-net/src/WhatsAppService.API/Application/Commands/SendMessageCommandHandler.cs create mode 100644 services/mkt-whatsapp-service-net/src/WhatsAppService.API/Application/Queries/GetConversationsQuery.cs create mode 100644 services/mkt-whatsapp-service-net/src/WhatsAppService.API/BackgroundJobs/AutomationTriggerHandler.cs create mode 100644 services/mkt-whatsapp-service-net/src/WhatsAppService.API/BackgroundJobs/ConversationExpiryJob.cs create mode 100644 services/mkt-whatsapp-service-net/src/WhatsAppService.API/Controllers/ConversationsController.cs create mode 100644 services/mkt-whatsapp-service-net/src/WhatsAppService.API/Controllers/CustomersController.cs create mode 100644 services/mkt-whatsapp-service-net/src/WhatsAppService.API/Controllers/WebhooksController.cs create mode 100644 services/mkt-whatsapp-service-net/src/WhatsAppService.API/Controllers/WhatsAppAccountsController.cs create mode 100644 services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/EntityConfigurations/AIAgentEntityTypeConfiguration.cs create mode 100644 services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/EntityConfigurations/AutomationFlowEntityTypeConfiguration.cs create mode 100644 services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/EntityConfigurations/ConversationEntityTypeConfiguration.cs create mode 100644 services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/EntityConfigurations/CustomerEntityTypeConfiguration.cs create mode 100644 services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/EntityConfigurations/WhatsAppAccountEntityTypeConfiguration.cs create mode 100644 services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/ExternalServices/ILlmService.cs create mode 100644 services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/ExternalServices/IWhatsAppCloudApiClient.cs create mode 100644 services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/ExternalServices/OpenAILlmService.cs create mode 100644 services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/ExternalServices/WhatsAppCloudApiClient.cs create mode 100644 services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/Repositories/AIAgentRepository.cs create mode 100644 services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/Repositories/AutomationFlowRepository.cs create mode 100644 services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/Repositories/ConversationRepository.cs create mode 100644 services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/Repositories/CustomerRepository.cs create mode 100644 services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/Repositories/WhatsAppAccountRepository.cs create mode 100644 services/mkt-x-service-net/src/MktXService.Infrastructure/EntityConfigurations/AIConversationSessionEntityTypeConfiguration.cs create mode 100644 services/mkt-x-service-net/src/MktXService.Infrastructure/EntityConfigurations/AutomationFlowEntityTypeConfiguration.cs create mode 100644 services/mkt-x-service-net/src/MktXService.Infrastructure/EntityConfigurations/CampaignEntityTypeConfiguration.cs create mode 100644 services/mkt-x-service-net/src/MktXService.Infrastructure/EntityConfigurations/ContactEntityTypeConfiguration.cs create mode 100644 services/mkt-x-service-net/src/MktXService.Infrastructure/EntityConfigurations/ConversationEntityTypeConfiguration.cs create mode 100644 services/mkt-x-service-net/src/MktXService.Infrastructure/EntityConfigurations/SegmentEntityTypeConfiguration.cs create mode 100644 services/mkt-x-service-net/src/MktXService.Infrastructure/EntityConfigurations/TemplateEntityTypeConfiguration.cs create mode 100644 services/mkt-x-service-net/src/MktXService.Infrastructure/EntityConfigurations/TwitterAccountEntityTypeConfiguration.cs create mode 100644 services/mkt-x-service-net/src/MktXService.Infrastructure/ExternalServices/OpenAI/IAIServiceClient.cs create mode 100644 services/mkt-x-service-net/src/MktXService.Infrastructure/ExternalServices/OpenAI/OpenAIServiceClient.cs create mode 100644 services/mkt-x-service-net/src/MktXService.Infrastructure/ExternalServices/Twitter/ITwitterApiClient.cs create mode 100644 services/mkt-x-service-net/src/MktXService.Infrastructure/ExternalServices/Twitter/TwitterApiClient.cs rename services/mkt-x-service-net/src/MktXService.Infrastructure/{MyServiceContext.cs => MktXServiceContext.cs} (57%) create mode 100644 services/mkt-x-service-net/src/MktXService.Infrastructure/Repositories/AIConversationSessionRepository.cs create mode 100644 services/mkt-x-service-net/src/MktXService.Infrastructure/Repositories/AutomationFlowRepository.cs create mode 100644 services/mkt-x-service-net/src/MktXService.Infrastructure/Repositories/CampaignRepository.cs create mode 100644 services/mkt-x-service-net/src/MktXService.Infrastructure/Repositories/ContactRepository.cs create mode 100644 services/mkt-x-service-net/src/MktXService.Infrastructure/Repositories/ConversationRepository.cs create mode 100644 services/mkt-x-service-net/src/MktXService.Infrastructure/Repositories/SegmentRepository.cs create mode 100644 services/mkt-x-service-net/src/MktXService.Infrastructure/Repositories/TemplateRepository.cs create mode 100644 services/mkt-x-service-net/src/MktXService.Infrastructure/Repositories/TwitterAccountRepository.cs delete mode 100644 services/mkt-zalo-service-net/src/MktZaloService.API/Application/Commands/ChangeSampleStatusCommand.cs delete mode 100644 services/mkt-zalo-service-net/src/MktZaloService.API/Application/Commands/ChangeSampleStatusCommandHandler.cs delete mode 100644 services/mkt-zalo-service-net/src/MktZaloService.API/Application/Commands/CreateSampleCommand.cs delete mode 100644 services/mkt-zalo-service-net/src/MktZaloService.API/Application/Commands/CreateSampleCommandHandler.cs delete mode 100644 services/mkt-zalo-service-net/src/MktZaloService.API/Application/Commands/DeleteSampleCommand.cs delete mode 100644 services/mkt-zalo-service-net/src/MktZaloService.API/Application/Commands/DeleteSampleCommandHandler.cs delete mode 100644 services/mkt-zalo-service-net/src/MktZaloService.API/Application/Commands/UpdateSampleCommand.cs delete mode 100644 services/mkt-zalo-service-net/src/MktZaloService.API/Application/Commands/UpdateSampleCommandHandler.cs delete mode 100644 services/mkt-zalo-service-net/src/MktZaloService.API/Application/Queries/GetSampleQuery.cs delete mode 100644 services/mkt-zalo-service-net/src/MktZaloService.API/Application/Queries/GetSampleQueryHandler.cs delete mode 100644 services/mkt-zalo-service-net/src/MktZaloService.API/Application/Queries/GetSamplesQuery.cs delete mode 100644 services/mkt-zalo-service-net/src/MktZaloService.API/Application/Queries/GetSamplesQueryHandler.cs delete mode 100644 services/mkt-zalo-service-net/src/MktZaloService.API/Application/Validations/CreateSampleCommandValidator.cs delete mode 100644 services/mkt-zalo-service-net/src/MktZaloService.API/Application/Validations/UpdateSampleCommandValidator.cs delete mode 100644 services/mkt-zalo-service-net/src/MktZaloService.API/Controllers/SamplesController.cs create mode 100644 services/mkt-zalo-service-net/src/MktZaloService.Infrastructure/Caching/ChatbotRuleCacheService.cs create mode 100644 services/mkt-zalo-service-net/src/MktZaloService.Infrastructure/Caching/ConversationCacheService.cs create mode 100644 services/mkt-zalo-service-net/src/MktZaloService.Infrastructure/EntityConfigurations/ChatbotRuleEntityTypeConfiguration.cs create mode 100644 services/mkt-zalo-service-net/src/MktZaloService.Infrastructure/EntityConfigurations/ConversationEntityTypeConfiguration.cs create mode 100644 services/mkt-zalo-service-net/src/MktZaloService.Infrastructure/EntityConfigurations/MessageTemplateEntityTypeConfiguration.cs delete mode 100644 services/mkt-zalo-service-net/src/MktZaloService.Infrastructure/EntityConfigurations/SampleEntityTypeConfiguration.cs delete mode 100644 services/mkt-zalo-service-net/src/MktZaloService.Infrastructure/EntityConfigurations/SampleStatusEntityTypeConfiguration.cs create mode 100644 services/mkt-zalo-service-net/src/MktZaloService.Infrastructure/EntityConfigurations/ZaloCustomerEntityTypeConfiguration.cs rename services/mkt-zalo-service-net/src/MktZaloService.Infrastructure/{MyServiceContext.cs => MktZaloServiceContext.cs} (70%) create mode 100644 services/mkt-zalo-service-net/src/MktZaloService.Infrastructure/Repositories/ChatbotRuleRepository.cs create mode 100644 services/mkt-zalo-service-net/src/MktZaloService.Infrastructure/Repositories/ConversationRepository.cs create mode 100644 services/mkt-zalo-service-net/src/MktZaloService.Infrastructure/Repositories/CustomerRepository.cs create mode 100644 services/mkt-zalo-service-net/src/MktZaloService.Infrastructure/Repositories/MessageTemplateRepository.cs delete mode 100644 services/mkt-zalo-service-net/src/MktZaloService.Infrastructure/Repositories/SampleRepository.cs create mode 100644 services/mkt-zalo-service-net/src/MktZaloService.Infrastructure/SeedData/DbSeeder.cs create mode 100644 services/mkt-zalo-service-net/src/MktZaloService.Infrastructure/Zalo/Models/ZaloModels.cs create mode 100644 services/mkt-zalo-service-net/src/MktZaloService.Infrastructure/Zalo/ZaloOAOptions.cs create mode 100644 services/mkt-zalo-service-net/src/MktZaloService.Infrastructure/Zalo/ZaloOfficialAccountClient.cs create mode 100644 services/mkt-zalo-service-net/src/MktZaloService.Infrastructure/Zalo/ZaloWebhookVerifier.cs diff --git a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/ChangeSampleStatusCommand.cs b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/ChangeSampleStatusCommand.cs deleted file mode 100644 index 18e60890..00000000 --- a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/ChangeSampleStatusCommand.cs +++ /dev/null @@ -1,14 +0,0 @@ -using MediatR; - -namespace FacebookService.API.Application.Commands; - -/// -/// EN: Command to change status of a Sample. -/// VI: Command để thay đổi trạng thái của Sample. -/// -/// EN: Sample ID / VI: ID sample -/// EN: New status (activate, complete, cancel) / VI: Trạng thái mới (activate, complete, cancel) -public record ChangeSampleStatusCommand( - Guid SampleId, - string NewStatus -) : IRequest; diff --git a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/ChangeSampleStatusCommandHandler.cs b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/ChangeSampleStatusCommandHandler.cs deleted file mode 100644 index 91555c76..00000000 --- a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/ChangeSampleStatusCommandHandler.cs +++ /dev/null @@ -1,70 +0,0 @@ -using MediatR; -using FacebookService.Domain.AggregatesModel.SampleAggregate; - -namespace FacebookService.API.Application.Commands; - -/// -/// EN: Handler for ChangeSampleStatusCommand. -/// VI: Handler cho ChangeSampleStatusCommand. -/// -public class ChangeSampleStatusCommandHandler : IRequestHandler -{ - private readonly ISampleRepository _sampleRepository; - private readonly ILogger _logger; - - public ChangeSampleStatusCommandHandler( - ISampleRepository sampleRepository, - ILogger logger) - { - _sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public async Task Handle( - ChangeSampleStatusCommand request, - CancellationToken cancellationToken) - { - _logger.LogInformation( - "Changing status of sample {SampleId} to {NewStatus} / Thay đổi trạng thái sample {SampleId} thành {NewStatus}", - request.SampleId, request.NewStatus); - - // EN: Get existing sample / VI: Lấy sample đã tồn tại - var sample = await _sampleRepository.GetAsync(request.SampleId); - - if (sample is null) - { - _logger.LogWarning( - "Sample {SampleId} not found / Sample {SampleId} không tìm thấy", - request.SampleId); - return false; - } - - // EN: Change status based on action / VI: Thay đổi trạng thái dựa trên action - switch (request.NewStatus.ToLowerInvariant()) - { - case "activate": - sample.Activate(); - break; - case "complete": - sample.Complete(); - break; - case "cancel": - sample.Cancel(); - break; - default: - _logger.LogWarning( - "Invalid status action: {NewStatus} / Action trạng thái không hợp lệ: {NewStatus}", - request.NewStatus); - return false; - } - - // EN: Save changes / VI: Lưu thay đổi - await _sampleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); - - _logger.LogInformation( - "Sample {SampleId} status changed to {NewStatus} / Trạng thái sample {SampleId} đã đổi thành {NewStatus}", - request.SampleId, request.NewStatus); - - return true; - } -} diff --git a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/CreateChatbotFlowCommand.cs b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/CreateChatbotFlowCommand.cs new file mode 100644 index 00000000..ea68a554 --- /dev/null +++ b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/CreateChatbotFlowCommand.cs @@ -0,0 +1,25 @@ +using MediatR; +using FacebookService.API.Application.Dtos; + +namespace FacebookService.API.Application.Commands; + +/// +/// EN: Command to create a new ChatbotFlow. +/// VI: Command để tạo một ChatbotFlow mới. +/// +/// EN: Shop ID / VI: ID shop +/// EN: Flow name / VI: Tên flow +/// EN: Trigger type (GetStarted, Keyword, Postback, Fallback) / VI: Loại trigger +/// EN: Trigger value (keyword, payload, etc.) / VI: Giá trị trigger +public record CreateChatbotFlowCommand( + Guid ShopId, + string Name, + string TriggerType, + string TriggerValue +) : IRequest; + +/// +/// EN: Result of CreateChatbotFlowCommand. +/// VI: Kết quả của CreateChatbotFlowCommand. +/// +public record CreateChatbotFlowCommandResult(Guid Id, bool Success); diff --git a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/CreateChatbotFlowCommandHandler.cs b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/CreateChatbotFlowCommandHandler.cs new file mode 100644 index 00000000..a55efc77 --- /dev/null +++ b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/CreateChatbotFlowCommandHandler.cs @@ -0,0 +1,77 @@ +using MediatR; +using FacebookService.Domain.AggregatesModel.ChatbotAggregate; + +namespace FacebookService.API.Application.Commands; + +/// +/// EN: Handler for CreateChatbotFlowCommand. +/// VI: Handler cho CreateChatbotFlowCommand. +/// +public class CreateChatbotFlowCommandHandler : IRequestHandler +{ + private readonly IChatbotFlowRepository _flowRepository; + private readonly ILogger _logger; + + public CreateChatbotFlowCommandHandler( + IChatbotFlowRepository flowRepository, + ILogger logger) + { + _flowRepository = flowRepository ?? throw new ArgumentNullException(nameof(flowRepository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle( + CreateChatbotFlowCommand request, + CancellationToken cancellationToken) + { + _logger.LogInformation( + "Creating chatbot flow: {Name} for shop: {ShopId}", + request.Name, request.ShopId); + + // EN: Check if trigger value already exists / VI: Kiểm tra trigger value đã tồn tại chưa + var exists = await _flowRepository.ExistsByTriggerValueAsync( + request.ShopId, request.TriggerValue, cancellationToken: cancellationToken); + + if (exists) + { + _logger.LogWarning( + "Trigger value already exists: {TriggerValue} for shop: {ShopId}", + request.TriggerValue, request.ShopId); + return new CreateChatbotFlowCommandResult(Guid.Empty, Success: false); + } + + // EN: Parse trigger type / VI: Parse loại trigger + var triggerType = ParseTriggerType(request.TriggerType); + if (triggerType is null) + { + _logger.LogWarning("Invalid trigger type: {TriggerType}", request.TriggerType); + return new CreateChatbotFlowCommandResult(Guid.Empty, Success: false); + } + + // EN: Create flow / VI: Tạo flow + var flow = new ChatbotFlow( + request.ShopId, + request.Name, + triggerType, + request.TriggerValue); + + _flowRepository.Add(flow); + await _flowRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation("ChatbotFlow created with ID: {FlowId}", flow.Id); + + return new CreateChatbotFlowCommandResult(flow.Id, Success: true); + } + + private static FlowTriggerType? ParseTriggerType(string triggerType) + { + return triggerType?.ToLowerInvariant() switch + { + "getstarted" => FlowTriggerType.GetStarted, + "keyword" => FlowTriggerType.Keyword, + "postback" => FlowTriggerType.Postback, + "fallback" => FlowTriggerType.Fallback, + _ => null + }; + } +} diff --git a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/CreateCustomerCommand.cs b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/CreateCustomerCommand.cs new file mode 100644 index 00000000..5d2e11c6 --- /dev/null +++ b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/CreateCustomerCommand.cs @@ -0,0 +1,33 @@ +using MediatR; +using FacebookService.API.Application.Dtos; + +namespace FacebookService.API.Application.Commands; + +/// +/// EN: Command to create a new Customer from Facebook user ID. +/// VI: Command để tạo một Customer mới từ Facebook user ID. +/// +/// EN: Facebook user ID / VI: ID người dùng Facebook +/// EN: Optional display name / VI: Tên hiển thị tùy chọn +/// EN: Optional email / VI: Email tùy chọn +/// EN: Optional phone / VI: Điện thoại tùy chọn +/// EN: Optional profile picture URL / VI: URL ảnh đại diện tùy chọn +/// EN: Optional locale / VI: Locale tùy chọn +/// EN: Optional timezone / VI: Timezone tùy chọn +public record CreateCustomerCommand( + string FacebookUserId, + string? Name = null, + string? Email = null, + string? Phone = null, + string? ProfilePicUrl = null, + string? Locale = null, + string? Timezone = null +) : IRequest; + +/// +/// EN: Result of CreateCustomerCommand. +/// VI: Kết quả của CreateCustomerCommand. +/// +/// EN: Created customer ID / VI: ID customer đã tạo +/// EN: True if customer was created, false if already existed / VI: True nếu customer được tạo mới +public record CreateCustomerCommandResult(Guid Id, bool IsNew); diff --git a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/CreateCustomerCommandHandler.cs b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/CreateCustomerCommandHandler.cs new file mode 100644 index 00000000..6d2ea0ae --- /dev/null +++ b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/CreateCustomerCommandHandler.cs @@ -0,0 +1,79 @@ +using MediatR; +using FacebookService.Domain.AggregatesModel.CustomerAggregate; + +namespace FacebookService.API.Application.Commands; + +/// +/// EN: Handler for CreateCustomerCommand. +/// VI: Handler cho CreateCustomerCommand. +/// +public class CreateCustomerCommandHandler : IRequestHandler +{ + private readonly ICustomerRepository _customerRepository; + private readonly ILogger _logger; + + public CreateCustomerCommandHandler( + ICustomerRepository customerRepository, + ILogger logger) + { + _customerRepository = customerRepository ?? throw new ArgumentNullException(nameof(customerRepository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle( + CreateCustomerCommand request, + CancellationToken cancellationToken) + { + _logger.LogInformation( + "Processing CreateCustomerCommand for FacebookUserId: {FacebookUserId}", + request.FacebookUserId); + + // EN: Check if customer already exists / VI: Kiểm tra customer đã tồn tại chưa + var existingCustomer = await _customerRepository.GetByFacebookUserIdAsync( + request.FacebookUserId, cancellationToken); + + if (existingCustomer is not null) + { + _logger.LogInformation( + "Customer already exists with ID: {CustomerId}, updating profile", + existingCustomer.Id); + + // EN: Update existing customer profile / VI: Cập nhật profile customer sẵn có + existingCustomer.UpdateProfile( + request.Name, + request.Email, + request.Phone, + request.ProfilePicUrl, + request.Locale, + request.Timezone); + + _customerRepository.Update(existingCustomer); + await _customerRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + return new CreateCustomerCommandResult(existingCustomer.Id, IsNew: false); + } + + // EN: Create new customer / VI: Tạo customer mới + var customer = new Customer(request.FacebookUserId, request.Name); + + // EN: Update profile with optional fields / VI: Cập nhật profile với fields tùy chọn + customer.UpdateProfile( + request.Name, + request.Email, + request.Phone, + request.ProfilePicUrl, + request.Locale, + request.Timezone); + + _customerRepository.Add(customer); + + // EN: Save changes (dispatches domain events) / VI: Lưu thay đổi (dispatch domain events) + await _customerRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "Customer created successfully with ID: {CustomerId}", + customer.Id); + + return new CreateCustomerCommandResult(customer.Id, IsNew: true); + } +} diff --git a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/CreateSampleCommand.cs b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/CreateSampleCommand.cs deleted file mode 100644 index 9782f000..00000000 --- a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/CreateSampleCommand.cs +++ /dev/null @@ -1,21 +0,0 @@ -using MediatR; - -namespace FacebookService.API.Application.Commands; - -/// -/// EN: Command to create a new Sample. -/// VI: Command để tạo một Sample mới. -/// -/// EN: Sample name / VI: Tên sample -/// EN: Optional description / VI: Mô tả tùy chọn -public record CreateSampleCommand( - string Name, - string? Description -) : IRequest; - -/// -/// EN: Result of CreateSampleCommand. -/// VI: Kết quả của CreateSampleCommand. -/// -/// EN: Created sample ID / VI: ID sample đã tạo -public record CreateSampleCommandResult(Guid Id); diff --git a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/CreateSampleCommandHandler.cs b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/CreateSampleCommandHandler.cs deleted file mode 100644 index 524bee64..00000000 --- a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/CreateSampleCommandHandler.cs +++ /dev/null @@ -1,46 +0,0 @@ -using MediatR; -using FacebookService.Domain.AggregatesModel.SampleAggregate; - -namespace FacebookService.API.Application.Commands; - -/// -/// EN: Handler for CreateSampleCommand. -/// VI: Handler cho CreateSampleCommand. -/// -public class CreateSampleCommandHandler : IRequestHandler -{ - private readonly ISampleRepository _sampleRepository; - private readonly ILogger _logger; - - public CreateSampleCommandHandler( - ISampleRepository sampleRepository, - ILogger logger) - { - _sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public async Task Handle( - CreateSampleCommand request, - CancellationToken cancellationToken) - { - _logger.LogInformation( - "Creating new sample with name: {Name} / Tạo sample mới với tên: {Name}", - request.Name); - - // EN: Create domain entity / VI: Tạo domain entity - var sample = new Sample(request.Name, request.Description); - - // EN: Add to repository / VI: Thêm vào repository - _sampleRepository.Add(sample); - - // EN: Save changes (dispatches domain events) / VI: Lưu thay đổi (dispatch domain events) - await _sampleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); - - _logger.LogInformation( - "Sample created successfully with ID: {SampleId} / Sample đã tạo thành công với ID: {SampleId}", - sample.Id); - - return new CreateSampleCommandResult(sample.Id); - } -} diff --git a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/DeleteSampleCommand.cs b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/DeleteSampleCommand.cs deleted file mode 100644 index eea27daf..00000000 --- a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/DeleteSampleCommand.cs +++ /dev/null @@ -1,10 +0,0 @@ -using MediatR; - -namespace FacebookService.API.Application.Commands; - -/// -/// EN: Command to delete a Sample. -/// VI: Command để xóa một Sample. -/// -/// EN: Sample ID to delete / VI: ID sample cần xóa -public record DeleteSampleCommand(Guid SampleId) : IRequest; diff --git a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/DeleteSampleCommandHandler.cs b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/DeleteSampleCommandHandler.cs deleted file mode 100644 index c46c54a2..00000000 --- a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/DeleteSampleCommandHandler.cs +++ /dev/null @@ -1,54 +0,0 @@ -using MediatR; -using FacebookService.Domain.AggregatesModel.SampleAggregate; - -namespace FacebookService.API.Application.Commands; - -/// -/// EN: Handler for DeleteSampleCommand. -/// VI: Handler cho DeleteSampleCommand. -/// -public class DeleteSampleCommandHandler : IRequestHandler -{ - private readonly ISampleRepository _sampleRepository; - private readonly ILogger _logger; - - public DeleteSampleCommandHandler( - ISampleRepository sampleRepository, - ILogger logger) - { - _sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public async Task Handle( - DeleteSampleCommand request, - CancellationToken cancellationToken) - { - _logger.LogInformation( - "Deleting sample {SampleId} / Xóa sample {SampleId}", - request.SampleId); - - // EN: Get existing sample / VI: Lấy sample đã tồn tại - var sample = await _sampleRepository.GetAsync(request.SampleId); - - if (sample is null) - { - _logger.LogWarning( - "Sample {SampleId} not found / Sample {SampleId} không tìm thấy", - request.SampleId); - return false; - } - - // EN: Delete sample / VI: Xóa sample - _sampleRepository.Delete(sample); - - // EN: Save changes / VI: Lưu thay đổi - await _sampleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); - - _logger.LogInformation( - "Sample {SampleId} deleted successfully / Sample {SampleId} đã xóa thành công", - request.SampleId); - - return true; - } -} diff --git a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/ProcessIncomingMessageCommand.cs b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/ProcessIncomingMessageCommand.cs new file mode 100644 index 00000000..7271eeaa --- /dev/null +++ b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/ProcessIncomingMessageCommand.cs @@ -0,0 +1,35 @@ +using MediatR; +using FacebookService.API.Application.Dtos; + +namespace FacebookService.API.Application.Commands; + +/// +/// EN: Command to process an incoming message from Facebook Messenger. +/// VI: Command để xử lý tin nhắn đến từ Facebook Messenger. +/// +/// EN: Facebook Page ID / VI: ID trang Facebook +/// EN: Facebook sender (user) ID / VI: ID người gửi Facebook +/// EN: Message content / VI: Nội dung tin nhắn +/// EN: Facebook message ID (mid) / VI: ID tin nhắn Facebook +/// EN: Message timestamp / VI: Thời gian tin nhắn +/// EN: Quick reply payload if any / VI: Payload quick reply nếu có +public record ProcessIncomingMessageCommand( + string PageId, + string SenderId, + string MessageText, + string? FacebookMessageId = null, + long? Timestamp = null, + string? QuickReplyPayload = null +) : IRequest; + +/// +/// EN: Result of ProcessIncomingMessageCommand. +/// VI: Kết quả của ProcessIncomingMessageCommand. +/// +public record ProcessIncomingMessageResult( + Guid ConversationId, + Guid CustomerId, + Guid MessageId, + bool IsNewConversation, + bool IsNewCustomer +); diff --git a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/ProcessIncomingMessageHandler.cs b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/ProcessIncomingMessageHandler.cs new file mode 100644 index 00000000..f4bc29ec --- /dev/null +++ b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/ProcessIncomingMessageHandler.cs @@ -0,0 +1,98 @@ +using MediatR; +using FacebookService.Domain.AggregatesModel.CustomerAggregate; +using FacebookService.Domain.AggregatesModel.ConversationAggregate; + +namespace FacebookService.API.Application.Commands; + +/// +/// EN: Handler for ProcessIncomingMessageCommand. +/// VI: Handler cho ProcessIncomingMessageCommand. +/// +public class ProcessIncomingMessageHandler : IRequestHandler +{ + private readonly ICustomerRepository _customerRepository; + private readonly IConversationRepository _conversationRepository; + private readonly ILogger _logger; + + public ProcessIncomingMessageHandler( + ICustomerRepository customerRepository, + IConversationRepository conversationRepository, + ILogger logger) + { + _customerRepository = customerRepository ?? throw new ArgumentNullException(nameof(customerRepository)); + _conversationRepository = conversationRepository ?? throw new ArgumentNullException(nameof(conversationRepository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle( + ProcessIncomingMessageCommand request, + CancellationToken cancellationToken) + { + _logger.LogInformation( + "Processing incoming message from sender: {SenderId} on page: {PageId}", + request.SenderId, request.PageId); + + var isNewCustomer = false; + var isNewConversation = false; + + // EN: Step 1 - Get or create customer / VI: Bước 1 - Lấy hoặc tạo customer + var customer = await _customerRepository.GetByFacebookUserIdAsync(request.SenderId, cancellationToken); + + if (customer is null) + { + customer = new Customer(request.SenderId); + _customerRepository.Add(customer); + isNewCustomer = true; + + _logger.LogInformation("Created new customer for sender: {SenderId}", request.SenderId); + } + else + { + customer.RecordInteraction(); + _customerRepository.Update(customer); + } + + // EN: Step 2 - Get or create conversation / VI: Bước 2 - Lấy hoặc tạo conversation + var conversation = await _conversationRepository.FindActiveByCustomerAndPageAsync( + customer.Id, request.PageId, cancellationToken); + + if (conversation is null) + { + conversation = new Conversation(customer.Id, request.PageId); + _conversationRepository.Add(conversation); + isNewConversation = true; + + _logger.LogInformation("Created new conversation for customer: {CustomerId}", customer.Id); + } + + // EN: Step 3 - Add incoming message / VI: Bước 3 - Thêm tin nhắn đến + var message = new Message( + senderId: request.SenderId, + content: request.MessageText, + messageType: MessageType.Text, + direction: MessageDirection.Incoming, + facebookMessageId: request.FacebookMessageId, + metadata: request.QuickReplyPayload != null + ? new Dictionary { ["quick_reply_payload"] = request.QuickReplyPayload } + : null + ); + + conversation.AddMessage(message); + _conversationRepository.Update(conversation); + + // EN: Step 4 - Save all changes / VI: Bước 4 - Lưu tất cả thay đổi + await _conversationRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "Message processed successfully. ConversationId: {ConversationId}, MessageId: {MessageId}", + conversation.Id, message.Id); + + return new ProcessIncomingMessageResult( + ConversationId: conversation.Id, + CustomerId: customer.Id, + MessageId: message.Id, + IsNewConversation: isNewConversation, + IsNewCustomer: isNewCustomer + ); + } +} diff --git a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/UpdateCustomerCommand.cs b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/UpdateCustomerCommand.cs new file mode 100644 index 00000000..5081e972 --- /dev/null +++ b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/UpdateCustomerCommand.cs @@ -0,0 +1,25 @@ +using MediatR; +using FacebookService.API.Application.Dtos; + +namespace FacebookService.API.Application.Commands; + +/// +/// EN: Command to update customer tags and custom fields. +/// VI: Command để cập nhật tags và custom fields của customer. +/// +/// EN: Customer ID / VI: ID customer +/// EN: New tags (replaces existing) / VI: Tags mới (thay thế hiện có) +/// EN: Custom fields to set / VI: Custom fields để đặt +public record UpdateCustomerCommand( + Guid CustomerId, + IEnumerable? Tags = null, + Dictionary? CustomFields = null +) : IRequest; + +/// +/// EN: Result of UpdateCustomerCommand. +/// VI: Kết quả của UpdateCustomerCommand. +/// +/// EN: Whether update was successful / VI: Cập nhật có thành công không +/// EN: Updated customer ID / VI: ID customer đã cập nhật +public record UpdateCustomerCommandResult(bool Success, Guid CustomerId); diff --git a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/UpdateCustomerCommandHandler.cs b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/UpdateCustomerCommandHandler.cs new file mode 100644 index 00000000..9d83e6c1 --- /dev/null +++ b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/UpdateCustomerCommandHandler.cs @@ -0,0 +1,72 @@ +using MediatR; +using FacebookService.Domain.AggregatesModel.CustomerAggregate; + +namespace FacebookService.API.Application.Commands; + +/// +/// EN: Handler for UpdateCustomerCommand. +/// VI: Handler cho UpdateCustomerCommand. +/// +public class UpdateCustomerCommandHandler : IRequestHandler +{ + private readonly ICustomerRepository _customerRepository; + private readonly ILogger _logger; + + public UpdateCustomerCommandHandler( + ICustomerRepository customerRepository, + ILogger logger) + { + _customerRepository = customerRepository ?? throw new ArgumentNullException(nameof(customerRepository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle( + UpdateCustomerCommand request, + CancellationToken cancellationToken) + { + _logger.LogInformation( + "Processing UpdateCustomerCommand for CustomerId: {CustomerId}", + request.CustomerId); + + var customer = await _customerRepository.GetByIdAsync(request.CustomerId, cancellationToken); + + if (customer is null) + { + _logger.LogWarning("Customer not found: {CustomerId}", request.CustomerId); + return new UpdateCustomerCommandResult(Success: false, request.CustomerId); + } + + // EN: Update tags if provided / VI: Cập nhật tags nếu được cung cấp + if (request.Tags is not null) + { + // EN: Clear existing tags and add new ones / VI: Xóa tags hiện có và thêm mới + var currentTags = customer.Tags.ToList(); + foreach (var tag in currentTags) + { + customer.RemoveTag(tag); + } + foreach (var tag in request.Tags) + { + customer.AddTag(tag); + } + } + + // EN: Update custom fields if provided / VI: Cập nhật custom fields nếu được cung cấp + if (request.CustomFields is not null) + { + foreach (var (key, value) in request.CustomFields) + { + customer.SetCustomField(key, value); + } + } + + _customerRepository.Update(customer); + await _customerRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "Customer updated successfully: {CustomerId}", + customer.Id); + + return new UpdateCustomerCommandResult(Success: true, customer.Id); + } +} diff --git a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/UpdateSampleCommand.cs b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/UpdateSampleCommand.cs deleted file mode 100644 index ec51816b..00000000 --- a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/UpdateSampleCommand.cs +++ /dev/null @@ -1,16 +0,0 @@ -using MediatR; - -namespace FacebookService.API.Application.Commands; - -/// -/// EN: Command to update an existing Sample. -/// VI: Command để cập nhật một Sample đã tồn tại. -/// -/// EN: Sample ID to update / VI: ID sample cần cập nhật -/// EN: New name / VI: Tên mới -/// EN: New description / VI: Mô tả mới -public record UpdateSampleCommand( - Guid SampleId, - string Name, - string? Description -) : IRequest; diff --git a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/UpdateSampleCommandHandler.cs b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/UpdateSampleCommandHandler.cs deleted file mode 100644 index b8f0f0a6..00000000 --- a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/UpdateSampleCommandHandler.cs +++ /dev/null @@ -1,54 +0,0 @@ -using MediatR; -using FacebookService.Domain.AggregatesModel.SampleAggregate; - -namespace FacebookService.API.Application.Commands; - -/// -/// EN: Handler for UpdateSampleCommand. -/// VI: Handler cho UpdateSampleCommand. -/// -public class UpdateSampleCommandHandler : IRequestHandler -{ - private readonly ISampleRepository _sampleRepository; - private readonly ILogger _logger; - - public UpdateSampleCommandHandler( - ISampleRepository sampleRepository, - ILogger logger) - { - _sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public async Task Handle( - UpdateSampleCommand request, - CancellationToken cancellationToken) - { - _logger.LogInformation( - "Updating sample {SampleId} / Cập nhật sample {SampleId}", - request.SampleId); - - // EN: Get existing sample / VI: Lấy sample đã tồn tại - var sample = await _sampleRepository.GetAsync(request.SampleId); - - if (sample is null) - { - _logger.LogWarning( - "Sample {SampleId} not found / Sample {SampleId} không tìm thấy", - request.SampleId); - return false; - } - - // EN: Update sample using domain method / VI: Cập nhật sample sử dụng domain method - sample.Update(request.Name, request.Description); - - // EN: Save changes / VI: Lưu thay đổi - await _sampleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); - - _logger.LogInformation( - "Sample {SampleId} updated successfully / Sample {SampleId} đã cập nhật thành công", - request.SampleId); - - return true; - } -} diff --git a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/UpsertAIChatbotConfigCommand.cs b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/UpsertAIChatbotConfigCommand.cs new file mode 100644 index 00000000..550ce06c --- /dev/null +++ b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/UpsertAIChatbotConfigCommand.cs @@ -0,0 +1,29 @@ +using MediatR; +using FacebookService.API.Application.Dtos; + +namespace FacebookService.API.Application.Commands; + +/// +/// EN: Command to create or update AI Chatbot configuration. +/// VI: Command để tạo hoặc cập nhật cấu hình AI Chatbot. +/// +/// EN: Shop ID / VI: ID shop +/// EN: AI provider (OpenAI, AzureOpenAI) / VI: Nhà cung cấp AI +/// EN: Model name (e.g., gpt-4-turbo) / VI: Tên model +/// EN: System prompt for AI context / VI: System prompt cho ngữ cảnh AI +/// EN: Temperature (0.0 - 2.0) / VI: Temperature +/// EN: Max tokens for response / VI: Max tokens cho response +public record UpsertAIChatbotConfigCommand( + Guid ShopId, + string Provider, + string Model, + string SystemPrompt, + float Temperature = 0.7f, + int MaxTokens = 500 +) : IRequest; + +/// +/// EN: Result of UpsertAIChatbotConfigCommand. +/// VI: Kết quả của UpsertAIChatbotConfigCommand. +/// +public record UpsertAIChatbotConfigResult(Guid Id, bool IsNew, bool Success); diff --git a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/UpsertAIChatbotConfigCommandHandler.cs b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/UpsertAIChatbotConfigCommandHandler.cs new file mode 100644 index 00000000..c8bc4ab4 --- /dev/null +++ b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Commands/UpsertAIChatbotConfigCommandHandler.cs @@ -0,0 +1,101 @@ +using MediatR; +using FacebookService.Domain.AggregatesModel.ChatbotAggregate; + +namespace FacebookService.API.Application.Commands; + +/// +/// EN: Handler for UpsertAIChatbotConfigCommand. +/// VI: Handler cho UpsertAIChatbotConfigCommand. +/// +public class UpsertAIChatbotConfigCommandHandler : IRequestHandler +{ + private readonly IAIChatbotConfigRepository _configRepository; + private readonly ILogger _logger; + + public UpsertAIChatbotConfigCommandHandler( + IAIChatbotConfigRepository configRepository, + ILogger logger) + { + _configRepository = configRepository ?? throw new ArgumentNullException(nameof(configRepository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle( + UpsertAIChatbotConfigCommand request, + CancellationToken cancellationToken) + { + _logger.LogInformation( + "Upserting AI chatbot config for shop: {ShopId}", + request.ShopId); + + // EN: Parse provider / VI: Parse provider + var provider = ParseProvider(request.Provider); + if (provider is null) + { + _logger.LogWarning("Invalid AI provider: {Provider}", request.Provider); + return new UpsertAIChatbotConfigResult(Guid.Empty, IsNew: false, Success: false); + } + + // EN: Check for existing config / VI: Kiểm tra config sẵn có + var existingConfig = await _configRepository.GetByShopIdAsync(request.ShopId, cancellationToken); + + if (existingConfig is not null) + { + // EN: Update existing / VI: Cập nhật sẵn có + try + { + existingConfig.UpdateConfig( + provider, + request.Model, + request.SystemPrompt, + request.Temperature, + request.MaxTokens); + + _configRepository.Update(existingConfig); + await _configRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation("AI config updated for shop: {ShopId}", request.ShopId); + return new UpsertAIChatbotConfigResult(existingConfig.Id, IsNew: false, Success: true); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update AI config for shop: {ShopId}", request.ShopId); + return new UpsertAIChatbotConfigResult(existingConfig.Id, IsNew: false, Success: false); + } + } + + // EN: Create new config / VI: Tạo config mới + try + { + var config = new AIChatbotConfig( + request.ShopId, + provider, + request.Model, + request.SystemPrompt, + request.Temperature, + request.MaxTokens); + + _configRepository.Add(config); + await _configRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation("AI config created for shop: {ShopId} with ID: {ConfigId}", + request.ShopId, config.Id); + return new UpsertAIChatbotConfigResult(config.Id, IsNew: true, Success: true); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create AI config for shop: {ShopId}", request.ShopId); + return new UpsertAIChatbotConfigResult(Guid.Empty, IsNew: true, Success: false); + } + } + + private static AIProvider? ParseProvider(string provider) + { + return provider?.ToLowerInvariant() switch + { + "openai" => AIProvider.OpenAI, + "azureopenai" or "azure" => AIProvider.AzureOpenAI, + _ => null + }; + } +} diff --git a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Dtos/ChatbotDtos.cs b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Dtos/ChatbotDtos.cs new file mode 100644 index 00000000..af02abf9 --- /dev/null +++ b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Dtos/ChatbotDtos.cs @@ -0,0 +1,60 @@ +namespace FacebookService.API.Application.Dtos; + +/// +/// EN: ChatbotFlow DTO for API responses. +/// VI: ChatbotFlow DTO cho API responses. +/// +public record ChatbotFlowDto( + Guid Id, + Guid ShopId, + string Name, + string TriggerType, + string TriggerValue, + bool IsActive, + DateTime CreatedAt, + DateTime? UpdatedAt, + IReadOnlyList? Nodes = null +); + +/// +/// EN: ChatbotFlow summary DTO for list responses. +/// VI: ChatbotFlow summary DTO cho list responses. +/// +public record ChatbotFlowSummaryDto( + Guid Id, + string Name, + string TriggerType, + string TriggerValue, + bool IsActive, + DateTime CreatedAt +); + +/// +/// EN: FlowNode DTO for API responses. +/// VI: FlowNode DTO cho API responses. +/// +public record FlowNodeDto( + Guid Id, + string NodeType, + string? Content, + int OrderIndex, + IReadOnlyCollection NextNodeIds, + IReadOnlyDictionary? Config = null +); + +/// +/// EN: AIChatbotConfig DTO for API responses. +/// VI: AIChatbotConfig DTO cho API responses. +/// +public record AIChatbotConfigDto( + Guid Id, + Guid ShopId, + string Provider, + string Model, + string SystemPrompt, + float Temperature, + int MaxTokens, + bool IsEnabled, + DateTime CreatedAt, + DateTime UpdatedAt +); diff --git a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Dtos/ConversationDtos.cs b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Dtos/ConversationDtos.cs new file mode 100644 index 00000000..d99a3f7c --- /dev/null +++ b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Dtos/ConversationDtos.cs @@ -0,0 +1,48 @@ +namespace FacebookService.API.Application.Dtos; + +/// +/// EN: Conversation DTO for API responses. +/// VI: Conversation DTO cho API responses. +/// +public record ConversationDto( + Guid Id, + Guid CustomerId, + CustomerSummaryDto? Customer, + string PageId, + string Status, + string Channel, + string? AssignedAgentId, + DateTime? LastMessageAt, + DateTime CreatedAt, + IReadOnlyList? Messages = null +); + +/// +/// EN: Conversation summary DTO for list responses. +/// VI: Conversation summary DTO cho list responses. +/// +public record ConversationSummaryDto( + Guid Id, + Guid CustomerId, + string? CustomerName, + string PageId, + string Status, + string? LastMessageText, + DateTime? LastMessageAt, + DateTime CreatedAt +); + +/// +/// EN: Message DTO for API responses. +/// VI: Message DTO cho API responses. +/// +public record MessageDto( + Guid Id, + string SenderId, + string Content, + string MessageType, + string Direction, + DateTime SentAt, + string? FacebookMessageId = null, + IReadOnlyDictionary? Metadata = null +); diff --git a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Dtos/CustomerDtos.cs b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Dtos/CustomerDtos.cs new file mode 100644 index 00000000..ff768761 --- /dev/null +++ b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Dtos/CustomerDtos.cs @@ -0,0 +1,33 @@ +namespace FacebookService.API.Application.Dtos; + +/// +/// EN: Customer DTO for API responses. +/// VI: Customer DTO cho API responses. +/// +public record CustomerDto( + Guid Id, + string FacebookUserId, + string? Name, + string? Email, + string? Phone, + string? ProfilePicUrl, + string? Locale, + string? Timezone, + IReadOnlyCollection Tags, + IReadOnlyDictionary CustomFields, + DateTime FirstSeenAt, + DateTime LastInteractionAt +); + +/// +/// EN: Customer summary DTO for list responses. +/// VI: Customer summary DTO cho list responses. +/// +public record CustomerSummaryDto( + Guid Id, + string FacebookUserId, + string? Name, + string? ProfilePicUrl, + IReadOnlyCollection Tags, + DateTime LastInteractionAt +); diff --git a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Queries/AIChatbotConfigQueries.cs b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Queries/AIChatbotConfigQueries.cs new file mode 100644 index 00000000..c898fb76 --- /dev/null +++ b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Queries/AIChatbotConfigQueries.cs @@ -0,0 +1,11 @@ +using MediatR; +using FacebookService.API.Application.Dtos; + +namespace FacebookService.API.Application.Queries; + +/// +/// EN: Query to get AI Chatbot config for a shop. +/// VI: Query để lấy cấu hình AI Chatbot cho shop. +/// +/// EN: Shop ID / VI: ID shop +public record GetAIChatbotConfigQuery(Guid ShopId) : IRequest; diff --git a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Queries/AIChatbotConfigQueryHandlers.cs b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Queries/AIChatbotConfigQueryHandlers.cs new file mode 100644 index 00000000..1c7c7e99 --- /dev/null +++ b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Queries/AIChatbotConfigQueryHandlers.cs @@ -0,0 +1,44 @@ +using MediatR; +using FacebookService.API.Application.Dtos; +using FacebookService.Domain.AggregatesModel.ChatbotAggregate; + +namespace FacebookService.API.Application.Queries; + +/// +/// EN: Handler for GetAIChatbotConfigQuery. +/// VI: Handler cho GetAIChatbotConfigQuery. +/// +public class GetAIChatbotConfigQueryHandler : IRequestHandler +{ + private readonly IAIChatbotConfigRepository _configRepository; + + public GetAIChatbotConfigQueryHandler(IAIChatbotConfigRepository configRepository) + { + _configRepository = configRepository ?? throw new ArgumentNullException(nameof(configRepository)); + } + + public async Task Handle( + GetAIChatbotConfigQuery request, + CancellationToken cancellationToken) + { + var config = await _configRepository.GetByShopIdAsync(request.ShopId, cancellationToken); + + if (config is null) + { + return null; + } + + return new AIChatbotConfigDto( + config.Id, + config.ShopId, + config.Provider.Name, + config.Model, + config.SystemPrompt, + config.Temperature, + config.MaxTokens, + config.IsEnabled, + config.CreatedAt, + config.UpdatedAt + ); + } +} diff --git a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Queries/ChatbotFlowQueries.cs b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Queries/ChatbotFlowQueries.cs new file mode 100644 index 00000000..e266dd1f --- /dev/null +++ b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Queries/ChatbotFlowQueries.cs @@ -0,0 +1,22 @@ +using MediatR; +using FacebookService.API.Application.Dtos; + +namespace FacebookService.API.Application.Queries; + +/// +/// EN: Query to get a ChatbotFlow by ID with nodes. +/// VI: Query để lấy một ChatbotFlow theo ID với nodes. +/// +/// EN: Flow ID / VI: ID flow +public record GetChatbotFlowByIdQuery(Guid FlowId) : IRequest; + +/// +/// EN: Query to get chatbot flows for a shop. +/// VI: Query để lấy danh sách chatbot flows của shop. +/// +/// EN: Shop ID / VI: ID shop +/// EN: Only return active flows / VI: Chỉ lấy flows đang hoạt động +public record GetChatbotFlowsQuery( + Guid ShopId, + bool ActiveOnly = false +) : IRequest>; diff --git a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Queries/ChatbotFlowQueryHandlers.cs b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Queries/ChatbotFlowQueryHandlers.cs new file mode 100644 index 00000000..79f7149a --- /dev/null +++ b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Queries/ChatbotFlowQueryHandlers.cs @@ -0,0 +1,97 @@ +using MediatR; +using FacebookService.API.Application.Dtos; +using FacebookService.Domain.AggregatesModel.ChatbotAggregate; + +namespace FacebookService.API.Application.Queries; + +/// +/// EN: Handler for GetChatbotFlowByIdQuery. +/// VI: Handler cho GetChatbotFlowByIdQuery. +/// +public class GetChatbotFlowByIdQueryHandler : IRequestHandler +{ + private readonly IChatbotFlowRepository _flowRepository; + + public GetChatbotFlowByIdQueryHandler(IChatbotFlowRepository flowRepository) + { + _flowRepository = flowRepository ?? throw new ArgumentNullException(nameof(flowRepository)); + } + + public async Task Handle( + GetChatbotFlowByIdQuery request, + CancellationToken cancellationToken) + { + var flow = await _flowRepository.GetByIdWithNodesAsync(request.FlowId, cancellationToken); + + if (flow is null) + { + return null; + } + + return MapToDto(flow); + } + + private static ChatbotFlowDto MapToDto(ChatbotFlow flow) + { + var nodes = flow.Nodes.OrderBy(n => n.OrderIndex).Select(MapNodeToDto).ToList(); + + return new ChatbotFlowDto( + flow.Id, + flow.ShopId, + flow.Name, + flow.TriggerType.Name, + flow.TriggerValue, + flow.IsActive, + flow.CreatedAt, + flow.UpdatedAt, + nodes + ); + } + + private static FlowNodeDto MapNodeToDto(FlowNode node) + { + return new FlowNodeDto( + node.Id, + node.NodeType.Name, + node.Content, + node.OrderIndex, + node.NextNodeIds, + node.Config + ); + } +} + +/// +/// EN: Handler for GetChatbotFlowsQuery. +/// VI: Handler cho GetChatbotFlowsQuery. +/// +public class GetChatbotFlowsQueryHandler : IRequestHandler> +{ + private readonly IChatbotFlowRepository _flowRepository; + + public GetChatbotFlowsQueryHandler(IChatbotFlowRepository flowRepository) + { + _flowRepository = flowRepository ?? throw new ArgumentNullException(nameof(flowRepository)); + } + + public async Task> Handle( + GetChatbotFlowsQuery request, + CancellationToken cancellationToken) + { + var flows = await _flowRepository.GetByShopIdAsync(request.ShopId, cancellationToken); + + if (request.ActiveOnly) + { + flows = flows.Where(f => f.IsActive).ToList(); + } + + return flows.Select(f => new ChatbotFlowSummaryDto( + f.Id, + f.Name, + f.TriggerType.Name, + f.TriggerValue, + f.IsActive, + f.CreatedAt + )).ToList(); + } +} diff --git a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Queries/ConversationQueries.cs b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Queries/ConversationQueries.cs new file mode 100644 index 00000000..b2c2f072 --- /dev/null +++ b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Queries/ConversationQueries.cs @@ -0,0 +1,39 @@ +using MediatR; +using FacebookService.API.Application.Dtos; + +namespace FacebookService.API.Application.Queries; + +/// +/// EN: Query to get a Conversation by ID with messages. +/// VI: Query để lấy một Conversation theo ID với tin nhắn. +/// +/// EN: Conversation ID / VI: ID conversation +/// EN: Whether to include messages / VI: Có bao gồm tin nhắn không +public record GetConversationByIdQuery( + Guid ConversationId, + bool IncludeMessages = true +) : IRequest; + +/// +/// EN: Query to get conversations with pagination. +/// VI: Query để lấy danh sách conversations với phân trang. +/// +/// EN: Shop ID / VI: ID shop +/// EN: Optional status filter / VI: Lọc theo status tùy chọn +/// EN: Number to skip / VI: Số lượng bỏ qua +/// EN: Number to take / VI: Số lượng lấy +public record GetConversationsQuery( + Guid? ShopId = null, + string? Status = null, + int Skip = 0, + int Take = 20 +) : IRequest; + +/// +/// EN: Result of GetConversationsQuery. +/// VI: Kết quả của GetConversationsQuery. +/// +public record GetConversationsQueryResult( + IReadOnlyList Conversations, + int TotalCount +); diff --git a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Queries/ConversationQueryHandlers.cs b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Queries/ConversationQueryHandlers.cs new file mode 100644 index 00000000..00479345 --- /dev/null +++ b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Queries/ConversationQueryHandlers.cs @@ -0,0 +1,78 @@ +using MediatR; +using FacebookService.API.Application.Dtos; +using FacebookService.Domain.AggregatesModel.ConversationAggregate; + +namespace FacebookService.API.Application.Queries; + +/// +/// EN: Handler for GetConversationByIdQuery. +/// VI: Handler cho GetConversationByIdQuery. +/// +public class GetConversationByIdQueryHandler : IRequestHandler +{ + private readonly IConversationRepository _conversationRepository; + + public GetConversationByIdQueryHandler(IConversationRepository conversationRepository) + { + _conversationRepository = conversationRepository ?? throw new ArgumentNullException(nameof(conversationRepository)); + } + + public async Task Handle( + GetConversationByIdQuery request, + CancellationToken cancellationToken) + { + Conversation? conversation; + + if (request.IncludeMessages) + { + conversation = await _conversationRepository.GetByIdWithMessagesAsync( + request.ConversationId, cancellationToken); + } + else + { + conversation = await _conversationRepository.GetByIdAsync( + request.ConversationId, cancellationToken); + } + + if (conversation is null) + { + return null; + } + + return MapToDto(conversation, request.IncludeMessages); + } + + private static ConversationDto MapToDto(Conversation conversation, bool includeMessages) + { + var messages = includeMessages + ? conversation.Messages.OrderBy(m => m.SentAt).Select(MapMessageToDto).ToList() + : null; + + return new ConversationDto( + conversation.Id, + conversation.CustomerId, + Customer: null, // EN: Not loaded here / VI: Không load ở đây + conversation.PageId, + conversation.Status.Name, + conversation.Channel, + conversation.AssignedAgentId, + conversation.LastMessageAt, + conversation.CreatedAt, + messages + ); + } + + private static MessageDto MapMessageToDto(Message message) + { + return new MessageDto( + message.Id, + message.SenderId, + message.Content, + message.MessageType.Name, + message.Direction.Name, + message.SentAt, + message.FacebookMessageId, + message.Metadata + ); + } +} diff --git a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Queries/CustomerQueries.cs b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Queries/CustomerQueries.cs new file mode 100644 index 00000000..9d548a49 --- /dev/null +++ b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Queries/CustomerQueries.cs @@ -0,0 +1,44 @@ +using MediatR; +using FacebookService.API.Application.Dtos; + +namespace FacebookService.API.Application.Queries; + +/// +/// EN: Query to get a Customer by ID. +/// VI: Query để lấy một Customer theo ID. +/// +/// EN: Customer ID / VI: ID customer +public record GetCustomerByIdQuery(Guid CustomerId) : IRequest; + +/// +/// EN: Query to get a Customer by Facebook User ID. +/// VI: Query để lấy một Customer theo Facebook User ID. +/// +/// EN: Facebook User ID / VI: ID người dùng Facebook +public record GetCustomerByFacebookIdQuery(string FacebookUserId) : IRequest; + +/// +/// EN: Query to get customers with pagination and filtering. +/// VI: Query để lấy danh sách customers với phân trang và lọc. +/// +/// EN: Shop ID / VI: ID shop +/// EN: Optional tag filter / VI: Lọc theo tags tùy chọn +/// EN: Optional search term / VI: Từ khóa tìm kiếm tùy chọn +/// EN: Number to skip / VI: Số lượng bỏ qua +/// EN: Number to take / VI: Số lượng lấy +public record GetCustomersQuery( + Guid? ShopId = null, + IEnumerable? Tags = null, + string? Search = null, + int Skip = 0, + int Take = 20 +) : IRequest; + +/// +/// EN: Result of GetCustomersQuery with pagination info. +/// VI: Kết quả của GetCustomersQuery với thông tin phân trang. +/// +public record GetCustomersQueryResult( + IReadOnlyList Customers, + int TotalCount +); diff --git a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Queries/CustomerQueryHandlers.cs b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Queries/CustomerQueryHandlers.cs new file mode 100644 index 00000000..c2ca5094 --- /dev/null +++ b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Queries/CustomerQueryHandlers.cs @@ -0,0 +1,92 @@ +using MediatR; +using FacebookService.API.Application.Dtos; +using FacebookService.Domain.AggregatesModel.CustomerAggregate; + +namespace FacebookService.API.Application.Queries; + +/// +/// EN: Handler for GetCustomerByIdQuery. +/// VI: Handler cho GetCustomerByIdQuery. +/// +public class GetCustomerByIdQueryHandler : IRequestHandler +{ + private readonly ICustomerRepository _customerRepository; + + public GetCustomerByIdQueryHandler(ICustomerRepository customerRepository) + { + _customerRepository = customerRepository ?? throw new ArgumentNullException(nameof(customerRepository)); + } + + public async Task Handle( + GetCustomerByIdQuery request, + CancellationToken cancellationToken) + { + var customer = await _customerRepository.GetByIdAsync(request.CustomerId, cancellationToken); + + if (customer is null) + { + return null; + } + + return MapToDto(customer); + } + + private static CustomerDto MapToDto(Customer customer) + { + return new CustomerDto( + customer.Id, + customer.FacebookUserId, + customer.Name, + customer.Email, + customer.Phone, + customer.ProfilePicUrl, + customer.Locale, + customer.Timezone, + customer.Tags, + customer.CustomFields, + customer.FirstSeenAt, + customer.LastInteractionAt + ); + } +} + +/// +/// EN: Handler for GetCustomerByFacebookIdQuery. +/// VI: Handler cho GetCustomerByFacebookIdQuery. +/// +public class GetCustomerByFacebookIdQueryHandler : IRequestHandler +{ + private readonly ICustomerRepository _customerRepository; + + public GetCustomerByFacebookIdQueryHandler(ICustomerRepository customerRepository) + { + _customerRepository = customerRepository ?? throw new ArgumentNullException(nameof(customerRepository)); + } + + public async Task Handle( + GetCustomerByFacebookIdQuery request, + CancellationToken cancellationToken) + { + var customer = await _customerRepository.GetByFacebookUserIdAsync(request.FacebookUserId, cancellationToken); + + if (customer is null) + { + return null; + } + + return new CustomerDto( + customer.Id, + customer.FacebookUserId, + customer.Name, + customer.Email, + customer.Phone, + customer.ProfilePicUrl, + customer.Locale, + customer.Timezone, + customer.Tags, + customer.CustomFields, + customer.FirstSeenAt, + customer.LastInteractionAt + ); + } +} diff --git a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Queries/GetSampleQuery.cs b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Queries/GetSampleQuery.cs deleted file mode 100644 index 8f2b8002..00000000 --- a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Queries/GetSampleQuery.cs +++ /dev/null @@ -1,23 +0,0 @@ -using MediatR; - -namespace FacebookService.API.Application.Queries; - -/// -/// EN: Query to get a Sample by ID. -/// VI: Query để lấy một Sample theo ID. -/// -/// EN: Sample ID / VI: ID sample -public record GetSampleQuery(Guid SampleId) : IRequest; - -/// -/// EN: Sample view model for API responses. -/// VI: Sample view model cho API responses. -/// -public record SampleViewModel( - Guid Id, - string Name, - string? Description, - string Status, - DateTime CreatedAt, - DateTime? UpdatedAt -); diff --git a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Queries/GetSampleQueryHandler.cs b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Queries/GetSampleQueryHandler.cs deleted file mode 100644 index f25c5a57..00000000 --- a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Queries/GetSampleQueryHandler.cs +++ /dev/null @@ -1,39 +0,0 @@ -using MediatR; -using FacebookService.Domain.AggregatesModel.SampleAggregate; - -namespace FacebookService.API.Application.Queries; - -/// -/// EN: Handler for GetSampleQuery. -/// VI: Handler cho GetSampleQuery. -/// -public class GetSampleQueryHandler : IRequestHandler -{ - private readonly ISampleRepository _sampleRepository; - - public GetSampleQueryHandler(ISampleRepository sampleRepository) - { - _sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository)); - } - - public async Task Handle( - GetSampleQuery request, - CancellationToken cancellationToken) - { - var sample = await _sampleRepository.GetAsync(request.SampleId); - - if (sample is null) - { - return null; - } - - return new SampleViewModel( - sample.Id, - sample.Name, - sample.Description, - sample.Status.Name, - sample.CreatedAt, - sample.UpdatedAt - ); - } -} diff --git a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Queries/GetSamplesQuery.cs b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Queries/GetSamplesQuery.cs deleted file mode 100644 index 70f5c947..00000000 --- a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Queries/GetSamplesQuery.cs +++ /dev/null @@ -1,9 +0,0 @@ -using MediatR; - -namespace FacebookService.API.Application.Queries; - -/// -/// EN: Query to get all Samples. -/// VI: Query để lấy tất cả Samples. -/// -public record GetSamplesQuery : IRequest>; diff --git a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Queries/GetSamplesQueryHandler.cs b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Queries/GetSamplesQueryHandler.cs deleted file mode 100644 index c6ff620e..00000000 --- a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Queries/GetSamplesQueryHandler.cs +++ /dev/null @@ -1,34 +0,0 @@ -using MediatR; -using FacebookService.Domain.AggregatesModel.SampleAggregate; - -namespace FacebookService.API.Application.Queries; - -/// -/// EN: Handler for GetSamplesQuery. -/// VI: Handler cho GetSamplesQuery. -/// -public class GetSamplesQueryHandler : IRequestHandler> -{ - private readonly ISampleRepository _sampleRepository; - - public GetSamplesQueryHandler(ISampleRepository sampleRepository) - { - _sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository)); - } - - public async Task> Handle( - GetSamplesQuery request, - CancellationToken cancellationToken) - { - var samples = await _sampleRepository.GetAllAsync(); - - return samples.Select(sample => new SampleViewModel( - sample.Id, - sample.Name, - sample.Description, - sample.Status.Name, - sample.CreatedAt, - sample.UpdatedAt - )); - } -} diff --git a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Validations/ChatbotValidators.cs b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Validations/ChatbotValidators.cs new file mode 100644 index 00000000..7065fdbc --- /dev/null +++ b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Validations/ChatbotValidators.cs @@ -0,0 +1,104 @@ +using FluentValidation; +using FacebookService.API.Application.Commands; + +namespace FacebookService.API.Application.Validations; + +/// +/// EN: Validator for ProcessIncomingMessageCommand. +/// VI: Validator cho ProcessIncomingMessageCommand. +/// +public class ProcessIncomingMessageCommandValidator : AbstractValidator +{ + public ProcessIncomingMessageCommandValidator() + { + RuleFor(x => x.PageId) + .NotEmpty() + .WithMessage("Page ID is required / Page ID là bắt buộc") + .MaximumLength(50) + .WithMessage("Page ID must be less than 50 characters / Page ID phải ít hơn 50 ký tự"); + + RuleFor(x => x.SenderId) + .NotEmpty() + .WithMessage("Sender ID is required / Sender ID là bắt buộc") + .MaximumLength(50) + .WithMessage("Sender ID must be less than 50 characters / Sender ID phải ít hơn 50 ký tự"); + + RuleFor(x => x.MessageText) + .NotEmpty() + .WithMessage("Message text is required / Nội dung tin nhắn là bắt buộc"); + } +} + +/// +/// EN: Validator for CreateChatbotFlowCommand. +/// VI: Validator cho CreateChatbotFlowCommand. +/// +public class CreateChatbotFlowCommandValidator : AbstractValidator +{ + private static readonly string[] ValidTriggerTypes = { "GetStarted", "Keyword", "Postback", "Fallback" }; + + public CreateChatbotFlowCommandValidator() + { + RuleFor(x => x.ShopId) + .NotEmpty() + .WithMessage("Shop ID is required / Shop ID là bắt buộc"); + + RuleFor(x => x.Name) + .NotEmpty() + .WithMessage("Flow name is required / Tên flow là bắt buộc") + .MaximumLength(255) + .WithMessage("Flow name must be less than 255 characters / Tên flow phải ít hơn 255 ký tự"); + + RuleFor(x => x.TriggerType) + .NotEmpty() + .WithMessage("Trigger type is required / Loại trigger là bắt buộc") + .Must(x => ValidTriggerTypes.Contains(x, StringComparer.OrdinalIgnoreCase)) + .WithMessage($"Trigger type must be one of: {string.Join(", ", ValidTriggerTypes)}"); + + RuleFor(x => x.TriggerValue) + .NotEmpty() + .WithMessage("Trigger value is required / Giá trị trigger là bắt buộc") + .MaximumLength(255) + .WithMessage("Trigger value must be less than 255 characters / Giá trị trigger phải ít hơn 255 ký tự"); + } +} + +/// +/// EN: Validator for UpsertAIChatbotConfigCommand. +/// VI: Validator cho UpsertAIChatbotConfigCommand. +/// +public class UpsertAIChatbotConfigCommandValidator : AbstractValidator +{ + private static readonly string[] ValidProviders = { "OpenAI", "AzureOpenAI", "Azure" }; + + public UpsertAIChatbotConfigCommandValidator() + { + RuleFor(x => x.ShopId) + .NotEmpty() + .WithMessage("Shop ID is required / Shop ID là bắt buộc"); + + RuleFor(x => x.Provider) + .NotEmpty() + .WithMessage("Provider is required / Provider là bắt buộc") + .Must(x => ValidProviders.Contains(x, StringComparer.OrdinalIgnoreCase)) + .WithMessage($"Provider must be one of: {string.Join(", ", ValidProviders)}"); + + RuleFor(x => x.Model) + .NotEmpty() + .WithMessage("Model is required / Model là bắt buộc") + .MaximumLength(50) + .WithMessage("Model must be less than 50 characters / Model phải ít hơn 50 ký tự"); + + RuleFor(x => x.SystemPrompt) + .NotEmpty() + .WithMessage("System prompt is required / System prompt là bắt buộc"); + + RuleFor(x => x.Temperature) + .InclusiveBetween(0.0f, 2.0f) + .WithMessage("Temperature must be between 0.0 and 2.0 / Temperature phải từ 0.0 đến 2.0"); + + RuleFor(x => x.MaxTokens) + .InclusiveBetween(1, 4096) + .WithMessage("Max tokens must be between 1 and 4096 / Max tokens phải từ 1 đến 4096"); + } +} diff --git a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Validations/CreateCustomerCommandValidator.cs b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Validations/CreateCustomerCommandValidator.cs new file mode 100644 index 00000000..a31d419c --- /dev/null +++ b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Validations/CreateCustomerCommandValidator.cs @@ -0,0 +1,37 @@ +using FluentValidation; +using FacebookService.API.Application.Commands; + +namespace FacebookService.API.Application.Validations; + +/// +/// EN: Validator for CreateCustomerCommand. +/// VI: Validator cho CreateCustomerCommand. +/// +public class CreateCustomerCommandValidator : AbstractValidator +{ + public CreateCustomerCommandValidator() + { + RuleFor(x => x.FacebookUserId) + .NotEmpty() + .WithMessage("Facebook User ID is required / Facebook User ID là bắt buộc") + .MaximumLength(50) + .WithMessage("Facebook User ID must be less than 50 characters / Facebook User ID phải ít hơn 50 ký tự"); + + RuleFor(x => x.Name) + .MaximumLength(255) + .WithMessage("Name must be less than 255 characters / Tên phải ít hơn 255 ký tự") + .When(x => x.Name != null); + + RuleFor(x => x.Email) + .EmailAddress() + .WithMessage("Email format is invalid / Định dạng email không hợp lệ") + .MaximumLength(255) + .WithMessage("Email must be less than 255 characters / Email phải ít hơn 255 ký tự") + .When(x => !string.IsNullOrEmpty(x.Email)); + + RuleFor(x => x.Phone) + .MaximumLength(50) + .WithMessage("Phone must be less than 50 characters / Số điện thoại phải ít hơn 50 ký tự") + .When(x => x.Phone != null); + } +} diff --git a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Validations/CreateSampleCommandValidator.cs b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Validations/CreateSampleCommandValidator.cs deleted file mode 100644 index d4a751df..00000000 --- a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Validations/CreateSampleCommandValidator.cs +++ /dev/null @@ -1,25 +0,0 @@ -using FluentValidation; -using FacebookService.API.Application.Commands; - -namespace FacebookService.API.Application.Validations; - -/// -/// EN: Validator for CreateSampleCommand. -/// VI: Validator cho CreateSampleCommand. -/// -public class CreateSampleCommandValidator : AbstractValidator -{ - public CreateSampleCommandValidator() - { - RuleFor(x => x.Name) - .NotEmpty() - .WithMessage("Name is required / Tên là bắt buộc") - .MaximumLength(200) - .WithMessage("Name must be less than 200 characters / Tên phải ít hơn 200 ký tự"); - - RuleFor(x => x.Description) - .MaximumLength(1000) - .WithMessage("Description must be less than 1000 characters / Mô tả phải ít hơn 1000 ký tự") - .When(x => x.Description != null); - } -} diff --git a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Validations/UpdateSampleCommandValidator.cs b/services/mkt-facebook-service-net/src/FacebookService.API/Application/Validations/UpdateSampleCommandValidator.cs deleted file mode 100644 index a10189e2..00000000 --- a/services/mkt-facebook-service-net/src/FacebookService.API/Application/Validations/UpdateSampleCommandValidator.cs +++ /dev/null @@ -1,29 +0,0 @@ -using FluentValidation; -using FacebookService.API.Application.Commands; - -namespace FacebookService.API.Application.Validations; - -/// -/// EN: Validator for UpdateSampleCommand. -/// VI: Validator cho UpdateSampleCommand. -/// -public class UpdateSampleCommandValidator : AbstractValidator -{ - public UpdateSampleCommandValidator() - { - RuleFor(x => x.SampleId) - .NotEmpty() - .WithMessage("Sample ID is required / ID sample là bắt buộc"); - - RuleFor(x => x.Name) - .NotEmpty() - .WithMessage("Name is required / Tên là bắt buộc") - .MaximumLength(200) - .WithMessage("Name must be less than 200 characters / Tên phải ít hơn 200 ký tự"); - - RuleFor(x => x.Description) - .MaximumLength(1000) - .WithMessage("Description must be less than 1000 characters / Mô tả phải ít hơn 1000 ký tự") - .When(x => x.Description != null); - } -} diff --git a/services/mkt-facebook-service-net/src/FacebookService.API/Controllers/ChatbotsController.cs b/services/mkt-facebook-service-net/src/FacebookService.API/Controllers/ChatbotsController.cs new file mode 100644 index 00000000..e1679fdb --- /dev/null +++ b/services/mkt-facebook-service-net/src/FacebookService.API/Controllers/ChatbotsController.cs @@ -0,0 +1,183 @@ +using Asp.Versioning; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using FacebookService.API.Application.Commands; +using FacebookService.API.Application.Queries; +using FacebookService.API.Application.Dtos; + +namespace FacebookService.API.Controllers; + +/// +/// EN: Controller for Chatbot Flow and AI Config management. +/// VI: Controller quản lý Chatbot Flow và AI Config. +/// +[ApiController] +[ApiVersion("1.0")] +[Route("api/v{version:apiVersion}/chatbots")] +[Produces("application/json")] +public class ChatbotsController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public ChatbotsController(IMediator mediator, ILogger logger) + { + _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + #region Chatbot Flows + + /// + /// EN: Get chatbot flows for a shop. + /// VI: Lấy danh sách chatbot flows của shop. + /// + [HttpGet("flows")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public async Task GetFlows( + [FromQuery] Guid shopId, + [FromQuery] bool activeOnly = false) + { + var flows = await _mediator.Send(new GetChatbotFlowsQuery(shopId, activeOnly)); + return Ok(new { success = true, data = flows }); + } + + /// + /// EN: Get a chatbot flow by ID. + /// VI: Lấy chatbot flow theo ID. + /// + [HttpGet("flows/{id:guid}")] + [ProducesResponseType(typeof(ChatbotFlowDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetFlow(Guid id) + { + var flow = await _mediator.Send(new GetChatbotFlowByIdQuery(id)); + + if (flow is null) + { + return NotFound(new + { + success = false, + error = new { code = "FLOW_NOT_FOUND", message = $"Chatbot flow with ID {id} not found" } + }); + } + + return Ok(new { success = true, data = flow }); + } + + /// + /// EN: Create a new chatbot flow. + /// VI: Tạo chatbot flow mới. + /// + [HttpPost("flows")] + [ProducesResponseType(typeof(CreateChatbotFlowCommandResult), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task CreateFlow([FromBody] CreateFlowRequest request) + { + var command = new CreateChatbotFlowCommand( + request.ShopId, + request.Name, + request.TriggerType, + request.TriggerValue); + + var result = await _mediator.Send(command); + + if (!result.Success) + { + return BadRequest(new + { + success = false, + error = new { code = "FLOW_CREATE_FAILED", message = "Failed to create chatbot flow" } + }); + } + + return CreatedAtAction( + nameof(GetFlow), + new { id = result.Id }, + new { success = true, data = result }); + } + + #endregion + + #region AI Config + + /// + /// EN: Get AI chatbot config for a shop. + /// VI: Lấy cấu hình AI chatbot của shop. + /// + [HttpGet("ai-config")] + [ProducesResponseType(typeof(AIChatbotConfigDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetAIConfig([FromQuery] Guid shopId) + { + var config = await _mediator.Send(new GetAIChatbotConfigQuery(shopId)); + + if (config is null) + { + return NotFound(new + { + success = false, + error = new { code = "AI_CONFIG_NOT_FOUND", message = "AI config not found for this shop" } + }); + } + + return Ok(new { success = true, data = config }); + } + + /// + /// EN: Create or update AI chatbot config. + /// VI: Tạo hoặc cập nhật cấu hình AI chatbot. + /// + [HttpPut("ai-config")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task UpsertAIConfig([FromBody] UpsertAIConfigRequest request) + { + var command = new UpsertAIChatbotConfigCommand( + request.ShopId, + request.Provider, + request.Model, + request.SystemPrompt, + request.Temperature, + request.MaxTokens); + + var result = await _mediator.Send(command); + + if (!result.Success) + { + return BadRequest(new + { + success = false, + error = new { code = "AI_CONFIG_FAILED", message = "Failed to save AI config" } + }); + } + + return Ok(new { + success = true, + data = result, + message = result.IsNew ? "AI config created" : "AI config updated" + }); + } + + #endregion +} + +#region Request Models + +public record CreateFlowRequest( + Guid ShopId, + string Name, + string TriggerType, + string TriggerValue +); + +public record UpsertAIConfigRequest( + Guid ShopId, + string Provider, + string Model, + string SystemPrompt, + float Temperature = 0.7f, + int MaxTokens = 500 +); + +#endregion diff --git a/services/mkt-facebook-service-net/src/FacebookService.API/Controllers/ConversationsController.cs b/services/mkt-facebook-service-net/src/FacebookService.API/Controllers/ConversationsController.cs new file mode 100644 index 00000000..a46f2bd8 --- /dev/null +++ b/services/mkt-facebook-service-net/src/FacebookService.API/Controllers/ConversationsController.cs @@ -0,0 +1,71 @@ +using Asp.Versioning; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using FacebookService.API.Application.Queries; +using FacebookService.API.Application.Dtos; + +namespace FacebookService.API.Controllers; + +/// +/// EN: Controller for Conversation management. +/// VI: Controller quản lý Conversation. +/// +[ApiController] +[ApiVersion("1.0")] +[Route("api/v{version:apiVersion}/conversations")] +[Produces("application/json")] +public class ConversationsController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public ConversationsController(IMediator mediator, ILogger logger) + { + _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// EN: Get a conversation by ID. + /// VI: Lấy conversation theo ID. + /// + [HttpGet("{id:guid}")] + [ProducesResponseType(typeof(ConversationDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetConversation( + Guid id, + [FromQuery] bool includeMessages = true) + { + var conversation = await _mediator.Send( + new GetConversationByIdQuery(id, includeMessages)); + + if (conversation is null) + { + return NotFound(new + { + success = false, + error = new { code = "CONVERSATION_NOT_FOUND", message = $"Conversation with ID {id} not found" } + }); + } + + return Ok(new { success = true, data = conversation }); + } + + /// + /// EN: Get conversations for a shop with pagination. + /// VI: Lấy danh sách conversations của shop với phân trang. + /// + [HttpGet] + [ProducesResponseType(typeof(GetConversationsQueryResult), StatusCodes.Status200OK)] + public async Task GetConversations( + [FromQuery] Guid? shopId = null, + [FromQuery] string? status = null, + [FromQuery] int skip = 0, + [FromQuery] int take = 20) + { + var result = await _mediator.Send( + new GetConversationsQuery(shopId, status, skip, take)); + + return Ok(new { success = true, data = result.Conversations, total = result.TotalCount }); + } +} diff --git a/services/mkt-facebook-service-net/src/FacebookService.API/Controllers/CustomersController.cs b/services/mkt-facebook-service-net/src/FacebookService.API/Controllers/CustomersController.cs new file mode 100644 index 00000000..b9ed2d19 --- /dev/null +++ b/services/mkt-facebook-service-net/src/FacebookService.API/Controllers/CustomersController.cs @@ -0,0 +1,143 @@ +using Asp.Versioning; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using FacebookService.API.Application.Commands; +using FacebookService.API.Application.Queries; +using FacebookService.API.Application.Dtos; + +namespace FacebookService.API.Controllers; + +/// +/// EN: Controller for Customer management. +/// VI: Controller quản lý Customer. +/// +[ApiController] +[ApiVersion("1.0")] +[Route("api/v{version:apiVersion}/customers")] +[Produces("application/json")] +public class CustomersController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public CustomersController(IMediator mediator, ILogger logger) + { + _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// EN: Get a customer by ID. + /// VI: Lấy customer theo ID. + /// + [HttpGet("{id:guid}")] + [ProducesResponseType(typeof(CustomerDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetCustomer(Guid id) + { + var customer = await _mediator.Send(new GetCustomerByIdQuery(id)); + + if (customer is null) + { + return NotFound(new + { + success = false, + error = new { code = "CUSTOMER_NOT_FOUND", message = $"Customer with ID {id} not found" } + }); + } + + return Ok(new { success = true, data = customer }); + } + + /// + /// EN: Get a customer by Facebook User ID. + /// VI: Lấy customer theo Facebook User ID. + /// + [HttpGet("facebook/{facebookUserId}")] + [ProducesResponseType(typeof(CustomerDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetCustomerByFacebookId(string facebookUserId) + { + var customer = await _mediator.Send(new GetCustomerByFacebookIdQuery(facebookUserId)); + + if (customer is null) + { + return NotFound(new + { + success = false, + error = new { code = "CUSTOMER_NOT_FOUND", message = $"Customer with Facebook ID {facebookUserId} not found" } + }); + } + + return Ok(new { success = true, data = customer }); + } + + /// + /// EN: Create or update a customer. + /// VI: Tạo hoặc cập nhật customer. + /// + [HttpPost] + [ProducesResponseType(typeof(CreateCustomerCommandResult), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task CreateCustomer([FromBody] CreateCustomerRequest request) + { + var command = new CreateCustomerCommand( + request.FacebookUserId, + request.Name, + request.Email, + request.Phone, + request.ProfilePicUrl, + request.Locale, + request.Timezone); + + var result = await _mediator.Send(command); + + return CreatedAtAction( + nameof(GetCustomer), + new { id = result.Id }, + new { success = true, data = result }); + } + + /// + /// EN: Update customer tags and custom fields. + /// VI: Cập nhật tags và custom fields của customer. + /// + [HttpPatch("{id:guid}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task UpdateCustomer(Guid id, [FromBody] UpdateCustomerRequest request) + { + var command = new UpdateCustomerCommand(id, request.Tags, request.CustomFields); + var result = await _mediator.Send(command); + + if (!result.Success) + { + return NotFound(new + { + success = false, + error = new { code = "CUSTOMER_NOT_FOUND", message = $"Customer with ID {id} not found" } + }); + } + + return Ok(new { success = true, message = "Customer updated successfully" }); + } +} + +#region Request Models + +public record CreateCustomerRequest( + string FacebookUserId, + string? Name = null, + string? Email = null, + string? Phone = null, + string? ProfilePicUrl = null, + string? Locale = null, + string? Timezone = null +); + +public record UpdateCustomerRequest( + IEnumerable? Tags = null, + Dictionary? CustomFields = null +); + +#endregion diff --git a/services/mkt-facebook-service-net/src/FacebookService.API/Controllers/SamplesController.cs b/services/mkt-facebook-service-net/src/FacebookService.API/Controllers/SamplesController.cs deleted file mode 100644 index 6f400561..00000000 --- a/services/mkt-facebook-service-net/src/FacebookService.API/Controllers/SamplesController.cs +++ /dev/null @@ -1,200 +0,0 @@ -using Asp.Versioning; -using MediatR; -using Microsoft.AspNetCore.Mvc; -using FacebookService.API.Application.Commands; -using FacebookService.API.Application.Queries; - -namespace FacebookService.API.Controllers; - -/// -/// EN: Controller for Sample CRUD operations using CQRS pattern. -/// VI: Controller cho các thao tác CRUD Sample sử dụng pattern CQRS. -/// -[ApiController] -[ApiVersion("1.0")] -[Route("api/v{version:apiVersion}/[controller]")] -[Produces("application/json")] -public class SamplesController : ControllerBase -{ - private readonly IMediator _mediator; - private readonly ILogger _logger; - - public SamplesController(IMediator mediator, ILogger logger) - { - _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - /// - /// EN: Get all samples. - /// VI: Lấy tất cả samples. - /// - /// EN: List of samples / VI: Danh sách samples - [HttpGet] - [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - public async Task GetSamples() - { - var samples = await _mediator.Send(new GetSamplesQuery()); - return Ok(new { success = true, data = samples }); - } - - /// - /// EN: Get a sample by ID. - /// VI: Lấy một sample theo ID. - /// - /// EN: Sample ID / VI: ID sample - /// EN: Sample details / VI: Chi tiết sample - [HttpGet("{id:guid}")] - [ProducesResponseType(typeof(SampleViewModel), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task GetSample(Guid id) - { - var sample = await _mediator.Send(new GetSampleQuery(id)); - - if (sample is null) - { - return NotFound(new - { - success = false, - error = new - { - code = "SAMPLE_NOT_FOUND", - message = $"Sample with ID {id} not found / Sample với ID {id} không tìm thấy" - } - }); - } - - return Ok(new { success = true, data = sample }); - } - - /// - /// EN: Create a new sample. - /// VI: Tạo một sample mới. - /// - /// EN: Create request / VI: Request tạo - /// EN: Created sample ID / VI: ID sample đã tạo - [HttpPost] - [ProducesResponseType(typeof(CreateSampleCommandResult), StatusCodes.Status201Created)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - public async Task CreateSample([FromBody] CreateSampleRequest request) - { - var command = new CreateSampleCommand(request.Name, request.Description); - var result = await _mediator.Send(command); - - return CreatedAtAction( - nameof(GetSample), - new { id = result.Id }, - new { success = true, data = result }); - } - - /// - /// EN: Update an existing sample. - /// VI: Cập nhật một sample đã tồn tại. - /// - /// EN: Sample ID / VI: ID sample - /// EN: Update request / VI: Request cập nhật - /// EN: Success status / VI: Trạng thái thành công - [HttpPut("{id:guid}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task UpdateSample(Guid id, [FromBody] UpdateSampleRequest request) - { - var command = new UpdateSampleCommand(id, request.Name, request.Description); - var result = await _mediator.Send(command); - - if (!result) - { - return NotFound(new - { - success = false, - error = new - { - code = "SAMPLE_NOT_FOUND", - message = $"Sample with ID {id} not found / Sample với ID {id} không tìm thấy" - } - }); - } - - return Ok(new { success = true, message = "Sample updated successfully / Sample đã cập nhật thành công" }); - } - - /// - /// EN: Delete a sample. - /// VI: Xóa một sample. - /// - /// EN: Sample ID / VI: ID sample - /// EN: Success status / VI: Trạng thái thành công - [HttpDelete("{id:guid}")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task DeleteSample(Guid id) - { - var command = new DeleteSampleCommand(id); - var result = await _mediator.Send(command); - - if (!result) - { - return NotFound(new - { - success = false, - error = new - { - code = "SAMPLE_NOT_FOUND", - message = $"Sample with ID {id} not found / Sample với ID {id} không tìm thấy" - } - }); - } - - return NoContent(); - } - - /// - /// EN: Change sample status. - /// VI: Thay đổi trạng thái sample. - /// - /// EN: Sample ID / VI: ID sample - /// EN: Status change request / VI: Request thay đổi trạng thái - /// EN: Success status / VI: Trạng thái thành công - [HttpPatch("{id:guid}/status")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task ChangeSampleStatus(Guid id, [FromBody] ChangeStatusRequest request) - { - var command = new ChangeSampleStatusCommand(id, request.Status); - var result = await _mediator.Send(command); - - if (!result) - { - return BadRequest(new - { - success = false, - error = new - { - code = "STATUS_CHANGE_FAILED", - message = "Failed to change sample status / Thay đổi trạng thái sample thất bại" - } - }); - } - - return Ok(new { success = true, message = "Sample status changed successfully / Trạng thái sample đã thay đổi thành công" }); - } -} - -/// -/// EN: Request model for creating a sample. -/// VI: Model request để tạo sample. -/// -public record CreateSampleRequest(string Name, string? Description); - -/// -/// EN: Request model for updating a sample. -/// VI: Model request để cập nhật sample. -/// -public record UpdateSampleRequest(string Name, string? Description); - -/// -/// EN: Request model for changing sample status. -/// VI: Model request để thay đổi trạng thái sample. -/// -public record ChangeStatusRequest(string Status); diff --git a/services/mkt-facebook-service-net/src/FacebookService.API/Controllers/WebhooksController.cs b/services/mkt-facebook-service-net/src/FacebookService.API/Controllers/WebhooksController.cs new file mode 100644 index 00000000..1a0485f0 --- /dev/null +++ b/services/mkt-facebook-service-net/src/FacebookService.API/Controllers/WebhooksController.cs @@ -0,0 +1,229 @@ +using Asp.Versioning; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using FacebookService.API.Application.Commands; +using System.Security.Cryptography; +using System.Text; + +namespace FacebookService.API.Controllers; + +/// +/// EN: Controller for Facebook Messenger webhooks. +/// VI: Controller cho Facebook Messenger webhooks. +/// +[ApiController] +[Route("api/webhooks/facebook")] +[Produces("application/json")] +public class WebhooksController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly ILogger _logger; + private readonly IConfiguration _configuration; + + public WebhooksController( + IMediator mediator, + ILogger logger, + IConfiguration configuration) + { + _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + } + + /// + /// EN: Webhook verification endpoint for Facebook. + /// VI: Endpoint xác thực webhook cho Facebook. + /// + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public IActionResult VerifyWebhook( + [FromQuery(Name = "hub.mode")] string mode, + [FromQuery(Name = "hub.verify_token")] string verifyToken, + [FromQuery(Name = "hub.challenge")] string challenge) + { + _logger.LogInformation("Webhook verification request received"); + + var expectedToken = _configuration["Facebook:VerifyToken"] + ?? _configuration["FACEBOOK_VERIFY_TOKEN"]; + + if (mode == "subscribe" && verifyToken == expectedToken) + { + _logger.LogInformation("Webhook verified successfully"); + return Ok(challenge); + } + + _logger.LogWarning("Webhook verification failed: invalid token"); + return Forbid(); + } + + /// + /// EN: Receive webhook events from Facebook Messenger. + /// VI: Nhận webhook events từ Facebook Messenger. + /// + [HttpPost] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task ReceiveWebhook([FromBody] FacebookWebhookPayload payload) + { + _logger.LogInformation("Webhook event received: {Object}", payload?.Object); + + // EN: Verify signature if configured / VI: Xác thực signature nếu được cấu hình + var appSecret = _configuration["Facebook:AppSecret"] ?? _configuration["FACEBOOK_APP_SECRET"]; + if (!string.IsNullOrEmpty(appSecret)) + { + var signature = Request.Headers["X-Hub-Signature-256"].FirstOrDefault(); + if (!await VerifySignatureAsync(appSecret, signature)) + { + _logger.LogWarning("Invalid webhook signature"); + return BadRequest(new { error = "Invalid signature" }); + } + } + + if (payload?.Object != "page") + { + return Ok("EVENT_RECEIVED"); + } + + // EN: Process each entry / VI: Xử lý từng entry + foreach (var entry in payload.Entry ?? Enumerable.Empty()) + { + foreach (var messaging in entry.Messaging ?? Enumerable.Empty()) + { + if (messaging.Message != null) + { + await ProcessMessageAsync(entry.Id, messaging); + } + else if (messaging.Postback != null) + { + await ProcessPostbackAsync(entry.Id, messaging); + } + } + } + + // EN: Return 200 quickly to avoid timeout / VI: Trả về 200 nhanh để tránh timeout + return Ok("EVENT_RECEIVED"); + } + + private async Task ProcessMessageAsync(string pageId, MessagingEvent messaging) + { + try + { + var command = new ProcessIncomingMessageCommand( + PageId: pageId, + SenderId: messaging.Sender?.Id ?? "", + MessageText: messaging.Message?.Text ?? "", + FacebookMessageId: messaging.Message?.Mid, + Timestamp: messaging.Timestamp, + QuickReplyPayload: messaging.Message?.QuickReply?.Payload + ); + + var result = await _mediator.Send(command); + + _logger.LogInformation( + "Message processed: ConversationId={ConversationId}, IsNew={IsNew}", + result.ConversationId, result.IsNewConversation); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing message from sender: {SenderId}", + messaging.Sender?.Id); + } + } + + private async Task ProcessPostbackAsync(string pageId, MessagingEvent messaging) + { + try + { + var command = new ProcessIncomingMessageCommand( + PageId: pageId, + SenderId: messaging.Sender?.Id ?? "", + MessageText: messaging.Postback?.Title ?? "", + QuickReplyPayload: messaging.Postback?.Payload + ); + + await _mediator.Send(command); + + _logger.LogInformation("Postback processed for sender: {SenderId}", + messaging.Sender?.Id); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing postback from sender: {SenderId}", + messaging.Sender?.Id); + } + } + + private async Task VerifySignatureAsync(string appSecret, string? signature) + { + if (string.IsNullOrEmpty(signature)) + return false; + + // EN: Read raw body / VI: Đọc raw body + Request.Body.Position = 0; + using var reader = new StreamReader(Request.Body, Encoding.UTF8, leaveOpen: true); + var body = await reader.ReadToEndAsync(); + Request.Body.Position = 0; + + // EN: Calculate expected signature / VI: Tính signature mong đợi + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(appSecret)); + var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(body)); + var expectedSignature = "sha256=" + BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); + + return signature == expectedSignature; + } +} + +#region Webhook Payload Models + +/// +/// EN: Facebook webhook payload model. +/// VI: Model payload webhook Facebook. +/// +public record FacebookWebhookPayload +{ + public string? Object { get; init; } + public List? Entry { get; init; } +} + +public record WebhookEntry +{ + public string Id { get; init; } = ""; + public long Time { get; init; } + public List? Messaging { get; init; } +} + +public record MessagingEvent +{ + public Sender? Sender { get; init; } + public Recipient? Recipient { get; init; } + public long Timestamp { get; init; } + public MessagePayload? Message { get; init; } + public PostbackPayload? Postback { get; init; } +} + +public record Sender { public string? Id { get; init; } } +public record Recipient { public string? Id { get; init; } } + +public record MessagePayload +{ + public string? Mid { get; init; } + public string? Text { get; init; } + public QuickReplyPayload? QuickReply { get; init; } + public List? Attachments { get; init; } +} + +public record QuickReplyPayload { public string? Payload { get; init; } } +public record PostbackPayload +{ + public string? Title { get; init; } + public string? Payload { get; init; } +} +public record AttachmentPayload +{ + public string? Type { get; init; } + public AttachmentInnerPayload? Payload { get; init; } +} +public record AttachmentInnerPayload { public string? Url { get; init; } } + +#endregion diff --git a/services/mkt-facebook-service-net/src/FacebookService.Domain/AggregatesModel/SampleAggregate/ISampleRepository.cs b/services/mkt-facebook-service-net/src/FacebookService.Domain/AggregatesModel/SampleAggregate/ISampleRepository.cs deleted file mode 100644 index 06b714c2..00000000 --- a/services/mkt-facebook-service-net/src/FacebookService.Domain/AggregatesModel/SampleAggregate/ISampleRepository.cs +++ /dev/null @@ -1,61 +0,0 @@ -using FacebookService.Domain.SeedWork; - -namespace FacebookService.Domain.AggregatesModel.SampleAggregate; - -/// -/// EN: Repository interface for Sample aggregate. -/// VI: Interface repository cho Sample aggregate. -/// -/// -/// EN: Following repository pattern, this interface defines the contract -/// for data access operations on Sample aggregate. -/// VI: Theo pattern repository, interface này định nghĩa contract -/// cho các thao tác truy cập dữ liệu trên Sample aggregate. -/// -public interface ISampleRepository : IRepository -{ - /// - /// EN: Get a sample by its ID. - /// VI: Lấy một sample theo ID. - /// - /// EN: The sample ID / VI: ID của sample - /// EN: The sample or null if not found / VI: Sample hoặc null nếu không tìm thấy - Task GetAsync(Guid sampleId); - - /// - /// EN: Get all samples. - /// VI: Lấy tất cả samples. - /// - /// EN: List of samples / VI: Danh sách samples - Task> GetAllAsync(); - - /// - /// EN: Add a new sample. - /// VI: Thêm một sample mới. - /// - /// EN: The sample to add / VI: Sample cần thêm - /// EN: The added sample / VI: Sample đã thêm - Sample Add(Sample sample); - - /// - /// EN: Update an existing sample. - /// VI: Cập nhật một sample đã tồn tại. - /// - /// EN: The sample to update / VI: Sample cần cập nhật - void Update(Sample sample); - - /// - /// EN: Delete a sample. - /// VI: Xóa một sample. - /// - /// EN: The sample to delete / VI: Sample cần xóa - void Delete(Sample sample); - - /// - /// EN: Get samples by status. - /// VI: Lấy samples theo trạng thái. - /// - /// EN: The status ID / VI: ID trạng thái - /// EN: List of samples with given status / VI: Danh sách samples với trạng thái cho trước - Task> GetByStatusAsync(int statusId); -} diff --git a/services/mkt-facebook-service-net/src/FacebookService.Domain/AggregatesModel/SampleAggregate/Sample.cs b/services/mkt-facebook-service-net/src/FacebookService.Domain/AggregatesModel/SampleAggregate/Sample.cs deleted file mode 100644 index 8e6844bc..00000000 --- a/services/mkt-facebook-service-net/src/FacebookService.Domain/AggregatesModel/SampleAggregate/Sample.cs +++ /dev/null @@ -1,158 +0,0 @@ -using FacebookService.Domain.Events; -using FacebookService.Domain.Exceptions; -using FacebookService.Domain.SeedWork; - -namespace FacebookService.Domain.AggregatesModel.SampleAggregate; - -/// -/// EN: Sample aggregate root demonstrating DDD patterns. -/// VI: Sample aggregate root minh họa các pattern DDD. -/// -public class Sample : Entity, IAggregateRoot -{ - // EN: Private fields for encapsulation - // VI: Fields private để đóng gói - private string _name = null!; - private string? _description; - private SampleStatus _status = null!; - private DateTime _createdAt; - private DateTime? _updatedAt; - - /// - /// EN: Sample name (required). - /// VI: Tên sample (bắt buộc). - /// - public string Name => _name; - - /// - /// EN: Optional description. - /// VI: Mô tả tùy chọn. - /// - public string? Description => _description; - - /// - /// EN: Current status. - /// VI: Trạng thái hiện tại. - /// - public SampleStatus 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 Sample() - { - } - - /// - /// EN: Create a new Sample with required information. - /// VI: Tạo một Sample mới với thông tin bắt buộc. - /// - /// EN: Sample name / VI: Tên sample - /// EN: Optional description / VI: Mô tả tùy chọn - public Sample(string name, string? description = null) : this() - { - if (string.IsNullOrWhiteSpace(name)) - throw new SampleDomainException("Sample name cannot be empty"); - - Id = Guid.NewGuid(); - _name = name; - _description = description; - _status = SampleStatus.Draft; - StatusId = SampleStatus.Draft.Id; - _createdAt = DateTime.UtcNow; - - // EN: Add domain event for creation - // VI: Thêm domain event cho việc tạo - AddDomainEvent(new SampleCreatedDomainEvent(this)); - } - - /// - /// EN: Update sample information. - /// VI: Cập nhật thông tin sample. - /// - public void Update(string name, string? description) - { - if (string.IsNullOrWhiteSpace(name)) - throw new SampleDomainException("Sample name cannot be empty"); - - if (_status == SampleStatus.Cancelled) - throw new SampleDomainException("Cannot update a cancelled sample"); - - _name = name; - _description = description; - _updatedAt = DateTime.UtcNow; - } - - /// - /// EN: Activate the sample. - /// VI: Kích hoạt sample. - /// - public void Activate() - { - if (_status != SampleStatus.Draft) - throw new SampleDomainException("Only draft samples can be activated"); - - var previousStatus = _status; - _status = SampleStatus.Active; - StatusId = SampleStatus.Active.Id; - _updatedAt = DateTime.UtcNow; - - AddDomainEvent(new SampleStatusChangedDomainEvent(Id, previousStatus, _status)); - } - - /// - /// EN: Complete the sample. - /// VI: Hoàn thành sample. - /// - public void Complete() - { - if (_status != SampleStatus.Active) - throw new SampleDomainException("Only active samples can be completed"); - - var previousStatus = _status; - _status = SampleStatus.Completed; - StatusId = SampleStatus.Completed.Id; - _updatedAt = DateTime.UtcNow; - - AddDomainEvent(new SampleStatusChangedDomainEvent(Id, previousStatus, _status)); - } - - /// - /// EN: Cancel the sample. - /// VI: Hủy sample. - /// - public void Cancel() - { - if (_status == SampleStatus.Completed) - throw new SampleDomainException("Cannot cancel a completed sample"); - - if (_status == SampleStatus.Cancelled) - throw new SampleDomainException("Sample is already cancelled"); - - var previousStatus = _status; - _status = SampleStatus.Cancelled; - StatusId = SampleStatus.Cancelled.Id; - _updatedAt = DateTime.UtcNow; - - AddDomainEvent(new SampleStatusChangedDomainEvent(Id, previousStatus, _status)); - } -} diff --git a/services/mkt-facebook-service-net/src/FacebookService.Domain/AggregatesModel/SampleAggregate/SampleStatus.cs b/services/mkt-facebook-service-net/src/FacebookService.Domain/AggregatesModel/SampleAggregate/SampleStatus.cs deleted file mode 100644 index e11060d6..00000000 --- a/services/mkt-facebook-service-net/src/FacebookService.Domain/AggregatesModel/SampleAggregate/SampleStatus.cs +++ /dev/null @@ -1,77 +0,0 @@ -using FacebookService.Domain.SeedWork; - -namespace FacebookService.Domain.AggregatesModel.SampleAggregate; - -/// -/// EN: Sample status enumeration following type-safe enum pattern. -/// VI: Enumeration trạng thái Sample theo pattern enum an toàn kiểu. -/// -public class SampleStatus : Enumeration -{ - /// - /// EN: Draft status - initial state - /// VI: Trạng thái nháp - trạng thái ban đầu - /// - public static SampleStatus Draft = new(1, nameof(Draft)); - - /// - /// EN: Active status - ready for use - /// VI: Trạng thái hoạt động - sẵn sàng sử dụng - /// - public static SampleStatus Active = new(2, nameof(Active)); - - /// - /// EN: Completed status - finished processing - /// VI: Trạng thái hoàn thành - đã xử lý xong - /// - public static SampleStatus Completed = new(3, nameof(Completed)); - - /// - /// EN: Cancelled status - cancelled by user - /// VI: Trạng thái đã hủy - bị hủy bởi người dùng - /// - public static SampleStatus Cancelled = new(4, nameof(Cancelled)); - - public SampleStatus(int id, string name) : base(id, name) - { - } - - /// - /// EN: Get all available statuses. - /// VI: Lấy tất cả các trạng thái có sẵn. - /// - public static IEnumerable List() => GetAll(); - - /// - /// EN: Parse status from name. - /// VI: Parse trạng thái từ tên. - /// - public static SampleStatus FromName(string name) - { - var status = List().SingleOrDefault(s => - string.Equals(s.Name, name, StringComparison.CurrentCultureIgnoreCase)); - - if (status is null) - { - throw new ArgumentException($"Possible values for SampleStatus: {string.Join(",", List().Select(s => s.Name))}"); - } - - return status; - } - - /// - /// EN: Parse status from ID. - /// VI: Parse trạng thái từ ID. - /// - public static SampleStatus From(int id) - { - var status = List().SingleOrDefault(s => s.Id == id); - - if (status is null) - { - throw new ArgumentException($"Possible values for SampleStatus: {string.Join(",", List().Select(s => s.Name))}"); - } - - return status; - } -} diff --git a/services/mkt-facebook-service-net/src/FacebookService.Domain/Events/SampleCreatedDomainEvent.cs b/services/mkt-facebook-service-net/src/FacebookService.Domain/Events/SampleCreatedDomainEvent.cs deleted file mode 100644 index f1e4906b..00000000 --- a/services/mkt-facebook-service-net/src/FacebookService.Domain/Events/SampleCreatedDomainEvent.cs +++ /dev/null @@ -1,22 +0,0 @@ -using MediatR; -using FacebookService.Domain.AggregatesModel.SampleAggregate; - -namespace FacebookService.Domain.Events; - -/// -/// EN: Domain event raised when a new Sample is created. -/// VI: Domain event được phát ra khi một Sample mới được tạo. -/// -public class SampleCreatedDomainEvent : INotification -{ - /// - /// EN: The newly created sample. - /// VI: Sample mới được tạo. - /// - public Sample Sample { get; } - - public SampleCreatedDomainEvent(Sample sample) - { - Sample = sample; - } -} diff --git a/services/mkt-facebook-service-net/src/FacebookService.Domain/Events/SampleStatusChangedDomainEvent.cs b/services/mkt-facebook-service-net/src/FacebookService.Domain/Events/SampleStatusChangedDomainEvent.cs deleted file mode 100644 index 5886040e..00000000 --- a/services/mkt-facebook-service-net/src/FacebookService.Domain/Events/SampleStatusChangedDomainEvent.cs +++ /dev/null @@ -1,39 +0,0 @@ -using MediatR; -using FacebookService.Domain.AggregatesModel.SampleAggregate; - -namespace FacebookService.Domain.Events; - -/// -/// EN: Domain event raised when Sample status changes. -/// VI: Domain event được phát ra khi trạng thái Sample thay đổi. -/// -public class SampleStatusChangedDomainEvent : INotification -{ - /// - /// EN: The sample ID. - /// VI: ID của sample. - /// - public Guid SampleId { get; } - - /// - /// EN: Previous status before the change. - /// VI: Trạng thái trước khi thay đổi. - /// - public SampleStatus PreviousStatus { get; } - - /// - /// EN: New status after the change. - /// VI: Trạng thái mới sau khi thay đổi. - /// - public SampleStatus NewStatus { get; } - - public SampleStatusChangedDomainEvent( - Guid sampleId, - SampleStatus previousStatus, - SampleStatus newStatus) - { - SampleId = sampleId; - PreviousStatus = previousStatus; - NewStatus = newStatus; - } -} diff --git a/services/mkt-facebook-service-net/src/FacebookService.Domain/Exceptions/SampleDomainException.cs b/services/mkt-facebook-service-net/src/FacebookService.Domain/Exceptions/SampleDomainException.cs deleted file mode 100644 index f5a85db7..00000000 --- a/services/mkt-facebook-service-net/src/FacebookService.Domain/Exceptions/SampleDomainException.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace FacebookService.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/mkt-facebook-service-net/src/FacebookService.Infrastructure/DependencyInjection.cs b/services/mkt-facebook-service-net/src/FacebookService.Infrastructure/DependencyInjection.cs index 3e509920..8f3e5214 100644 --- a/services/mkt-facebook-service-net/src/FacebookService.Infrastructure/DependencyInjection.cs +++ b/services/mkt-facebook-service-net/src/FacebookService.Infrastructure/DependencyInjection.cs @@ -6,6 +6,9 @@ using FacebookService.Domain.AggregatesModel.ConversationAggregate; using FacebookService.Domain.AggregatesModel.ChatbotAggregate; using FacebookService.Infrastructure.Idempotency; using FacebookService.Infrastructure.Repositories; +using FacebookService.Infrastructure.ExternalServices; +using Polly; +using Polly.Extensions.Http; namespace FacebookService.Infrastructure; @@ -57,6 +60,36 @@ public static class DependencyInjection // EN: Register idempotency services / VI: Đăng ký idempotency services services.AddScoped(); + // EN: Register Facebook Messenger client / VI: Đăng ký Facebook Messenger client + services.AddHttpClient() + .AddPolicyHandler(FacebookMessengerClient.GetRetryPolicy()); + + // EN: Register AI clients / VI: Đăng ký AI clients + var aiProvider = configuration["AI:Provider"] + ?? configuration["AI_PROVIDER"] + ?? "openai"; + + if (aiProvider.Equals("azure", StringComparison.OrdinalIgnoreCase) || + aiProvider.Equals("azureopenai", StringComparison.OrdinalIgnoreCase)) + { + services.AddHttpClient() + .AddPolicyHandler(GetAIRetryPolicy()); + } + else + { + services.AddHttpClient() + .AddPolicyHandler(GetAIRetryPolicy()); + } + return services; } + + private static IAsyncPolicy GetAIRetryPolicy() + { + return HttpPolicyExtensions + .HandleTransientHttpError() + .OrResult(msg => msg.StatusCode == System.Net.HttpStatusCode.TooManyRequests) + .WaitAndRetryAsync(3, retryAttempt => + TimeSpan.FromSeconds(Math.Pow(2, retryAttempt))); + } } diff --git a/services/mkt-facebook-service-net/src/FacebookService.Infrastructure/EntityConfigurations/SampleEntityTypeConfiguration.cs b/services/mkt-facebook-service-net/src/FacebookService.Infrastructure/EntityConfigurations/SampleEntityTypeConfiguration.cs deleted file mode 100644 index 746bf880..00000000 --- a/services/mkt-facebook-service-net/src/FacebookService.Infrastructure/EntityConfigurations/SampleEntityTypeConfiguration.cs +++ /dev/null @@ -1,61 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using FacebookService.Domain.AggregatesModel.SampleAggregate; - -namespace FacebookService.Infrastructure.EntityConfigurations; - -/// -/// EN: EF Core configuration for Sample entity. -/// VI: Cấu hình EF Core cho entity Sample. -/// -public class SampleEntityTypeConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - // EN: Table name / VI: Tên bảng - builder.ToTable("samples"); - - // EN: Primary key / VI: Khóa chính - builder.HasKey(s => s.Id); - - // EN: Ignore domain events (not persisted) - // VI: Bỏ qua domain events (không lưu) - builder.Ignore(s => s.DomainEvents); - - // EN: Properties / VI: Các thuộc tính - builder.Property(s => s.Id) - .HasColumnName("id") - .IsRequired(); - - builder.Property("_name") - .HasColumnName("name") - .HasMaxLength(200) - .IsRequired(); - - builder.Property("_description") - .HasColumnName("description") - .HasMaxLength(1000); - - builder.Property("_createdAt") - .HasColumnName("created_at") - .IsRequired(); - - builder.Property("_updatedAt") - .HasColumnName("updated_at"); - - // EN: Status relationship / VI: Quan hệ với Status - builder.Property(s => s.StatusId) - .HasColumnName("status_id") - .IsRequired(); - - builder.HasOne(s => s.Status) - .WithMany() - .HasForeignKey(s => s.StatusId) - .OnDelete(DeleteBehavior.Restrict); - - // EN: Indexes / VI: Các index - builder.HasIndex("_name"); - builder.HasIndex(s => s.StatusId); - builder.HasIndex("_createdAt"); - } -} diff --git a/services/mkt-facebook-service-net/src/FacebookService.Infrastructure/EntityConfigurations/SampleStatusEntityTypeConfiguration.cs b/services/mkt-facebook-service-net/src/FacebookService.Infrastructure/EntityConfigurations/SampleStatusEntityTypeConfiguration.cs deleted file mode 100644 index ed97e62d..00000000 --- a/services/mkt-facebook-service-net/src/FacebookService.Infrastructure/EntityConfigurations/SampleStatusEntityTypeConfiguration.cs +++ /dev/null @@ -1,39 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using FacebookService.Domain.AggregatesModel.SampleAggregate; - -namespace FacebookService.Infrastructure.EntityConfigurations; - -/// -/// EN: EF Core configuration for SampleStatus enumeration. -/// VI: Cấu hình EF Core cho enumeration SampleStatus. -/// -public class SampleStatusEntityTypeConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - // EN: Table name / VI: Tên bảng - builder.ToTable("sample_statuses"); - - // EN: Primary key / VI: Khóa chính - builder.HasKey(s => s.Id); - - builder.Property(s => s.Id) - .HasColumnName("id") - .ValueGeneratedNever() - .IsRequired(); - - builder.Property(s => s.Name) - .HasColumnName("name") - .HasMaxLength(50) - .IsRequired(); - - // EN: Seed initial data / VI: Seed dữ liệu ban đầu - builder.HasData( - SampleStatus.Draft, - SampleStatus.Active, - SampleStatus.Completed, - SampleStatus.Cancelled - ); - } -} diff --git a/services/mkt-facebook-service-net/src/FacebookService.Infrastructure/ExternalServices/FacebookMessengerClient.cs b/services/mkt-facebook-service-net/src/FacebookService.Infrastructure/ExternalServices/FacebookMessengerClient.cs new file mode 100644 index 00000000..b10cb7cc --- /dev/null +++ b/services/mkt-facebook-service-net/src/FacebookService.Infrastructure/ExternalServices/FacebookMessengerClient.cs @@ -0,0 +1,272 @@ +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Polly; +using Polly.Extensions.Http; + +namespace FacebookService.Infrastructure.ExternalServices; + +/// +/// EN: Facebook Messenger API client implementation. +/// VI: Triển khai client cho Facebook Messenger API. +/// +public class FacebookMessengerClient : IFacebookMessengerClient +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private readonly string _pageAccessToken; + private readonly string _apiVersion; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + public FacebookMessengerClient( + HttpClient httpClient, + IConfiguration configuration, + ILogger logger) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + _pageAccessToken = configuration["Facebook:PageAccessToken"] + ?? configuration["FACEBOOK_PAGE_ACCESS_TOKEN"] + ?? throw new InvalidOperationException("Facebook Page Access Token not configured"); + + _apiVersion = configuration["Facebook:ApiVersion"] + ?? configuration["FACEBOOK_API_VERSION"] + ?? "v18.0"; + + _httpClient.BaseAddress = new Uri($"https://graph.facebook.com/{_apiVersion}/"); + } + + /// + public async Task SendTextMessageAsync( + string recipientId, + string message, + CancellationToken cancellationToken = default) + { + var payload = new + { + recipient = new { id = recipientId }, + message = new { text = message } + }; + + return await SendRequestAsync(payload, cancellationToken); + } + + /// + public async Task SendQuickRepliesAsync( + string recipientId, + string message, + IEnumerable quickReplies, + CancellationToken cancellationToken = default) + { + var payload = new + { + recipient = new { id = recipientId }, + message = new + { + text = message, + quick_replies = quickReplies.Select(qr => new + { + content_type = qr.ContentType, + title = qr.Title, + payload = qr.Payload, + image_url = qr.ImageUrl + }) + } + }; + + return await SendRequestAsync(payload, cancellationToken); + } + + /// + public async Task SendGenericTemplateAsync( + string recipientId, + IEnumerable elements, + CancellationToken cancellationToken = default) + { + var payload = new + { + recipient = new { id = recipientId }, + message = new + { + attachment = new + { + type = "template", + payload = new + { + template_type = "generic", + elements = elements.Select(MapElement) + } + } + } + }; + + return await SendRequestAsync(payload, cancellationToken); + } + + /// + public async Task SendButtonTemplateAsync( + string recipientId, + string text, + IEnumerable buttons, + CancellationToken cancellationToken = default) + { + var payload = new + { + recipient = new { id = recipientId }, + message = new + { + attachment = new + { + type = "template", + payload = new + { + template_type = "button", + text = text, + buttons = buttons.Select(MapButton) + } + } + } + }; + + return await SendRequestAsync(payload, cancellationToken); + } + + /// + public async Task GetUserProfileAsync( + string userId, + CancellationToken cancellationToken = default) + { + try + { + var url = $"{userId}?fields=first_name,last_name,profile_pic,locale,timezone&access_token={_pageAccessToken}"; + var response = await _httpClient.GetAsync(url, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("Failed to get user profile for: {UserId}", userId); + return null; + } + + var json = await response.Content.ReadAsStringAsync(cancellationToken); + var data = JsonSerializer.Deserialize(json); + + return new FacebookUserProfile( + Id: userId, + FirstName: data.TryGetProperty("first_name", out var fn) ? fn.GetString() : null, + LastName: data.TryGetProperty("last_name", out var ln) ? ln.GetString() : null, + ProfilePic: data.TryGetProperty("profile_pic", out var pp) ? pp.GetString() : null, + Locale: data.TryGetProperty("locale", out var loc) ? loc.GetString() : null, + Timezone: data.TryGetProperty("timezone", out var tz) ? (float?)tz.GetSingle() : null + ); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting user profile for: {UserId}", userId); + return null; + } + } + + /// + public async Task MarkAsSeenAsync( + string recipientId, + CancellationToken cancellationToken = default) + { + var payload = new + { + recipient = new { id = recipientId }, + sender_action = "mark_seen" + }; + + var result = await SendRequestAsync(payload, cancellationToken); + return result.Success; + } + + /// + public async Task ShowTypingAsync( + string recipientId, + bool isTyping = true, + CancellationToken cancellationToken = default) + { + var payload = new + { + recipient = new { id = recipientId }, + sender_action = isTyping ? "typing_on" : "typing_off" + }; + + var result = await SendRequestAsync(payload, cancellationToken); + return result.Success; + } + + private async Task SendRequestAsync( + object payload, + CancellationToken cancellationToken) + { + try + { + var url = $"me/messages?access_token={_pageAccessToken}"; + var response = await _httpClient.PostAsJsonAsync(url, payload, JsonOptions, cancellationToken); + var content = await response.Content.ReadAsStringAsync(cancellationToken); + + if (response.IsSuccessStatusCode) + { + var data = JsonSerializer.Deserialize(content); + return new SendMessageResult( + Success: true, + MessageId: data.TryGetProperty("message_id", out var mid) ? mid.GetString() : null, + RecipientId: data.TryGetProperty("recipient_id", out var rid) ? rid.GetString() : null + ); + } + + _logger.LogWarning("Facebook API error: {Response}", content); + return new SendMessageResult(Success: false, Error: content); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error sending message to Facebook"); + return new SendMessageResult(Success: false, Error: ex.Message); + } + } + + private static object MapElement(TemplateElement element) + { + return new + { + title = element.Title, + subtitle = element.Subtitle, + image_url = element.ImageUrl, + default_action = element.DefaultAction != null ? MapButton(element.DefaultAction) : null, + buttons = element.Buttons?.Select(MapButton) + }; + } + + private static object MapButton(TemplateButton button) + { + return button.Type switch + { + "web_url" => new { type = "web_url", title = button.Title, url = button.Url }, + "postback" => new { type = "postback", title = button.Title, payload = button.Payload }, + "phone_number" => new { type = "phone_number", title = button.Title, payload = button.Payload }, + _ => new { type = button.Type, title = button.Title } + }; + } + + /// + /// EN: Create retry policy for HTTP requests. + /// VI: Tạo retry policy cho HTTP requests. + /// + public static IAsyncPolicy GetRetryPolicy() + { + return HttpPolicyExtensions + .HandleTransientHttpError() + .WaitAndRetryAsync(3, retryAttempt => + TimeSpan.FromSeconds(Math.Pow(2, retryAttempt))); + } +} diff --git a/services/mkt-facebook-service-net/src/FacebookService.Infrastructure/ExternalServices/IAIClient.cs b/services/mkt-facebook-service-net/src/FacebookService.Infrastructure/ExternalServices/IAIClient.cs new file mode 100644 index 00000000..a7fcd0fe --- /dev/null +++ b/services/mkt-facebook-service-net/src/FacebookService.Infrastructure/ExternalServices/IAIClient.cs @@ -0,0 +1,51 @@ +namespace FacebookService.Infrastructure.ExternalServices; + +/// +/// EN: Interface for AI/LLM service client. +/// VI: Interface cho AI/LLM service client. +/// +public interface IAIClient +{ + /// + /// EN: Generate a chat completion response. + /// VI: Tạo response chat completion. + /// + Task GenerateChatCompletionAsync( + string systemPrompt, + IEnumerable conversationHistory, + AICompletionOptions options, + CancellationToken cancellationToken = default); + + /// + /// EN: Check if the AI service is available. + /// VI: Kiểm tra AI service có sẵn sàng không. + /// + Task IsAvailableAsync(CancellationToken cancellationToken = default); +} + +#region Models + +public record ChatMessage( + string Role, // "user", "assistant", "system" + string Content +); + +public record AICompletionOptions( + string Model, + float Temperature = 0.7f, + int MaxTokens = 500, + float TopP = 1.0f, + float FrequencyPenalty = 0.0f, + float PresencePenalty = 0.0f +); + +public record AICompletionResult( + bool Success, + string? Content = null, + int? PromptTokens = null, + int? CompletionTokens = null, + string? Error = null, + string? FinishReason = null +); + +#endregion diff --git a/services/mkt-facebook-service-net/src/FacebookService.Infrastructure/ExternalServices/IFacebookMessengerClient.cs b/services/mkt-facebook-service-net/src/FacebookService.Infrastructure/ExternalServices/IFacebookMessengerClient.cs new file mode 100644 index 00000000..4820cca1 --- /dev/null +++ b/services/mkt-facebook-service-net/src/FacebookService.Infrastructure/ExternalServices/IFacebookMessengerClient.cs @@ -0,0 +1,113 @@ +namespace FacebookService.Infrastructure.ExternalServices; + +/// +/// EN: Interface for Facebook Messenger API client. +/// VI: Interface cho Facebook Messenger API client. +/// +public interface IFacebookMessengerClient +{ + /// + /// EN: Send a text message to a user. + /// VI: Gửi tin nhắn văn bản đến người dùng. + /// + Task SendTextMessageAsync( + string recipientId, + string message, + CancellationToken cancellationToken = default); + + /// + /// EN: Send a message with quick replies. + /// VI: Gửi tin nhắn với quick replies. + /// + Task SendQuickRepliesAsync( + string recipientId, + string message, + IEnumerable quickReplies, + CancellationToken cancellationToken = default); + + /// + /// EN: Send a generic template (cards). + /// VI: Gửi template generic (cards). + /// + Task SendGenericTemplateAsync( + string recipientId, + IEnumerable elements, + CancellationToken cancellationToken = default); + + /// + /// EN: Send a button template. + /// VI: Gửi template button. + /// + Task SendButtonTemplateAsync( + string recipientId, + string text, + IEnumerable buttons, + CancellationToken cancellationToken = default); + + /// + /// EN: Get user profile from Facebook. + /// VI: Lấy thông tin người dùng từ Facebook. + /// + Task GetUserProfileAsync( + string userId, + CancellationToken cancellationToken = default); + + /// + /// EN: Mark message as seen. + /// VI: Đánh dấu tin nhắn đã xem. + /// + Task MarkAsSeenAsync( + string recipientId, + CancellationToken cancellationToken = default); + + /// + /// EN: Show typing indicator. + /// VI: Hiển thị indicator đang gõ. + /// + Task ShowTypingAsync( + string recipientId, + bool isTyping = true, + CancellationToken cancellationToken = default); +} + +#region Models + +public record SendMessageResult( + bool Success, + string? MessageId = null, + string? RecipientId = null, + string? Error = null +); + +public record QuickReply( + string ContentType, // "text", "user_phone_number", "user_email" + string? Title = null, + string? Payload = null, + string? ImageUrl = null +); + +public record TemplateElement( + string Title, + string? Subtitle = null, + string? ImageUrl = null, + TemplateButton? DefaultAction = null, + IEnumerable? Buttons = null +); + +public record TemplateButton( + string Type, // "web_url", "postback", "phone_number" + string Title, + string? Url = null, + string? Payload = null +); + +public record FacebookUserProfile( + string Id, + string? FirstName, + string? LastName, + string? ProfilePic, + string? Locale, + float? Timezone +); + +#endregion diff --git a/services/mkt-facebook-service-net/src/FacebookService.Infrastructure/ExternalServices/OpenAIClients.cs b/services/mkt-facebook-service-net/src/FacebookService.Infrastructure/ExternalServices/OpenAIClients.cs new file mode 100644 index 00000000..d14c2c06 --- /dev/null +++ b/services/mkt-facebook-service-net/src/FacebookService.Infrastructure/ExternalServices/OpenAIClients.cs @@ -0,0 +1,223 @@ +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace FacebookService.Infrastructure.ExternalServices; + +/// +/// EN: OpenAI API client implementation. +/// VI: Triển khai client cho OpenAI API. +/// +public class OpenAIClient : IAIClient +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private readonly string _apiKey; + + public OpenAIClient( + HttpClient httpClient, + IConfiguration configuration, + ILogger logger) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + _apiKey = configuration["OpenAI:ApiKey"] + ?? configuration["OPENAI_API_KEY"] + ?? throw new InvalidOperationException("OpenAI API Key not configured"); + + _httpClient.BaseAddress = new Uri("https://api.openai.com/v1/"); + _httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {_apiKey}"); + } + + /// + public async Task GenerateChatCompletionAsync( + string systemPrompt, + IEnumerable conversationHistory, + AICompletionOptions options, + CancellationToken cancellationToken = default) + { + try + { + var messages = new List + { + new { role = "system", content = systemPrompt } + }; + + messages.AddRange(conversationHistory.Select(m => new + { + role = m.Role, + content = m.Content + })); + + var payload = new + { + model = options.Model, + messages = messages, + temperature = options.Temperature, + max_tokens = options.MaxTokens, + top_p = options.TopP, + frequency_penalty = options.FrequencyPenalty, + presence_penalty = options.PresencePenalty + }; + + var response = await _httpClient.PostAsJsonAsync( + "chat/completions", payload, cancellationToken); + + var content = await response.Content.ReadAsStringAsync(cancellationToken); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("OpenAI API error: {Response}", content); + return new AICompletionResult(Success: false, Error: content); + } + + var data = JsonSerializer.Deserialize(content); + var choice = data.GetProperty("choices")[0]; + var message = choice.GetProperty("message"); + var usage = data.GetProperty("usage"); + + return new AICompletionResult( + Success: true, + Content: message.GetProperty("content").GetString(), + PromptTokens: usage.GetProperty("prompt_tokens").GetInt32(), + CompletionTokens: usage.GetProperty("completion_tokens").GetInt32(), + FinishReason: choice.GetProperty("finish_reason").GetString() + ); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error calling OpenAI API"); + return new AICompletionResult(Success: false, Error: ex.Message); + } + } + + /// + public async Task IsAvailableAsync(CancellationToken cancellationToken = default) + { + try + { + var response = await _httpClient.GetAsync("models", cancellationToken); + return response.IsSuccessStatusCode; + } + catch + { + return false; + } + } +} + +/// +/// EN: Azure OpenAI API client implementation. +/// VI: Triển khai client cho Azure OpenAI API. +/// +public class AzureOpenAIClient : IAIClient +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private readonly string _deploymentName; + private readonly string _apiVersion; + + public AzureOpenAIClient( + HttpClient httpClient, + IConfiguration configuration, + ILogger logger) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + var endpoint = configuration["AzureOpenAI:Endpoint"] + ?? configuration["AZURE_OPENAI_ENDPOINT"] + ?? throw new InvalidOperationException("Azure OpenAI Endpoint not configured"); + + var apiKey = configuration["AzureOpenAI:ApiKey"] + ?? configuration["AZURE_OPENAI_KEY"] + ?? throw new InvalidOperationException("Azure OpenAI API Key not configured"); + + _deploymentName = configuration["AzureOpenAI:DeploymentName"] + ?? configuration["AZURE_OPENAI_DEPLOYMENT_NAME"] + ?? "gpt-4"; + + _apiVersion = configuration["AzureOpenAI:ApiVersion"] + ?? "2024-02-01"; + + _httpClient.BaseAddress = new Uri(endpoint.TrimEnd('/') + "/"); + _httpClient.DefaultRequestHeaders.Add("api-key", apiKey); + } + + /// + public async Task GenerateChatCompletionAsync( + string systemPrompt, + IEnumerable conversationHistory, + AICompletionOptions options, + CancellationToken cancellationToken = default) + { + try + { + var messages = new List + { + new { role = "system", content = systemPrompt } + }; + + messages.AddRange(conversationHistory.Select(m => new + { + role = m.Role, + content = m.Content + })); + + var payload = new + { + messages = messages, + temperature = options.Temperature, + max_tokens = options.MaxTokens, + top_p = options.TopP, + frequency_penalty = options.FrequencyPenalty, + presence_penalty = options.PresencePenalty + }; + + var url = $"openai/deployments/{_deploymentName}/chat/completions?api-version={_apiVersion}"; + var response = await _httpClient.PostAsJsonAsync(url, payload, cancellationToken); + var content = await response.Content.ReadAsStringAsync(cancellationToken); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("Azure OpenAI API error: {Response}", content); + return new AICompletionResult(Success: false, Error: content); + } + + var data = JsonSerializer.Deserialize(content); + var choice = data.GetProperty("choices")[0]; + var message = choice.GetProperty("message"); + var usage = data.GetProperty("usage"); + + return new AICompletionResult( + Success: true, + Content: message.GetProperty("content").GetString(), + PromptTokens: usage.GetProperty("prompt_tokens").GetInt32(), + CompletionTokens: usage.GetProperty("completion_tokens").GetInt32(), + FinishReason: choice.GetProperty("finish_reason").GetString() + ); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error calling Azure OpenAI API"); + return new AICompletionResult(Success: false, Error: ex.Message); + } + } + + /// + public async Task IsAvailableAsync(CancellationToken cancellationToken = default) + { + try + { + var url = $"openai/deployments/{_deploymentName}?api-version={_apiVersion}"; + var response = await _httpClient.GetAsync(url, cancellationToken); + return response.IsSuccessStatusCode; + } + catch + { + return false; + } + } +} diff --git a/services/mkt-facebook-service-net/tests/FacebookService.UnitTests/Application/CreateSampleCommandHandlerTests.cs b/services/mkt-facebook-service-net/tests/FacebookService.UnitTests/Application/CreateSampleCommandHandlerTests.cs deleted file mode 100644 index 9c1d2d1b..00000000 --- a/services/mkt-facebook-service-net/tests/FacebookService.UnitTests/Application/CreateSampleCommandHandlerTests.cs +++ /dev/null @@ -1,65 +0,0 @@ -using FluentAssertions; -using Microsoft.Extensions.Logging; -using Moq; -using FacebookService.API.Application.Commands; -using FacebookService.Domain.AggregatesModel.SampleAggregate; -using FacebookService.Domain.SeedWork; -using Xunit; - -namespace FacebookService.UnitTests.Application; - -/// -/// EN: Unit tests for CreateSampleCommandHandler. -/// VI: Unit tests cho CreateSampleCommandHandler. -/// -public class CreateSampleCommandHandlerTests -{ - private readonly Mock _mockRepository; - private readonly Mock> _mockLogger; - private readonly CreateSampleCommandHandler _handler; - - public CreateSampleCommandHandlerTests() - { - _mockRepository = new Mock(); - _mockLogger = new Mock>(); - - var mockUnitOfWork = new Mock(); - mockUnitOfWork.Setup(u => u.SaveEntitiesAsync(It.IsAny())) - .ReturnsAsync(true); - - _mockRepository.SetupGet(r => r.UnitOfWork).Returns(mockUnitOfWork.Object); - - _handler = new CreateSampleCommandHandler(_mockRepository.Object, _mockLogger.Object); - } - - [Fact] - public async Task Handle_WithValidCommand_ShouldCreateSampleAndReturnId() - { - // Arrange - var command = new CreateSampleCommand("Test Sample", "Test Description"); - - _mockRepository.Setup(r => r.Add(It.IsAny())) - .Returns((Sample s) => s); - - // Act - var result = await _handler.Handle(command, CancellationToken.None); - - // Assert - result.Should().NotBeNull(); - result.Id.Should().NotBeEmpty(); - _mockRepository.Verify(r => r.Add(It.IsAny()), Times.Once); - } - - [Fact] - public async Task Handle_WithValidCommand_ShouldCallSaveEntities() - { - // Arrange - var command = new CreateSampleCommand("Test Sample", null); - - // Act - await _handler.Handle(command, CancellationToken.None); - - // Assert - _mockRepository.Verify(r => r.UnitOfWork.SaveEntitiesAsync(It.IsAny()), Times.Once); - } -} diff --git a/services/mkt-facebook-service-net/tests/FacebookService.UnitTests/Domain/SampleAggregateTests.cs b/services/mkt-facebook-service-net/tests/FacebookService.UnitTests/Domain/SampleAggregateTests.cs deleted file mode 100644 index f95be3ef..00000000 --- a/services/mkt-facebook-service-net/tests/FacebookService.UnitTests/Domain/SampleAggregateTests.cs +++ /dev/null @@ -1,151 +0,0 @@ -using FluentAssertions; -using FacebookService.Domain.AggregatesModel.SampleAggregate; -using FacebookService.Domain.Exceptions; -using Xunit; - -namespace FacebookService.UnitTests.Domain; - -/// -/// EN: Unit tests for Sample aggregate. -/// VI: Unit tests cho Sample aggregate. -/// -public class SampleAggregateTests -{ - [Fact] - public void CreateSample_WithValidName_ShouldCreateWithDraftStatus() - { - // Arrange - var name = "Test Sample"; - var description = "Test Description"; - - // Act - var sample = new Sample(name, description); - - // Assert - sample.Name.Should().Be(name); - sample.Description.Should().Be(description); - sample.Status.Should().Be(SampleStatus.Draft); - sample.Id.Should().NotBeEmpty(); - sample.DomainEvents.Should().ContainSingle(); // SampleCreatedDomainEvent - } - - [Fact] - public void CreateSample_WithEmptyName_ShouldThrowException() - { - // Arrange - var name = ""; - - // Act - var act = () => new Sample(name); - - // Assert - act.Should().Throw() - .WithMessage("Sample name cannot be empty"); - } - - [Fact] - public void Activate_WhenDraft_ShouldChangeToActive() - { - // Arrange - var sample = new Sample("Test Sample"); - sample.ClearDomainEvents(); - - // Act - sample.Activate(); - - // Assert - sample.Status.Should().Be(SampleStatus.Active); - sample.DomainEvents.Should().ContainSingle(); // SampleStatusChangedDomainEvent - } - - [Fact] - public void Activate_WhenNotDraft_ShouldThrowException() - { - // Arrange - var sample = new Sample("Test Sample"); - sample.Activate(); - - // Act - var act = () => sample.Activate(); - - // Assert - act.Should().Throw() - .WithMessage("Only draft samples can be activated"); - } - - [Fact] - public void Complete_WhenActive_ShouldChangeToCompleted() - { - // Arrange - var sample = new Sample("Test Sample"); - sample.Activate(); - sample.ClearDomainEvents(); - - // Act - sample.Complete(); - - // Assert - sample.Status.Should().Be(SampleStatus.Completed); - } - - [Fact] - public void Cancel_WhenDraftOrActive_ShouldChangeToCancelled() - { - // Arrange - var sample = new Sample("Test Sample"); - - // Act - sample.Cancel(); - - // Assert - sample.Status.Should().Be(SampleStatus.Cancelled); - } - - [Fact] - public void Cancel_WhenCompleted_ShouldThrowException() - { - // Arrange - var sample = new Sample("Test Sample"); - sample.Activate(); - sample.Complete(); - - // Act - var act = () => sample.Cancel(); - - // Assert - act.Should().Throw() - .WithMessage("Cannot cancel a completed sample"); - } - - [Fact] - public void Update_WhenNotCancelled_ShouldUpdateNameAndDescription() - { - // Arrange - var sample = new Sample("Original Name", "Original Description"); - var newName = "Updated Name"; - var newDescription = "Updated Description"; - - // Act - sample.Update(newName, newDescription); - - // Assert - sample.Name.Should().Be(newName); - sample.Description.Should().Be(newDescription); - sample.UpdatedAt.Should().NotBeNull(); - } - - [Fact] - public void Update_WhenCancelled_ShouldThrowException() - { - // Arrange - var sample = new Sample("Test Sample"); - sample.Cancel(); - - // Act - var act = () => sample.Update("New Name", null); - - // Assert - act.Should().Throw() - .WithMessage("Cannot update a cancelled sample"); - } -} diff --git a/services/mkt-whatsapp-service-net/src/WhatsAppService.API/Application/Commands/ConnectWhatsAppAccountCommand.cs b/services/mkt-whatsapp-service-net/src/WhatsAppService.API/Application/Commands/ConnectWhatsAppAccountCommand.cs new file mode 100644 index 00000000..ab96770a --- /dev/null +++ b/services/mkt-whatsapp-service-net/src/WhatsAppService.API/Application/Commands/ConnectWhatsAppAccountCommand.cs @@ -0,0 +1,23 @@ +using MediatR; + +namespace WhatsAppService.API.Application.Commands; + +/// +/// EN: Command to connect a WhatsApp Business Account. +/// VI: Command để kết nối WhatsApp Business Account. +/// +public record ConnectWhatsAppAccountCommand( + Guid ShopId, + string PhoneNumberId, + string AccessToken, + string? WebhookUrl +) : IRequest; + +/// +/// EN: Result of connecting a WhatsApp account. +/// VI: Kết quả kết nối WhatsApp account. +/// +public record ConnectWhatsAppAccountResult( + bool Success, + Guid? AccountId, + string? Error); diff --git a/services/mkt-whatsapp-service-net/src/WhatsAppService.API/Application/Commands/ConnectWhatsAppAccountCommandHandler.cs b/services/mkt-whatsapp-service-net/src/WhatsAppService.API/Application/Commands/ConnectWhatsAppAccountCommandHandler.cs new file mode 100644 index 00000000..48e08be4 --- /dev/null +++ b/services/mkt-whatsapp-service-net/src/WhatsAppService.API/Application/Commands/ConnectWhatsAppAccountCommandHandler.cs @@ -0,0 +1,68 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using WhatsAppService.Domain.AggregatesModel.WhatsAppAccountAggregate; + +namespace WhatsAppService.API.Application.Commands; + +/// +/// EN: Handler for ConnectWhatsAppAccountCommand. +/// VI: Handler cho ConnectWhatsAppAccountCommand. +/// +public class ConnectWhatsAppAccountCommandHandler : IRequestHandler +{ + private readonly IWhatsAppAccountRepository _repository; + private readonly ILogger _logger; + + public ConnectWhatsAppAccountCommandHandler( + IWhatsAppAccountRepository repository, + ILogger logger) + { + _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle( + ConnectWhatsAppAccountCommand request, + CancellationToken cancellationToken) + { + try + { + // Check if account already exists for this shop + var existing = await _repository.GetByShopIdAsync(request.ShopId, cancellationToken); + if (existing != null) + { + return new ConnectWhatsAppAccountResult(false, null, "Shop already has a connected WhatsApp account"); + } + + // Check if phone number is already in use + var existingPhone = await _repository.GetByPhoneNumberIdAsync(request.PhoneNumberId, cancellationToken); + if (existingPhone != null) + { + return new ConnectWhatsAppAccountResult(false, null, "Phone number is already connected to another shop"); + } + + // TODO: Encrypt access token before storing + var encryptedToken = request.AccessToken; // Placeholder - should use encryption service + + var account = new WhatsAppAccount( + request.ShopId, + request.PhoneNumberId, + encryptedToken, + request.WebhookUrl); + + await _repository.AddAsync(account, cancellationToken); + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "WhatsApp account connected for shop {ShopId}. AccountId: {AccountId}", + request.ShopId, account.Id); + + return new ConnectWhatsAppAccountResult(true, account.Id, null); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to connect WhatsApp account for shop {ShopId}", request.ShopId); + return new ConnectWhatsAppAccountResult(false, null, ex.Message); + } + } +} diff --git a/services/mkt-whatsapp-service-net/src/WhatsAppService.API/Application/Commands/ProcessIncomingMessageCommand.cs b/services/mkt-whatsapp-service-net/src/WhatsAppService.API/Application/Commands/ProcessIncomingMessageCommand.cs new file mode 100644 index 00000000..186b73b6 --- /dev/null +++ b/services/mkt-whatsapp-service-net/src/WhatsAppService.API/Application/Commands/ProcessIncomingMessageCommand.cs @@ -0,0 +1,30 @@ +using MediatR; + +namespace WhatsAppService.API.Application.Commands; + +/// +/// EN: Command to process an incoming WhatsApp message from webhook. +/// VI: Command để xử lý tin nhắn WhatsApp đến từ webhook. +/// +public record ProcessIncomingMessageCommand( + string PhoneNumberId, + string SenderWaId, + string MessageId, + string MessageType, + string? TextBody, + string? MediaId, + string? MediaMimeType, + object? Interactive, + DateTime Timestamp +) : IRequest; + +/// +/// EN: Result of processing incoming message. +/// VI: Kết quả xử lý tin nhắn đến. +/// +public record ProcessIncomingMessageResult( + bool Success, + Guid? ConversationId, + Guid? MessageId, + bool IsNewConversation, + string? Error); diff --git a/services/mkt-whatsapp-service-net/src/WhatsAppService.API/Application/Commands/ProcessIncomingMessageCommandHandler.cs b/services/mkt-whatsapp-service-net/src/WhatsAppService.API/Application/Commands/ProcessIncomingMessageCommandHandler.cs new file mode 100644 index 00000000..4c7079e3 --- /dev/null +++ b/services/mkt-whatsapp-service-net/src/WhatsAppService.API/Application/Commands/ProcessIncomingMessageCommandHandler.cs @@ -0,0 +1,98 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using WhatsAppService.Domain.AggregatesModel.ConversationAggregate; +using WhatsAppService.Domain.AggregatesModel.CustomerAggregate; +using WhatsAppService.Domain.AggregatesModel.WhatsAppAccountAggregate; + +namespace WhatsAppService.API.Application.Commands; + +/// +/// EN: Handler for ProcessIncomingMessageCommand. +/// VI: Handler cho ProcessIncomingMessageCommand. +/// +public class ProcessIncomingMessageCommandHandler : IRequestHandler +{ + private readonly IWhatsAppAccountRepository _accountRepository; + private readonly IConversationRepository _conversationRepository; + private readonly ICustomerRepository _customerRepository; + private readonly ILogger _logger; + + public ProcessIncomingMessageCommandHandler( + IWhatsAppAccountRepository accountRepository, + IConversationRepository conversationRepository, + ICustomerRepository customerRepository, + ILogger logger) + { + _accountRepository = accountRepository; + _conversationRepository = conversationRepository; + _customerRepository = customerRepository; + _logger = logger; + } + + public async Task Handle( + ProcessIncomingMessageCommand request, + CancellationToken cancellationToken) + { + try + { + // Find WhatsApp account by phone number ID + var account = await _accountRepository.GetByPhoneNumberIdAsync(request.PhoneNumberId, cancellationToken); + if (account == null) + { + _logger.LogWarning("No WhatsApp account found for phone number ID: {PhoneNumberId}", request.PhoneNumberId); + return new ProcessIncomingMessageResult(false, null, null, false, "Account not found"); + } + + var shopId = account.ShopId; + var isNewConversation = false; + + // Get or create customer + var customer = await _customerRepository.GetByWaIdAsync(shopId, request.SenderWaId, cancellationToken); + if (customer == null) + { + customer = new Customer(shopId, request.SenderWaId); + await _customerRepository.AddAsync(customer, cancellationToken); + } + + // Get or create conversation + var conversation = await _conversationRepository.GetActiveByCustomerAsync(shopId, request.SenderWaId, cancellationToken); + if (conversation == null) + { + conversation = new Conversation(shopId, request.SenderWaId, account.Id); + await _conversationRepository.AddAsync(conversation, cancellationToken); + isNewConversation = true; + } + + // Create message content based on type + var content = CreateMessageContent(request); + + // Add message to conversation + var message = conversation.AddMessage(content, Message.Directions.Inbound, request.MessageId); + + await _conversationRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "Processed incoming message. ConversationId: {ConversationId}, MessageId: {MessageId}, IsNew: {IsNew}", + conversation.Id, message.Id, isNewConversation); + + return new ProcessIncomingMessageResult(true, conversation.Id, message.Id, isNewConversation, null); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to process incoming message from {SenderWaId}", request.SenderWaId); + return new ProcessIncomingMessageResult(false, null, null, false, ex.Message); + } + } + + private static MessageContent CreateMessageContent(ProcessIncomingMessageCommand request) + { + return request.MessageType.ToLowerInvariant() switch + { + "text" => MessageContent.CreateText(request.TextBody ?? ""), + "image" or "video" or "audio" or "document" => + MessageContent.CreateMedia(request.MessageType, request.MediaId ?? "", null, request.MediaMimeType), + "interactive" => MessageContent.CreateInteractive(request.Interactive ?? new { }), + _ => MessageContent.CreateText(request.TextBody ?? $"[Unsupported: {request.MessageType}]") + }; + } +} diff --git a/services/mkt-whatsapp-service-net/src/WhatsAppService.API/Application/Commands/SendMessageCommand.cs b/services/mkt-whatsapp-service-net/src/WhatsAppService.API/Application/Commands/SendMessageCommand.cs new file mode 100644 index 00000000..9f3c10d4 --- /dev/null +++ b/services/mkt-whatsapp-service-net/src/WhatsAppService.API/Application/Commands/SendMessageCommand.cs @@ -0,0 +1,26 @@ +using MediatR; + +namespace WhatsAppService.API.Application.Commands; + +/// +/// EN: Command to send a WhatsApp message. +/// VI: Command để gửi tin nhắn WhatsApp. +/// +public record SendMessageCommand( + Guid ConversationId, + string MessageType, + string? Text, + string? MediaUrl, + string? Caption, + object? Interactive +) : IRequest; + +/// +/// EN: Result of sending a message. +/// VI: Kết quả gửi tin nhắn. +/// +public record SendMessageResult( + bool Success, + Guid? MessageId, + string? WhatsAppMessageId, + string? Error); diff --git a/services/mkt-whatsapp-service-net/src/WhatsAppService.API/Application/Commands/SendMessageCommandHandler.cs b/services/mkt-whatsapp-service-net/src/WhatsAppService.API/Application/Commands/SendMessageCommandHandler.cs new file mode 100644 index 00000000..0e84ed05 --- /dev/null +++ b/services/mkt-whatsapp-service-net/src/WhatsAppService.API/Application/Commands/SendMessageCommandHandler.cs @@ -0,0 +1,120 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using WhatsAppService.Domain.AggregatesModel.ConversationAggregate; +using WhatsAppService.Domain.AggregatesModel.WhatsAppAccountAggregate; +using WhatsAppService.Infrastructure.ExternalServices; + +namespace WhatsAppService.API.Application.Commands; + +/// +/// EN: Handler for SendMessageCommand. +/// VI: Handler cho SendMessageCommand. +/// +public class SendMessageCommandHandler : IRequestHandler +{ + private readonly IConversationRepository _conversationRepository; + private readonly IWhatsAppAccountRepository _accountRepository; + private readonly IWhatsAppCloudApiClient _whatsAppClient; + private readonly ILogger _logger; + + public SendMessageCommandHandler( + IConversationRepository conversationRepository, + IWhatsAppAccountRepository accountRepository, + IWhatsAppCloudApiClient whatsAppClient, + ILogger logger) + { + _conversationRepository = conversationRepository; + _accountRepository = accountRepository; + _whatsAppClient = whatsAppClient; + _logger = logger; + } + + public async Task Handle( + SendMessageCommand request, + CancellationToken cancellationToken) + { + try + { + // Get conversation + var conversation = await _conversationRepository.GetByIdAsync(request.ConversationId, cancellationToken); + if (conversation == null) + { + return new SendMessageResult(false, null, null, "Conversation not found"); + } + + // Check 24h window + if (!conversation.IsWithinMessagingWindow()) + { + return new SendMessageResult(false, null, null, "Conversation outside 24h messaging window. Use template message."); + } + + // Get WhatsApp account + var account = await _accountRepository.GetByIdAsync(conversation.WhatsAppAccountId, cancellationToken); + if (account == null || account.Status != WhatsAppAccountStatus.Active) + { + return new SendMessageResult(false, null, null, "WhatsApp account not available"); + } + + // TODO: Decrypt access token + var accessToken = account.AccessTokenEncrypted; + + // Create message content + var content = CreateMessageContent(request); + + // Send via WhatsApp API + var apiResult = await SendViaApiAsync(account.PhoneNumberId, accessToken, conversation.CustomerWaId, request, cancellationToken); + + if (!apiResult.Success) + { + return new SendMessageResult(false, null, null, apiResult.ErrorMessage); + } + + // Add message to conversation + var message = conversation.AddMessage(content, Message.Directions.Outbound, apiResult.MessageId); + await _conversationRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "Sent message. ConversationId: {ConversationId}, MessageId: {MessageId}", + conversation.Id, message.Id); + + return new SendMessageResult(true, message.Id, apiResult.MessageId, null); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send message for conversation {ConversationId}", request.ConversationId); + return new SendMessageResult(false, null, null, ex.Message); + } + } + + private static MessageContent CreateMessageContent(SendMessageCommand request) + { + return request.MessageType.ToLowerInvariant() switch + { + "text" => MessageContent.CreateText(request.Text ?? ""), + "image" or "video" or "audio" or "document" => + MessageContent.CreateMedia(request.MessageType, request.MediaUrl ?? "", request.Caption), + "interactive" => MessageContent.CreateInteractive(request.Interactive ?? new { }), + _ => MessageContent.CreateText(request.Text ?? "") + }; + } + + private async Task SendViaApiAsync( + string phoneNumberId, + string accessToken, + string recipientWaId, + SendMessageCommand request, + CancellationToken cancellationToken) + { + return request.MessageType.ToLowerInvariant() switch + { + "text" => await _whatsAppClient.SendTextMessageAsync( + phoneNumberId, accessToken, recipientWaId, request.Text ?? "", cancellationToken), + "image" or "video" or "audio" or "document" => await _whatsAppClient.SendMediaMessageAsync( + phoneNumberId, accessToken, recipientWaId, request.MessageType, request.MediaUrl ?? "", request.Caption, cancellationToken), + "interactive" => await _whatsAppClient.SendInteractiveMessageAsync( + phoneNumberId, accessToken, recipientWaId, request.Interactive ?? new { }, cancellationToken), + _ => await _whatsAppClient.SendTextMessageAsync( + phoneNumberId, accessToken, recipientWaId, request.Text ?? "", cancellationToken) + }; + } +} diff --git a/services/mkt-whatsapp-service-net/src/WhatsAppService.API/Application/Queries/GetConversationsQuery.cs b/services/mkt-whatsapp-service-net/src/WhatsAppService.API/Application/Queries/GetConversationsQuery.cs new file mode 100644 index 00000000..b72f9bf0 --- /dev/null +++ b/services/mkt-whatsapp-service-net/src/WhatsAppService.API/Application/Queries/GetConversationsQuery.cs @@ -0,0 +1,36 @@ +using MediatR; + +namespace WhatsAppService.API.Application.Queries; + +/// +/// EN: Query to get conversations by shop with pagination. +/// VI: Query để lấy conversations theo shop với phân trang. +/// +public record GetConversationsQuery( + Guid ShopId, + int? StatusId, + int Skip = 0, + int Take = 20 +) : IRequest; + +/// +/// EN: Result of GetConversationsQuery. +/// VI: Kết quả của GetConversationsQuery. +/// +public record GetConversationsResult( + List Conversations, + int TotalCount); + +/// +/// EN: Conversation view model. +/// VI: View model cho conversation. +/// +public record ConversationViewModel( + Guid Id, + string CustomerWaId, + string Status, + Guid? AssignedAgentId, + DateTime? LastMessageAt, + DateTime CreatedAt, + DateTime? ExpiresAt, + int MessageCount); diff --git a/services/mkt-whatsapp-service-net/src/WhatsAppService.API/BackgroundJobs/AutomationTriggerHandler.cs b/services/mkt-whatsapp-service-net/src/WhatsAppService.API/BackgroundJobs/AutomationTriggerHandler.cs new file mode 100644 index 00000000..a7cb090f --- /dev/null +++ b/services/mkt-whatsapp-service-net/src/WhatsAppService.API/BackgroundJobs/AutomationTriggerHandler.cs @@ -0,0 +1,61 @@ +using MediatR; +using WhatsAppService.Domain.AggregatesModel.AutomationFlowAggregate; +using WhatsAppService.Domain.Events; + +namespace WhatsAppService.API.BackgroundJobs; + +/// +/// EN: Domain event handler for processing automation triggers on new messages. +/// VI: Domain event handler để xử lý automation triggers khi có tin nhắn mới. +/// +public class AutomationTriggerHandler : INotificationHandler +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + public AutomationTriggerHandler( + IServiceProvider serviceProvider, + ILogger logger) + { + _serviceProvider = serviceProvider; + _logger = logger; + } + + public async Task Handle(MessageReceivedEvent notification, CancellationToken cancellationToken) + { + try + { + using var scope = _serviceProvider.CreateScope(); + var flowRepository = scope.ServiceProvider.GetRequiredService(); + + // Get active keyword-triggered flows for this shop + var flows = await flowRepository.GetByTriggerTypeAsync( + notification.ShopId, + TriggerType.Keyword, + cancellationToken); + + if (flows.Count == 0) + { + return; + } + + var messageText = notification.Content.Text?.ToLowerInvariant() ?? ""; + + foreach (var flow in flows.OrderByDescending(f => f.Priority)) + { + // TODO: Parse trigger config and check for keyword matches + // This is a placeholder - actual implementation would parse TriggerConfig JSON + _logger.LogDebug( + "Checking flow {FlowId} for keyword triggers on message from {CustomerWaId}", + flow.Id, notification.CustomerWaId); + + // If matched, execute flow steps + // await ExecuteFlowAsync(flow, notification, cancellationToken); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing automation triggers for message {MessageId}", notification.MessageId); + } + } +} diff --git a/services/mkt-whatsapp-service-net/src/WhatsAppService.API/BackgroundJobs/ConversationExpiryJob.cs b/services/mkt-whatsapp-service-net/src/WhatsAppService.API/BackgroundJobs/ConversationExpiryJob.cs new file mode 100644 index 00000000..114c2423 --- /dev/null +++ b/services/mkt-whatsapp-service-net/src/WhatsAppService.API/BackgroundJobs/ConversationExpiryJob.cs @@ -0,0 +1,67 @@ +using WhatsAppService.Domain.AggregatesModel.ConversationAggregate; + +namespace WhatsAppService.API.BackgroundJobs; + +/// +/// EN: Background job to check and expire conversations outside 24h window. +/// VI: Background job để kiểm tra và hết hạn conversations ngoài cửa sổ 24h. +/// +public class ConversationExpiryJob : BackgroundService +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + private readonly TimeSpan _checkInterval = TimeSpan.FromMinutes(5); + + public ConversationExpiryJob( + IServiceProvider serviceProvider, + ILogger logger) + { + _serviceProvider = serviceProvider; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("ConversationExpiryJob started"); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await ProcessExpiredConversationsAsync(stoppingToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in ConversationExpiryJob"); + } + + await Task.Delay(_checkInterval, stoppingToken); + } + + _logger.LogInformation("ConversationExpiryJob stopped"); + } + + private async Task ProcessExpiredConversationsAsync(CancellationToken cancellationToken) + { + using var scope = _serviceProvider.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + + var expiredConversations = await repository.GetExpiredConversationsAsync(cancellationToken); + + if (expiredConversations.Count == 0) + { + return; + } + + _logger.LogInformation("Found {Count} expired conversations", expiredConversations.Count); + + foreach (var conversation in expiredConversations) + { + conversation.CheckExpiry(); + } + + await repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation("Processed {Count} expired conversations", expiredConversations.Count); + } +} diff --git a/services/mkt-whatsapp-service-net/src/WhatsAppService.API/Controllers/ConversationsController.cs b/services/mkt-whatsapp-service-net/src/WhatsAppService.API/Controllers/ConversationsController.cs new file mode 100644 index 00000000..ef4cc26c --- /dev/null +++ b/services/mkt-whatsapp-service-net/src/WhatsAppService.API/Controllers/ConversationsController.cs @@ -0,0 +1,170 @@ +using MediatR; +using Microsoft.AspNetCore.Mvc; +using WhatsAppService.API.Application.Commands; +using WhatsAppService.Domain.AggregatesModel.ConversationAggregate; + +namespace WhatsAppService.API.Controllers; + +/// +/// EN: Controller for managing conversations. +/// VI: Controller để quản lý conversations. +/// +[ApiController] +[Route("api/[controller]")] +[Produces("application/json")] +public class ConversationsController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly IConversationRepository _repository; + private readonly ILogger _logger; + + public ConversationsController( + IMediator mediator, + IConversationRepository repository, + ILogger logger) + { + _mediator = mediator; + _repository = repository; + _logger = logger; + } + + /// + /// EN: Get conversations by shop with pagination. + /// VI: Lấy conversations theo shop với phân trang. + /// + [HttpGet] + [ProducesResponseType(typeof(object), StatusCodes.Status200OK)] + public async Task GetConversations( + [FromQuery] Guid shopId, + [FromQuery] string? status, + [FromQuery] int skip = 0, + [FromQuery] int take = 20) + { + ConversationStatus? statusFilter = null; + if (!string.IsNullOrEmpty(status)) + { + statusFilter = ConversationStatus.FromName(status); + } + + var conversations = await _repository.GetByShopIdAsync(shopId, skip, take, statusFilter); + + return Ok(new + { + success = true, + data = conversations.Select(c => new + { + id = c.Id, + customerWaId = c.CustomerWaId, + status = c.Status.Name, + assignedAgentId = c.AssignedAgentId, + lastMessageAt = c.LastMessageAt, + createdAt = c.CreatedAt, + expiresAt = c.ExpiresAt, + messageCount = c.Messages.Count + }) + }); + } + + /// + /// EN: Get conversation by ID with messages. + /// VI: Lấy conversation theo ID kèm messages. + /// + [HttpGet("{id:guid}")] + [ProducesResponseType(typeof(object), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetById(Guid id) + { + var conversation = await _repository.GetByIdAsync(id); + if (conversation == null) + { + return NotFound(new { success = false, error = "Conversation not found" }); + } + + return Ok(new + { + success = true, + data = new + { + id = conversation.Id, + customerWaId = conversation.CustomerWaId, + status = conversation.Status.Name, + assignedAgentId = conversation.AssignedAgentId, + lastMessageAt = conversation.LastMessageAt, + createdAt = conversation.CreatedAt, + expiresAt = conversation.ExpiresAt, + messages = conversation.Messages.OrderBy(m => m.Timestamp).Select(m => new + { + id = m.Id, + direction = m.Direction, + contentType = m.Content.Type, + text = m.Content.Text, + status = m.Status, + timestamp = m.Timestamp + }) + } + }); + } + + /// + /// EN: Send a message in conversation. + /// VI: Gửi tin nhắn trong conversation. + /// + [HttpPost("{id:guid}/messages")] + [ProducesResponseType(typeof(object), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task SendMessage(Guid id, [FromBody] SendMessageRequest request) + { + var command = new SendMessageCommand( + id, + request.Type, + request.Text, + request.MediaUrl, + request.Caption, + request.Interactive); + + var result = await _mediator.Send(command); + + if (!result.Success) + { + return BadRequest(new { success = false, error = result.Error }); + } + + return Ok(new + { + success = true, + data = new + { + messageId = result.MessageId, + whatsAppMessageId = result.WhatsAppMessageId + } + }); + } + + /// + /// EN: Close a conversation. + /// VI: Đóng conversation. + /// + [HttpPost("{id:guid}/close")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Close(Guid id) + { + var conversation = await _repository.GetByIdAsync(id); + if (conversation == null) + { + return NotFound(new { success = false, error = "Conversation not found" }); + } + + conversation.Close(); + await _repository.UnitOfWork.SaveEntitiesAsync(); + + return NoContent(); + } +} + +public record SendMessageRequest( + string Type, + string? Text, + string? MediaUrl, + string? Caption, + object? Interactive); diff --git a/services/mkt-whatsapp-service-net/src/WhatsAppService.API/Controllers/CustomersController.cs b/services/mkt-whatsapp-service-net/src/WhatsAppService.API/Controllers/CustomersController.cs new file mode 100644 index 00000000..4b1b8183 --- /dev/null +++ b/services/mkt-whatsapp-service-net/src/WhatsAppService.API/Controllers/CustomersController.cs @@ -0,0 +1,173 @@ +using MediatR; +using Microsoft.AspNetCore.Mvc; +using WhatsAppService.Domain.AggregatesModel.CustomerAggregate; + +namespace WhatsAppService.API.Controllers; + +/// +/// EN: Controller for managing customers. +/// VI: Controller để quản lý khách hàng. +/// +[ApiController] +[Route("api/[controller]")] +[Produces("application/json")] +public class CustomersController : ControllerBase +{ + private readonly ICustomerRepository _repository; + private readonly ILogger _logger; + + public CustomersController( + ICustomerRepository repository, + ILogger logger) + { + _repository = repository; + _logger = logger; + } + + /// + /// EN: Get customers by shop with pagination. + /// VI: Lấy khách hàng theo shop với phân trang. + /// + [HttpGet] + [ProducesResponseType(typeof(object), StatusCodes.Status200OK)] + public async Task GetCustomers( + [FromQuery] Guid shopId, + [FromQuery] int skip = 0, + [FromQuery] int take = 20) + { + var customers = await _repository.GetByShopIdAsync(shopId, skip, take); + + return Ok(new + { + success = true, + data = customers.Select(c => new + { + id = c.Id, + waId = c.WaId, + name = c.Name, + profilePictureUrl = c.ProfilePictureUrl, + optInStatus = c.Consent.Status, + tags = c.Tags, + firstContactedAt = c.FirstContactedAt, + createdAt = c.CreatedAt + }) + }); + } + + /// + /// EN: Get customer by ID. + /// VI: Lấy khách hàng theo ID. + /// + [HttpGet("{id:guid}")] + [ProducesResponseType(typeof(object), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetById(Guid id) + { + var customer = await _repository.GetByIdAsync(id); + if (customer == null) + { + return NotFound(new { success = false, error = "Customer not found" }); + } + + return Ok(new + { + success = true, + data = new + { + id = customer.Id, + waId = customer.WaId, + name = customer.Name, + profilePictureUrl = customer.ProfilePictureUrl, + optInStatus = customer.Consent.Status, + optInTimestamp = customer.Consent.Timestamp, + optInSource = customer.Consent.Source, + tags = customer.Tags, + customFields = customer.CustomFields, + firstContactedAt = customer.FirstContactedAt, + createdAt = customer.CreatedAt + } + }); + } + + /// + /// EN: Update customer profile. + /// VI: Cập nhật hồ sơ khách hàng. + /// + [HttpPatch("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Update(Guid id, [FromBody] UpdateCustomerRequest request) + { + var customer = await _repository.GetByIdAsync(id); + if (customer == null) + { + return NotFound(new { success = false, error = "Customer not found" }); + } + + if (!string.IsNullOrEmpty(request.Name)) + { + customer.UpdateName(request.Name); + } + + if (request.Tags != null) + { + // Clear and re-add tags + foreach (var tag in customer.Tags.ToList()) + { + customer.RemoveTag(tag); + } + foreach (var tag in request.Tags) + { + customer.AddTag(tag); + } + } + + if (request.CustomFields != null) + { + foreach (var field in request.CustomFields) + { + customer.SetCustomField(field.Key, field.Value); + } + } + + await _repository.UnitOfWork.SaveEntitiesAsync(); + return NoContent(); + } + + /// + /// EN: Update customer opt-in consent. + /// VI: Cập nhật đồng ý opt-in của khách hàng. + /// + [HttpPost("{id:guid}/consent")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task UpdateConsent(Guid id, [FromBody] UpdateConsentRequest request) + { + var customer = await _repository.GetByIdAsync(id); + if (customer == null) + { + return NotFound(new { success = false, error = "Customer not found" }); + } + + if (request.OptIn) + { + customer.OptIn(request.Source ?? "api"); + } + else + { + customer.OptOut(request.Source ?? "api"); + } + + await _repository.UnitOfWork.SaveEntitiesAsync(); + return NoContent(); + } +} + +public record UpdateCustomerRequest( + string? Name, + List? Tags, + Dictionary? CustomFields); + +public record UpdateConsentRequest( + bool OptIn, + string? Source); diff --git a/services/mkt-whatsapp-service-net/src/WhatsAppService.API/Controllers/WebhooksController.cs b/services/mkt-whatsapp-service-net/src/WhatsAppService.API/Controllers/WebhooksController.cs new file mode 100644 index 00000000..1627a60e --- /dev/null +++ b/services/mkt-whatsapp-service-net/src/WhatsAppService.API/Controllers/WebhooksController.cs @@ -0,0 +1,188 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using WhatsAppService.API.Application.Commands; + +namespace WhatsAppService.API.Controllers; + +/// +/// EN: Controller for handling WhatsApp webhooks. +/// VI: Controller để xử lý webhooks WhatsApp. +/// +[ApiController] +[Route("api/webhooks")] +public class WebhooksController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly ILogger _logger; + private readonly IConfiguration _configuration; + + public WebhooksController( + IMediator mediator, + ILogger logger, + IConfiguration configuration) + { + _mediator = mediator; + _logger = logger; + _configuration = configuration; + } + + /// + /// EN: Webhook verification endpoint (GET). + /// VI: Endpoint xác minh webhook (GET). + /// + [HttpGet("whatsapp")] + public IActionResult VerifyWebhook( + [FromQuery(Name = "hub.mode")] string? mode, + [FromQuery(Name = "hub.verify_token")] string? verifyToken, + [FromQuery(Name = "hub.challenge")] string? challenge) + { + _logger.LogInformation("Webhook verification request. Mode: {Mode}", mode); + + if (mode != "subscribe") + { + return Forbid(); + } + + var expectedToken = _configuration["WhatsApp:WebhookVerifyToken"]; + if (verifyToken != expectedToken) + { + _logger.LogWarning("Invalid verify token received"); + return Forbid(); + } + + _logger.LogInformation("Webhook verified successfully"); + return Ok(challenge); + } + + /// + /// EN: Webhook event handler (POST). + /// VI: Handler sự kiện webhook (POST). + /// + [HttpPost("whatsapp")] + public async Task HandleWebhook( + [FromHeader(Name = "X-Hub-Signature-256")] string? signature, + [FromBody] JsonElement payload) + { + // EN: Verify signature + // VI: Xác minh chữ ký + var appSecret = _configuration["WhatsApp:AppSecret"]; + if (!string.IsNullOrEmpty(appSecret) && !string.IsNullOrEmpty(signature)) + { + var payloadBytes = Encoding.UTF8.GetBytes(payload.GetRawText()); + var expectedSignature = ComputeHmacSha256(payloadBytes, appSecret); + + if (!$"sha256={expectedSignature}".Equals(signature, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogWarning("Invalid webhook signature"); + return Unauthorized(); + } + } + + // EN: Return 200 immediately, process async + // VI: Trả về 200 ngay lập tức, xử lý bất đồng bộ + _ = Task.Run(async () => await ProcessWebhookAsync(payload)); + + return Ok(); + } + + private async Task ProcessWebhookAsync(JsonElement payload) + { + try + { + var objectType = payload.GetProperty("object").GetString(); + if (objectType != "whatsapp_business_account") + { + return; + } + + var entries = payload.GetProperty("entry"); + foreach (var entry in entries.EnumerateArray()) + { + var changes = entry.GetProperty("changes"); + foreach (var change in changes.EnumerateArray()) + { + var field = change.GetProperty("field").GetString(); + if (field != "messages") + { + continue; + } + + var value = change.GetProperty("value"); + await ProcessMessagesAsync(value); + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing webhook payload"); + } + } + + private async Task ProcessMessagesAsync(JsonElement value) + { + if (!value.TryGetProperty("messages", out var messages)) + { + return; + } + + var metadata = value.GetProperty("metadata"); + var phoneNumberId = metadata.GetProperty("phone_number_id").GetString() ?? ""; + + foreach (var message in messages.EnumerateArray()) + { + var from = message.GetProperty("from").GetString() ?? ""; + var messageId = message.GetProperty("id").GetString() ?? ""; + var timestamp = message.GetProperty("timestamp").GetString() ?? ""; + var type = message.GetProperty("type").GetString() ?? "text"; + + string? textBody = null; + string? mediaId = null; + string? mimeType = null; + object? interactive = null; + + if (type == "text" && message.TryGetProperty("text", out var text)) + { + textBody = text.GetProperty("body").GetString(); + } + else if (type is "image" or "video" or "audio" or "document") + { + if (message.TryGetProperty(type, out var media)) + { + mediaId = media.TryGetProperty("id", out var id) ? id.GetString() : null; + mimeType = media.TryGetProperty("mime_type", out var mime) ? mime.GetString() : null; + } + } + else if (type == "interactive" && message.TryGetProperty("interactive", out var interactiveEl)) + { + interactive = JsonSerializer.Deserialize(interactiveEl.GetRawText()); + } + + var command = new ProcessIncomingMessageCommand( + phoneNumberId, + from, + messageId, + type, + textBody, + mediaId, + mimeType, + interactive, + DateTimeOffset.FromUnixTimeSeconds(long.Parse(timestamp)).UtcDateTime + ); + + var result = await _mediator.Send(command); + _logger.LogInformation( + "Message processed. Success: {Success}, ConversationId: {ConversationId}", + result.Success, result.ConversationId); + } + } + + private static string ComputeHmacSha256(byte[] data, string key) + { + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(key)); + var hash = hmac.ComputeHash(data); + return Convert.ToHexString(hash).ToLowerInvariant(); + } +} diff --git a/services/mkt-whatsapp-service-net/src/WhatsAppService.API/Controllers/WhatsAppAccountsController.cs b/services/mkt-whatsapp-service-net/src/WhatsAppService.API/Controllers/WhatsAppAccountsController.cs new file mode 100644 index 00000000..7907328c --- /dev/null +++ b/services/mkt-whatsapp-service-net/src/WhatsAppService.API/Controllers/WhatsAppAccountsController.cs @@ -0,0 +1,113 @@ +using MediatR; +using Microsoft.AspNetCore.Mvc; +using WhatsAppService.API.Application.Commands; +using WhatsAppService.Domain.AggregatesModel.WhatsAppAccountAggregate; + +namespace WhatsAppService.API.Controllers; + +/// +/// EN: Controller for managing WhatsApp accounts. +/// VI: Controller để quản lý WhatsApp accounts. +/// +[ApiController] +[Route("api/[controller]")] +[Produces("application/json")] +public class WhatsAppAccountsController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly IWhatsAppAccountRepository _repository; + private readonly ILogger _logger; + + public WhatsAppAccountsController( + IMediator mediator, + IWhatsAppAccountRepository repository, + ILogger logger) + { + _mediator = mediator; + _repository = repository; + _logger = logger; + } + + /// + /// EN: Connect a WhatsApp Business Account. + /// VI: Kết nối WhatsApp Business Account. + /// + [HttpPost] + [ProducesResponseType(typeof(object), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task Connect([FromBody] ConnectAccountRequest request) + { + var command = new ConnectWhatsAppAccountCommand( + request.ShopId, + request.PhoneNumberId, + request.AccessToken, + request.WebhookUrl); + + var result = await _mediator.Send(command); + + if (!result.Success) + { + return BadRequest(new { success = false, error = result.Error }); + } + + return Ok(new { success = true, data = new { accountId = result.AccountId } }); + } + + /// + /// EN: Get WhatsApp account by shop ID. + /// VI: Lấy WhatsApp account theo shop ID. + /// + [HttpGet("shop/{shopId:guid}")] + [ProducesResponseType(typeof(object), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetByShopId(Guid shopId) + { + var account = await _repository.GetByShopIdAsync(shopId); + if (account == null) + { + return NotFound(new { success = false, error = "Account not found" }); + } + + return Ok(new + { + success = true, + data = new + { + id = account.Id, + shopId = account.ShopId, + phoneNumberId = account.PhoneNumberId, + status = account.Status.Name, + messageTier = account.MessageTier, + webhookUrl = account.WebhookUrl, + createdAt = account.CreatedAt + } + }); + } + + /// + /// EN: Disconnect WhatsApp account. + /// VI: Ngắt kết nối WhatsApp account. + /// + [HttpDelete("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Disconnect(Guid id) + { + var account = await _repository.GetByIdAsync(id); + if (account == null) + { + return NotFound(new { success = false, error = "Account not found" }); + } + + account.Disconnect(); + await _repository.UnitOfWork.SaveEntitiesAsync(); + + return NoContent(); + } +} + +public record ConnectAccountRequest( + Guid ShopId, + string PhoneNumberId, + string AccessToken, + string? WebhookUrl); diff --git a/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/EntityConfigurations/AIAgentEntityTypeConfiguration.cs b/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/EntityConfigurations/AIAgentEntityTypeConfiguration.cs new file mode 100644 index 00000000..2fdc97e8 --- /dev/null +++ b/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/EntityConfigurations/AIAgentEntityTypeConfiguration.cs @@ -0,0 +1,50 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using WhatsAppService.Domain.AggregatesModel.AIAgentAggregate; + +namespace WhatsAppService.Infrastructure.EntityConfigurations; + +/// +/// EN: EF Core configuration for AIAgent entity. +/// VI: Cấu hình EF Core cho entity AIAgent. +/// +public class AIAgentEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("ai_agents"); + builder.HasKey(e => e.Id); + builder.Ignore(e => e.DomainEvents); + + builder.Property(e => e.Id).HasColumnName("id").IsRequired(); + builder.Property("_shopId").HasColumnName("shop_id").IsRequired(); + builder.Property("_agentName").HasColumnName("agent_name").HasMaxLength(200).IsRequired(); + builder.Property("_knowledgeBaseId").HasColumnName("knowledge_base_id"); + builder.Property("_isActive").HasColumnName("is_active").HasDefaultValue(false); + builder.Property("_maxPromptTokens").HasColumnName("max_prompt_tokens"); + builder.Property("_maxCompletionTokens").HasColumnName("max_completion_tokens"); + builder.Property("_dailyBudgetUsd").HasColumnName("daily_budget_usd").HasPrecision(10, 2); + builder.Property("_monthlyBudgetUsd").HasColumnName("monthly_budget_usd").HasPrecision(10, 2); + builder.Property("_createdAt").HasColumnName("created_at").IsRequired(); + builder.Property("_updatedAt").HasColumnName("updated_at"); + + // EN: AgentPersonality as owned entity / VI: AgentPersonality như owned entity + builder.OwnsOne("_personality", personality => + { + personality.Property(p => p.Tone).HasColumnName("personality_tone").HasMaxLength(50).HasDefaultValue("friendly"); + personality.Property(p => p.Language).HasColumnName("personality_language").HasMaxLength(20).HasDefaultValue("vietnamese"); + personality.Property(p => p.PromptTemplate).HasColumnName("prompt_template"); + + // EN: Constraints stored as JSONB / VI: Constraints lưu dưới dạng JSONB + personality.Property("ConstraintsJson") + .HasColumnName("personality_constraints") + .HasColumnType("jsonb") + .HasDefaultValueSql("'[]'::jsonb"); + + personality.Ignore(p => p.Constraints); + }); + + builder.HasIndex("_shopId").HasDatabaseName("idx_ai_agents_shop"); + builder.HasIndex("_shopId", "_isActive").HasDatabaseName("idx_ai_agents_shop_active"); + } +} diff --git a/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/EntityConfigurations/AutomationFlowEntityTypeConfiguration.cs b/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/EntityConfigurations/AutomationFlowEntityTypeConfiguration.cs new file mode 100644 index 00000000..72007268 --- /dev/null +++ b/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/EntityConfigurations/AutomationFlowEntityTypeConfiguration.cs @@ -0,0 +1,82 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using WhatsAppService.Domain.AggregatesModel.AutomationFlowAggregate; + +namespace WhatsAppService.Infrastructure.EntityConfigurations; + +/// +/// EN: EF Core configuration for AutomationFlow entity. +/// VI: Cấu hình EF Core cho entity AutomationFlow. +/// +public class AutomationFlowEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("automation_flows"); + builder.HasKey(e => e.Id); + builder.Ignore(e => e.DomainEvents); + + builder.Property(e => e.Id).HasColumnName("id").IsRequired(); + builder.Property("_shopId").HasColumnName("shop_id").IsRequired(); + builder.Property("_flowName").HasColumnName("flow_name").HasMaxLength(200).IsRequired(); + builder.Property("_triggerConfig").HasColumnName("trigger_config").HasColumnType("jsonb").IsRequired(); + builder.Property("_isActive").HasColumnName("is_active").HasDefaultValue(false); + builder.Property("_priority").HasColumnName("priority").HasDefaultValue(50); + builder.Property("_executionCount").HasColumnName("execution_count").HasDefaultValue(0); + builder.Property("_createdAt").HasColumnName("created_at").IsRequired(); + builder.Property("_updatedAt").HasColumnName("updated_at"); + + builder.Property(e => e.TriggerTypeId).HasColumnName("trigger_type_id").IsRequired(); + builder.HasOne(e => e.TriggerType).WithMany().HasForeignKey(e => e.TriggerTypeId).OnDelete(DeleteBehavior.Restrict); + + var stepsNav = builder.Metadata.FindNavigation(nameof(AutomationFlow.Steps))!; + stepsNav.SetPropertyAccessMode(PropertyAccessMode.Field); + + builder.HasIndex("_shopId", "_isActive").HasDatabaseName("idx_flows_shop_active"); + builder.HasIndex(e => e.TriggerTypeId).HasDatabaseName("idx_flows_trigger"); + } +} + +/// +/// EN: EF Core configuration for FlowStep entity. +/// VI: Cấu hình EF Core cho entity FlowStep. +/// +public class FlowStepEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("flow_steps"); + builder.HasKey(e => e.Id); + + builder.Property(e => e.Id).HasColumnName("id").IsRequired(); + builder.Property("_flowId").HasColumnName("flow_id").IsRequired(); + builder.Property("_order").HasColumnName("step_order").IsRequired(); + builder.Property("_action").HasColumnName("action").HasMaxLength(50).IsRequired(); + builder.Property("_actionConfig").HasColumnName("action_config").HasColumnType("jsonb").IsRequired(); + builder.Property("_conditions").HasColumnName("conditions").HasColumnType("jsonb"); + builder.Property("_nextStepMapping").HasColumnName("next_step_mapping").HasColumnType("jsonb"); + + builder.HasIndex("_flowId", "_order").IsUnique().HasDatabaseName("idx_flow_steps_order"); + } +} + +/// +/// EN: EF Core configuration for TriggerType enumeration. +/// VI: Cấu hình EF Core cho enumeration TriggerType. +/// +public class TriggerTypeEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("trigger_types"); + builder.HasKey(s => s.Id); + builder.Property(s => s.Id).HasColumnName("id").ValueGeneratedNever().IsRequired(); + builder.Property(s => s.Name).HasColumnName("name").HasMaxLength(50).IsRequired(); + + builder.HasData( + TriggerType.Keyword, + TriggerType.Event, + TriggerType.Schedule + ); + } +} diff --git a/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/EntityConfigurations/ConversationEntityTypeConfiguration.cs b/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/EntityConfigurations/ConversationEntityTypeConfiguration.cs new file mode 100644 index 00000000..6ba14a6f --- /dev/null +++ b/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/EntityConfigurations/ConversationEntityTypeConfiguration.cs @@ -0,0 +1,99 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using WhatsAppService.Domain.AggregatesModel.ConversationAggregate; + +namespace WhatsAppService.Infrastructure.EntityConfigurations; + +/// +/// EN: EF Core configuration for Conversation entity. +/// VI: Cấu hình EF Core cho entity Conversation. +/// +public class ConversationEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("conversations"); + builder.HasKey(e => e.Id); + builder.Ignore(e => e.DomainEvents); + + builder.Property(e => e.Id).HasColumnName("id").IsRequired(); + builder.Property("_shopId").HasColumnName("shop_id").IsRequired(); + builder.Property("_customerWaId").HasColumnName("customer_wa_id").HasMaxLength(20).IsRequired(); + builder.Property("_whatsAppAccountId").HasColumnName("whatsapp_account_id").IsRequired(); + builder.Property("_assignedAgentId").HasColumnName("assigned_agent_id"); + builder.Property("_lastMessageAt").HasColumnName("last_message_at"); + builder.Property("_createdAt").HasColumnName("created_at").IsRequired(); + builder.Property("_expiresAt").HasColumnName("expires_at"); + + builder.Property>("_tags") + .HasColumnName("tags") + .HasColumnType("jsonb") + .HasDefaultValueSql("'[]'::jsonb"); + + builder.Property(e => e.StatusId).HasColumnName("status_id").IsRequired(); + builder.HasOne(e => e.Status).WithMany().HasForeignKey(e => e.StatusId).OnDelete(DeleteBehavior.Restrict); + + // EN: Messages as owned collection / VI: Messages như collection sở hữu + var messagesNav = builder.Metadata.FindNavigation(nameof(Conversation.Messages))!; + messagesNav.SetPropertyAccessMode(PropertyAccessMode.Field); + + builder.HasIndex("_shopId", "StatusId").HasDatabaseName("idx_conversations_shop_status"); + builder.HasIndex("_customerWaId").HasDatabaseName("idx_conversations_customer"); + builder.HasIndex("_expiresAt").HasDatabaseName("idx_conversations_expires"); + } +} + +/// +/// EN: EF Core configuration for Message entity. +/// VI: Cấu hình EF Core cho entity Message. +/// +public class MessageEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("messages"); + builder.HasKey(e => e.Id); + + builder.Property(e => e.Id).HasColumnName("id").IsRequired(); + builder.Property("_conversationId").HasColumnName("conversation_id").IsRequired(); + builder.Property("_whatsAppMessageId").HasColumnName("whatsapp_message_id").HasMaxLength(100); + builder.Property("_direction").HasColumnName("direction").HasMaxLength(10).IsRequired(); + builder.Property("_status").HasColumnName("status").HasMaxLength(20).HasDefaultValue("sent"); + builder.Property("_timestamp").HasColumnName("timestamp").IsRequired(); + + // EN: MessageContent as owned entity / VI: MessageContent như owned entity + builder.OwnsOne("_content", content => + { + content.Property(c => c.Type).HasColumnName("content_type").HasMaxLength(20).IsRequired(); + content.Property(c => c.Text).HasColumnName("content_text"); + content.Property(c => c.MediaUrl).HasColumnName("media_url").HasMaxLength(500); + content.Property(c => c.Caption).HasColumnName("caption").HasMaxLength(1000); + content.Property(c => c.MimeType).HasColumnName("mime_type").HasMaxLength(100); + content.Property(c => c.InteractiveJson).HasColumnName("interactive_json").HasColumnType("jsonb"); + }); + + builder.HasIndex("_conversationId", "_timestamp").HasDatabaseName("idx_messages_conversation_timestamp"); + builder.HasIndex("_whatsAppMessageId").IsUnique().HasDatabaseName("idx_messages_wa_id"); + } +} + +/// +/// EN: EF Core configuration for ConversationStatus enumeration. +/// VI: Cấu hình EF Core cho enumeration ConversationStatus. +/// +public class ConversationStatusEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("conversation_statuses"); + builder.HasKey(s => s.Id); + builder.Property(s => s.Id).HasColumnName("id").ValueGeneratedNever().IsRequired(); + builder.Property(s => s.Name).HasColumnName("name").HasMaxLength(50).IsRequired(); + + builder.HasData( + ConversationStatus.Active, + ConversationStatus.Closed, + ConversationStatus.Expired + ); + } +} diff --git a/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/EntityConfigurations/CustomerEntityTypeConfiguration.cs b/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/EntityConfigurations/CustomerEntityTypeConfiguration.cs new file mode 100644 index 00000000..69fbd869 --- /dev/null +++ b/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/EntityConfigurations/CustomerEntityTypeConfiguration.cs @@ -0,0 +1,51 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using WhatsAppService.Domain.AggregatesModel.CustomerAggregate; + +namespace WhatsAppService.Infrastructure.EntityConfigurations; + +/// +/// EN: EF Core configuration for Customer entity. +/// VI: Cấu hình EF Core cho entity Customer. +/// +public class CustomerEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("customers"); + builder.HasKey(e => e.Id); + builder.Ignore(e => e.DomainEvents); + + builder.Property(e => e.Id).HasColumnName("id").IsRequired(); + builder.Property("_shopId").HasColumnName("shop_id").IsRequired(); + builder.Property("_waId").HasColumnName("wa_id").HasMaxLength(20).IsRequired(); + builder.Property("_name").HasColumnName("name").HasMaxLength(200); + builder.Property("_profilePictureUrl").HasColumnName("profile_picture_url").HasMaxLength(500); + builder.Property("_firstContactedAt").HasColumnName("first_contacted_at").IsRequired(); + builder.Property("_createdAt").HasColumnName("created_at").IsRequired(); + builder.Property("_updatedAt").HasColumnName("updated_at"); + + // EN: Tags stored as JSONB / VI: Tags lưu dưới dạng JSONB + builder.Property>("_tags") + .HasColumnName("tags") + .HasColumnType("jsonb") + .HasDefaultValueSql("'[]'::jsonb"); + + // EN: Custom fields stored as JSONB / VI: Custom fields lưu dưới dạng JSONB + builder.Property>("_customFields") + .HasColumnName("custom_fields") + .HasColumnType("jsonb") + .HasDefaultValueSql("'{}'::jsonb"); + + // EN: OptInConsent as owned entity / VI: OptInConsent như owned entity + builder.OwnsOne("_consent", consent => + { + consent.Property(c => c.Status).HasColumnName("opt_in_status").HasMaxLength(20).HasDefaultValue("pending"); + consent.Property(c => c.Timestamp).HasColumnName("opt_in_timestamp"); + consent.Property(c => c.Source).HasColumnName("opt_in_source").HasMaxLength(50); + }); + + builder.HasIndex("_shopId", "_waId").IsUnique().HasDatabaseName("idx_customers_shop_waid"); + builder.HasIndex("_shopId").HasDatabaseName("idx_customers_shop"); + } +} diff --git a/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/EntityConfigurations/WhatsAppAccountEntityTypeConfiguration.cs b/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/EntityConfigurations/WhatsAppAccountEntityTypeConfiguration.cs new file mode 100644 index 00000000..7b1f3ea2 --- /dev/null +++ b/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/EntityConfigurations/WhatsAppAccountEntityTypeConfiguration.cs @@ -0,0 +1,57 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using WhatsAppService.Domain.AggregatesModel.WhatsAppAccountAggregate; + +namespace WhatsAppService.Infrastructure.EntityConfigurations; + +/// +/// EN: EF Core configuration for WhatsAppAccount entity. +/// VI: Cấu hình EF Core cho entity WhatsAppAccount. +/// +public class WhatsAppAccountEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("whatsapp_accounts"); + builder.HasKey(e => e.Id); + builder.Ignore(e => e.DomainEvents); + + builder.Property(e => e.Id).HasColumnName("id").IsRequired(); + builder.Property("_shopId").HasColumnName("shop_id").IsRequired(); + builder.Property("_phoneNumberId").HasColumnName("phone_number_id").HasMaxLength(50).IsRequired(); + builder.Property("_accessTokenEncrypted").HasColumnName("access_token_encrypted").IsRequired(); + builder.Property("_webhookUrl").HasColumnName("webhook_url").HasMaxLength(500); + builder.Property("_webhookVerifyToken").HasColumnName("webhook_verify_token").HasMaxLength(100); + builder.Property("_messageTier").HasColumnName("message_tier").HasMaxLength(20).HasDefaultValue("tier_1"); + builder.Property("_createdAt").HasColumnName("created_at").IsRequired(); + builder.Property("_updatedAt").HasColumnName("updated_at"); + + builder.Property(e => e.StatusId).HasColumnName("status_id").IsRequired(); + builder.HasOne(e => e.Status).WithMany().HasForeignKey(e => e.StatusId).OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex("_shopId").HasDatabaseName("idx_whatsapp_accounts_shop"); + builder.HasIndex("_phoneNumberId").IsUnique().HasDatabaseName("idx_whatsapp_accounts_phone"); + } +} + +/// +/// EN: EF Core configuration for WhatsAppAccountStatus enumeration. +/// VI: Cấu hình EF Core cho enumeration WhatsAppAccountStatus. +/// +public class WhatsAppAccountStatusEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("whatsapp_account_statuses"); + builder.HasKey(s => s.Id); + builder.Property(s => s.Id).HasColumnName("id").ValueGeneratedNever().IsRequired(); + builder.Property(s => s.Name).HasColumnName("name").HasMaxLength(50).IsRequired(); + + builder.HasData( + WhatsAppAccountStatus.Pending, + WhatsAppAccountStatus.Active, + WhatsAppAccountStatus.Suspended, + WhatsAppAccountStatus.Disconnected + ); + } +} diff --git a/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/ExternalServices/ILlmService.cs b/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/ExternalServices/ILlmService.cs new file mode 100644 index 00000000..e9211a4c --- /dev/null +++ b/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/ExternalServices/ILlmService.cs @@ -0,0 +1,64 @@ +namespace WhatsAppService.Infrastructure.ExternalServices; + +/// +/// EN: Interface for LLM (Large Language Model) service. +/// VI: Interface cho dịch vụ LLM (Large Language Model). +/// +public interface ILlmService +{ + /// + /// EN: Generate a response based on conversation context. + /// VI: Tạo phản hồi dựa trên ngữ cảnh hội thoại. + /// + Task GenerateResponseAsync( + LlmRequest request, + CancellationToken cancellationToken = default); + + /// + /// EN: Extract intent from user message. + /// VI: Trích xuất ý định từ tin nhắn người dùng. + /// + Task ExtractIntentAsync( + string message, + string[] possibleIntents, + CancellationToken cancellationToken = default); +} + +/// +/// EN: LLM request with conversation context. +/// VI: Yêu cầu LLM với ngữ cảnh hội thoại. +/// +public record LlmRequest( + string SystemPrompt, + List Messages, + int? MaxTokens = null, + double? Temperature = null); + +/// +/// EN: Message in conversation context. +/// VI: Tin nhắn trong ngữ cảnh hội thoại. +/// +public record ConversationMessage( + string Role, // "user", "assistant", "system" + string Content); + +/// +/// EN: LLM response with usage stats. +/// VI: Phản hồi LLM với thống kê sử dụng. +/// +public record LlmResponse( + bool Success, + string? Content, + int PromptTokens, + int CompletionTokens, + string? Error); + +/// +/// EN: Intent extraction result. +/// VI: Kết quả trích xuất ý định. +/// +public record IntentResult( + bool Success, + string? Intent, + double Confidence, + string? Error); diff --git a/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/ExternalServices/IWhatsAppCloudApiClient.cs b/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/ExternalServices/IWhatsAppCloudApiClient.cs new file mode 100644 index 00000000..6a78327c --- /dev/null +++ b/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/ExternalServices/IWhatsAppCloudApiClient.cs @@ -0,0 +1,85 @@ +namespace WhatsAppService.Infrastructure.ExternalServices; + +/// +/// EN: Interface for WhatsApp Cloud API client. +/// VI: Interface cho WhatsApp Cloud API client. +/// +public interface IWhatsAppCloudApiClient +{ + /// + /// EN: Send a text message. + /// VI: Gửi tin nhắn văn bản. + /// + Task SendTextMessageAsync( + string phoneNumberId, + string accessToken, + string recipientWaId, + string text, + CancellationToken cancellationToken = default); + + /// + /// EN: Send a template message (for outside 24h window). + /// VI: Gửi tin nhắn template (cho ngoài cửa sổ 24h). + /// + Task SendTemplateMessageAsync( + string phoneNumberId, + string accessToken, + string recipientWaId, + string templateName, + string languageCode, + object[]? components = null, + CancellationToken cancellationToken = default); + + /// + /// EN: Send a media message (image, video, document, audio). + /// VI: Gửi tin nhắn media (ảnh, video, tài liệu, audio). + /// + Task SendMediaMessageAsync( + string phoneNumberId, + string accessToken, + string recipientWaId, + string mediaType, + string mediaUrl, + string? caption = null, + CancellationToken cancellationToken = default); + + /// + /// EN: Send an interactive message (buttons, list). + /// VI: Gửi tin nhắn interactive (buttons, list). + /// + Task SendInteractiveMessageAsync( + string phoneNumberId, + string accessToken, + string recipientWaId, + object interactiveContent, + CancellationToken cancellationToken = default); + + /// + /// EN: Mark message as read. + /// VI: Đánh dấu tin nhắn đã đọc. + /// + Task MarkMessageAsReadAsync( + string phoneNumberId, + string accessToken, + string messageId, + CancellationToken cancellationToken = default); + + /// + /// EN: Get media URL by media ID. + /// VI: Lấy URL media theo media ID. + /// + Task GetMediaUrlAsync( + string accessToken, + string mediaId, + CancellationToken cancellationToken = default); +} + +/// +/// EN: Result of sending a message. +/// VI: Kết quả gửi tin nhắn. +/// +public record SendMessageResult( + bool Success, + string? MessageId, + string? ErrorCode, + string? ErrorMessage); diff --git a/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/ExternalServices/OpenAILlmService.cs b/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/ExternalServices/OpenAILlmService.cs new file mode 100644 index 00000000..8a417d06 --- /dev/null +++ b/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/ExternalServices/OpenAILlmService.cs @@ -0,0 +1,152 @@ +using System.ClientModel; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using OpenAI; +using OpenAI.Chat; + +namespace WhatsAppService.Infrastructure.ExternalServices; + +/// +/// EN: OpenAI LLM service implementation. +/// VI: Triển khai dịch vụ OpenAI LLM. +/// +public class OpenAILlmService : ILlmService +{ + private readonly ChatClient _chatClient; + private readonly ILogger _logger; + private readonly OpenAISettings _settings; + + public OpenAILlmService( + IOptions settings, + ILogger logger) + { + _settings = settings.Value; + _logger = logger; + + var apiKey = _settings.ApiKey ?? throw new InvalidOperationException("OpenAI API key is not configured"); + var client = new OpenAIClient(apiKey); + _chatClient = client.GetChatClient(_settings.Model ?? "gpt-4o-mini"); + } + + public async Task GenerateResponseAsync( + LlmRequest request, + CancellationToken cancellationToken = default) + { + try + { + var messages = new List(); + + // Add system prompt + if (!string.IsNullOrEmpty(request.SystemPrompt)) + { + messages.Add(ChatMessage.CreateSystemMessage(request.SystemPrompt)); + } + + // Add conversation history + foreach (var msg in request.Messages) + { + messages.Add(msg.Role.ToLowerInvariant() switch + { + "user" => ChatMessage.CreateUserMessage(msg.Content), + "assistant" => ChatMessage.CreateAssistantMessage(msg.Content), + "system" => ChatMessage.CreateSystemMessage(msg.Content), + _ => ChatMessage.CreateUserMessage(msg.Content) + }); + } + + var options = new ChatCompletionOptions + { + MaxOutputTokenCount = request.MaxTokens ?? _settings.MaxTokens ?? 500, + Temperature = (float?)(request.Temperature ?? _settings.Temperature ?? 0.7) + }; + + _logger.LogDebug("Sending request to OpenAI with {MessageCount} messages", messages.Count); + + var response = await _chatClient.CompleteChatAsync(messages, options, cancellationToken); + var completion = response.Value; + + var content = completion.Content.FirstOrDefault()?.Text; + var usage = completion.Usage; + + _logger.LogInformation( + "OpenAI response received. Prompt tokens: {PromptTokens}, Completion tokens: {CompletionTokens}", + usage.InputTokenCount, usage.OutputTokenCount); + + return new LlmResponse( + Success: true, + Content: content, + PromptTokens: usage.InputTokenCount, + CompletionTokens: usage.OutputTokenCount, + Error: null); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to generate response from OpenAI"); + return new LlmResponse( + Success: false, + Content: null, + PromptTokens: 0, + CompletionTokens: 0, + Error: ex.Message); + } + } + + public async Task ExtractIntentAsync( + string message, + string[] possibleIntents, + CancellationToken cancellationToken = default) + { + try + { + var intentsStr = string.Join(", ", possibleIntents); + var systemPrompt = $"You are an intent classifier. Analyze the user message and classify it into one of these intents: {intentsStr}. Respond with JSON only in this format: {{\"intent\": \"intent_name\", \"confidence\": 0.0-1.0}}"; + + var messages = new List + { + ChatMessage.CreateSystemMessage(systemPrompt), + ChatMessage.CreateUserMessage(message) + }; + + var options = new ChatCompletionOptions + { + MaxOutputTokenCount = 100, + Temperature = 0.1f + }; + + var response = await _chatClient.CompleteChatAsync(messages, options, cancellationToken); + var content = response.Value.Content.FirstOrDefault()?.Text ?? "{}"; + + var result = JsonSerializer.Deserialize(content); + + return new IntentResult( + Success: true, + Intent: result?.Intent, + Confidence: result?.Confidence ?? 0, + Error: null); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to extract intent"); + return new IntentResult( + Success: false, + Intent: null, + Confidence: 0, + Error: ex.Message); + } + } + + private record IntentExtraction(string? Intent, double Confidence); +} + +/// +/// EN: OpenAI configuration settings. +/// VI: Cấu hình OpenAI. +/// +public class OpenAISettings +{ + public string? ApiKey { get; set; } + public string? Model { get; set; } = "gpt-4o-mini"; + public int? MaxTokens { get; set; } = 500; + public double? Temperature { get; set; } = 0.7; +} diff --git a/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/ExternalServices/WhatsAppCloudApiClient.cs b/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/ExternalServices/WhatsAppCloudApiClient.cs new file mode 100644 index 00000000..159032aa --- /dev/null +++ b/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/ExternalServices/WhatsAppCloudApiClient.cs @@ -0,0 +1,238 @@ +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; + +namespace WhatsAppService.Infrastructure.ExternalServices; + +/// +/// EN: WhatsApp Cloud API client implementation with resilience. +/// VI: Triển khai WhatsApp Cloud API client với khả năng phục hồi. +/// +public class WhatsAppCloudApiClient : IWhatsAppCloudApiClient +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private readonly JsonSerializerOptions _jsonOptions; + + private const string BaseUrl = "https://graph.facebook.com"; + private const string ApiVersion = "v18.0"; + + public WhatsAppCloudApiClient(HttpClient httpClient, ILogger logger) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _jsonOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + } + + public async Task SendTextMessageAsync( + string phoneNumberId, + string accessToken, + string recipientWaId, + string text, + CancellationToken cancellationToken = default) + { + var payload = new + { + messaging_product = "whatsapp", + recipient_type = "individual", + to = recipientWaId, + type = "text", + text = new { body = text } + }; + + return await SendMessageAsync(phoneNumberId, accessToken, payload, cancellationToken); + } + + public async Task SendTemplateMessageAsync( + string phoneNumberId, + string accessToken, + string recipientWaId, + string templateName, + string languageCode, + object[]? components = null, + CancellationToken cancellationToken = default) + { + var template = new Dictionary + { + ["name"] = templateName, + ["language"] = new { code = languageCode } + }; + + if (components != null) + { + template["components"] = components; + } + + var payload = new + { + messaging_product = "whatsapp", + recipient_type = "individual", + to = recipientWaId, + type = "template", + template + }; + + return await SendMessageAsync(phoneNumberId, accessToken, payload, cancellationToken); + } + + public async Task SendMediaMessageAsync( + string phoneNumberId, + string accessToken, + string recipientWaId, + string mediaType, + string mediaUrl, + string? caption = null, + CancellationToken cancellationToken = default) + { + var mediaContent = new Dictionary { ["link"] = mediaUrl }; + if (!string.IsNullOrEmpty(caption)) + { + mediaContent["caption"] = caption; + } + + var payload = new Dictionary + { + ["messaging_product"] = "whatsapp", + ["recipient_type"] = "individual", + ["to"] = recipientWaId, + ["type"] = mediaType, + [mediaType] = mediaContent + }; + + return await SendMessageAsync(phoneNumberId, accessToken, payload, cancellationToken); + } + + public async Task SendInteractiveMessageAsync( + string phoneNumberId, + string accessToken, + string recipientWaId, + object interactiveContent, + CancellationToken cancellationToken = default) + { + var payload = new + { + messaging_product = "whatsapp", + recipient_type = "individual", + to = recipientWaId, + type = "interactive", + interactive = interactiveContent + }; + + return await SendMessageAsync(phoneNumberId, accessToken, payload, cancellationToken); + } + + public async Task MarkMessageAsReadAsync( + string phoneNumberId, + string accessToken, + string messageId, + CancellationToken cancellationToken = default) + { + try + { + var url = $"{BaseUrl}/{ApiVersion}/{phoneNumberId}/messages"; + var payload = new + { + messaging_product = "whatsapp", + status = "read", + message_id = messageId + }; + + using var request = new HttpRequestMessage(HttpMethod.Post, url); + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken); + request.Content = JsonContent.Create(payload, options: _jsonOptions); + + var response = await _httpClient.SendAsync(request, cancellationToken); + return response.IsSuccessStatusCode; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to mark message {MessageId} as read", messageId); + return false; + } + } + + public async Task GetMediaUrlAsync( + string accessToken, + string mediaId, + CancellationToken cancellationToken = default) + { + try + { + var url = $"{BaseUrl}/{ApiVersion}/{mediaId}"; + using var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken); + + var response = await _httpClient.SendAsync(request, cancellationToken); + if (!response.IsSuccessStatusCode) return null; + + var result = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + return result?.Url; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to get media URL for {MediaId}", mediaId); + return null; + } + } + + private async Task SendMessageAsync( + string phoneNumberId, + string accessToken, + object payload, + CancellationToken cancellationToken) + { + try + { + var url = $"{BaseUrl}/{ApiVersion}/{phoneNumberId}/messages"; + + using var request = new HttpRequestMessage(HttpMethod.Post, url); + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken); + request.Content = JsonContent.Create(payload, options: _jsonOptions); + + _logger.LogDebug("Sending message to WhatsApp API: {Url}", url); + + var response = await _httpClient.SendAsync(request, cancellationToken); + var content = await response.Content.ReadAsStringAsync(cancellationToken); + + if (response.IsSuccessStatusCode) + { + var result = JsonSerializer.Deserialize(content, _jsonOptions); + var messageId = result?.Messages?.FirstOrDefault()?.Id; + + _logger.LogInformation("Message sent successfully. MessageId: {MessageId}", messageId); + return new SendMessageResult(true, messageId, null, null); + } + + var error = JsonSerializer.Deserialize(content, _jsonOptions); + _logger.LogWarning("WhatsApp API error: {Code} - {Message}", error?.Error?.Code, error?.Error?.Message); + + return new SendMessageResult(false, null, error?.Error?.Code?.ToString(), error?.Error?.Message); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send message via WhatsApp API"); + return new SendMessageResult(false, null, "EXCEPTION", ex.Message); + } + } + + private record WhatsAppSendResponse( + [property: JsonPropertyName("messages")] List? Messages); + + private record MessageInfo( + [property: JsonPropertyName("id")] string? Id); + + private record WhatsAppErrorResponse( + [property: JsonPropertyName("error")] ErrorInfo? Error); + + private record ErrorInfo( + [property: JsonPropertyName("code")] int? Code, + [property: JsonPropertyName("message")] string? Message); + + private record MediaResponse( + [property: JsonPropertyName("url")] string? Url); +} diff --git a/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/MyServiceContext.cs b/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/MyServiceContext.cs index 6028c536..25effbc0 100644 --- a/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/MyServiceContext.cs +++ b/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/MyServiceContext.cs @@ -1,7 +1,12 @@ using MediatR; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; +using WhatsAppService.Domain.AggregatesModel.AIAgentAggregate; +using WhatsAppService.Domain.AggregatesModel.AutomationFlowAggregate; +using WhatsAppService.Domain.AggregatesModel.ConversationAggregate; +using WhatsAppService.Domain.AggregatesModel.CustomerAggregate; using WhatsAppService.Domain.AggregatesModel.SampleAggregate; +using WhatsAppService.Domain.AggregatesModel.WhatsAppAccountAggregate; using WhatsAppService.Domain.SeedWork; using WhatsAppService.Infrastructure.EntityConfigurations; @@ -16,12 +21,26 @@ public class WhatsAppServiceContext : DbContext, IUnitOfWork private readonly IMediator _mediator; private IDbContextTransaction? _currentTransaction; - /// - /// EN: Samples table. - /// VI: Bảng Samples. - /// + // EN: Sample (template) / VI: Sample (mẫu) public DbSet Samples => Set(); + // EN: WhatsApp Accounts / VI: Tài khoản WhatsApp + public DbSet WhatsAppAccounts => Set(); + + // EN: Customers / VI: Khách hàng + public DbSet Customers => Set(); + + // EN: Conversations & Messages / VI: Hội thoại & Tin nhắn + public DbSet Conversations => Set(); + public DbSet Messages => Set(); + + // EN: Automation Flows / VI: Luồng tự động + public DbSet AutomationFlows => Set(); + public DbSet FlowSteps => Set(); + + // EN: AI Agents / VI: AI Agents + public DbSet AIAgents => Set(); + /// /// EN: Read-only access to current transaction. /// VI: Truy cập chỉ đọc đến transaction hiện tại. @@ -50,8 +69,30 @@ public class WhatsAppServiceContext : DbContext, IUnitOfWork { // EN: Apply entity configurations // VI: Áp dụng các cấu hình entity + + // Sample (template) modelBuilder.ApplyConfiguration(new SampleEntityTypeConfiguration()); modelBuilder.ApplyConfiguration(new SampleStatusEntityTypeConfiguration()); + + // WhatsApp Account + modelBuilder.ApplyConfiguration(new WhatsAppAccountEntityTypeConfiguration()); + modelBuilder.ApplyConfiguration(new WhatsAppAccountStatusEntityTypeConfiguration()); + + // Customer + modelBuilder.ApplyConfiguration(new CustomerEntityTypeConfiguration()); + + // Conversation & Message + modelBuilder.ApplyConfiguration(new ConversationEntityTypeConfiguration()); + modelBuilder.ApplyConfiguration(new MessageEntityTypeConfiguration()); + modelBuilder.ApplyConfiguration(new ConversationStatusEntityTypeConfiguration()); + + // Automation Flow + modelBuilder.ApplyConfiguration(new AutomationFlowEntityTypeConfiguration()); + modelBuilder.ApplyConfiguration(new FlowStepEntityTypeConfiguration()); + modelBuilder.ApplyConfiguration(new TriggerTypeEntityTypeConfiguration()); + + // AI Agent + modelBuilder.ApplyConfiguration(new AIAgentEntityTypeConfiguration()); } /// diff --git a/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/Repositories/AIAgentRepository.cs b/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/Repositories/AIAgentRepository.cs new file mode 100644 index 00000000..48ddf0f6 --- /dev/null +++ b/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/Repositories/AIAgentRepository.cs @@ -0,0 +1,54 @@ +using Microsoft.EntityFrameworkCore; +using WhatsAppService.Domain.AggregatesModel.AIAgentAggregate; +using WhatsAppService.Domain.SeedWork; + +namespace WhatsAppService.Infrastructure.Repositories; + +/// +/// EN: Repository implementation for AIAgent aggregate. +/// VI: Triển khai repository cho AIAgent aggregate. +/// +public class AIAgentRepository : IAIAgentRepository +{ + private readonly WhatsAppServiceContext _context; + + public IUnitOfWork UnitOfWork => _context; + + public AIAgentRepository(WhatsAppServiceContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + public async Task AddAsync(AIAgent agent, CancellationToken cancellationToken = default) + { + var entry = await _context.AIAgents.AddAsync(agent, cancellationToken); + return entry.Entity; + } + + public void Update(AIAgent agent) + { + _context.Entry(agent).State = EntityState.Modified; + } + + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _context.AIAgents.FirstOrDefaultAsync(a => a.Id == id, cancellationToken); + } + + public async Task GetActiveByShopIdAsync(Guid shopId, CancellationToken cancellationToken = default) + { + return await _context.AIAgents + .FirstOrDefaultAsync(a => + EF.Property(a, "_shopId") == shopId && + EF.Property(a, "_isActive") == true, + cancellationToken); + } + + public async Task> GetByShopIdAsync(Guid shopId, CancellationToken cancellationToken = default) + { + return await _context.AIAgents + .Where(a => EF.Property(a, "_shopId") == shopId) + .OrderByDescending(a => EF.Property(a, "_createdAt")) + .ToListAsync(cancellationToken); + } +} diff --git a/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/Repositories/AutomationFlowRepository.cs b/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/Repositories/AutomationFlowRepository.cs new file mode 100644 index 00000000..1fa76c04 --- /dev/null +++ b/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/Repositories/AutomationFlowRepository.cs @@ -0,0 +1,65 @@ +using Microsoft.EntityFrameworkCore; +using WhatsAppService.Domain.AggregatesModel.AutomationFlowAggregate; +using WhatsAppService.Domain.SeedWork; + +namespace WhatsAppService.Infrastructure.Repositories; + +/// +/// EN: Repository implementation for AutomationFlow aggregate. +/// VI: Triển khai repository cho AutomationFlow aggregate. +/// +public class AutomationFlowRepository : IAutomationFlowRepository +{ + private readonly WhatsAppServiceContext _context; + + public IUnitOfWork UnitOfWork => _context; + + public AutomationFlowRepository(WhatsAppServiceContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + public async Task AddAsync(AutomationFlow flow, CancellationToken cancellationToken = default) + { + var entry = await _context.AutomationFlows.AddAsync(flow, cancellationToken); + return entry.Entity; + } + + public void Update(AutomationFlow flow) + { + _context.Entry(flow).State = EntityState.Modified; + } + + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _context.AutomationFlows + .Include(f => f.TriggerType) + .Include(f => f.Steps.OrderBy(s => EF.Property(s, "_order"))) + .FirstOrDefaultAsync(f => f.Id == id, cancellationToken); + } + + public async Task> GetActiveByShopIdAsync(Guid shopId, CancellationToken cancellationToken = default) + { + return await _context.AutomationFlows + .Include(f => f.TriggerType) + .Include(f => f.Steps.OrderBy(s => EF.Property(s, "_order"))) + .Where(f => + EF.Property(f, "_shopId") == shopId && + EF.Property(f, "_isActive") == true) + .OrderByDescending(f => EF.Property(f, "_priority")) + .ToListAsync(cancellationToken); + } + + public async Task> GetByTriggerTypeAsync(Guid shopId, TriggerType triggerType, CancellationToken cancellationToken = default) + { + return await _context.AutomationFlows + .Include(f => f.TriggerType) + .Include(f => f.Steps.OrderBy(s => EF.Property(s, "_order"))) + .Where(f => + EF.Property(f, "_shopId") == shopId && + f.TriggerTypeId == triggerType.Id && + EF.Property(f, "_isActive") == true) + .OrderByDescending(f => EF.Property(f, "_priority")) + .ToListAsync(cancellationToken); + } +} diff --git a/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/Repositories/ConversationRepository.cs b/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/Repositories/ConversationRepository.cs new file mode 100644 index 00000000..86753c70 --- /dev/null +++ b/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/Repositories/ConversationRepository.cs @@ -0,0 +1,81 @@ +using Microsoft.EntityFrameworkCore; +using WhatsAppService.Domain.AggregatesModel.ConversationAggregate; +using WhatsAppService.Domain.SeedWork; + +namespace WhatsAppService.Infrastructure.Repositories; + +/// +/// EN: Repository implementation for Conversation aggregate. +/// VI: Triển khai repository cho Conversation aggregate. +/// +public class ConversationRepository : IConversationRepository +{ + private readonly WhatsAppServiceContext _context; + + public IUnitOfWork UnitOfWork => _context; + + public ConversationRepository(WhatsAppServiceContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + public async Task AddAsync(Conversation conversation, CancellationToken cancellationToken = default) + { + var entry = await _context.Conversations.AddAsync(conversation, cancellationToken); + return entry.Entity; + } + + public void Update(Conversation conversation) + { + _context.Entry(conversation).State = EntityState.Modified; + } + + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _context.Conversations + .Include(c => c.Status) + .Include(c => c.Messages) + .FirstOrDefaultAsync(c => c.Id == id, cancellationToken); + } + + public async Task GetActiveByCustomerAsync(Guid shopId, string customerWaId, CancellationToken cancellationToken = default) + { + return await _context.Conversations + .Include(c => c.Status) + .Include(c => c.Messages) + .FirstOrDefaultAsync(c => + EF.Property(c, "_shopId") == shopId && + EF.Property(c, "_customerWaId") == customerWaId && + c.StatusId == ConversationStatus.Active.Id, + cancellationToken); + } + + public async Task> GetByShopIdAsync(Guid shopId, int skip, int take, ConversationStatus? status = null, CancellationToken cancellationToken = default) + { + var query = _context.Conversations + .Include(c => c.Status) + .Where(c => EF.Property(c, "_shopId") == shopId); + + if (status != null) + { + query = query.Where(c => c.StatusId == status.Id); + } + + return await query + .OrderByDescending(c => EF.Property(c, "_lastMessageAt")) + .Skip(skip) + .Take(take) + .ToListAsync(cancellationToken); + } + + public async Task> GetExpiredConversationsAsync(CancellationToken cancellationToken = default) + { + var now = DateTime.UtcNow; + return await _context.Conversations + .Include(c => c.Status) + .Where(c => + c.StatusId == ConversationStatus.Active.Id && + EF.Property(c, "_expiresAt") < now) + .ToListAsync(cancellationToken); + } +} diff --git a/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/Repositories/CustomerRepository.cs b/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/Repositories/CustomerRepository.cs new file mode 100644 index 00000000..f6d29edd --- /dev/null +++ b/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/Repositories/CustomerRepository.cs @@ -0,0 +1,65 @@ +using Microsoft.EntityFrameworkCore; +using WhatsAppService.Domain.AggregatesModel.CustomerAggregate; +using WhatsAppService.Domain.SeedWork; + +namespace WhatsAppService.Infrastructure.Repositories; + +/// +/// EN: Repository implementation for Customer aggregate. +/// VI: Triển khai repository cho Customer aggregate. +/// +public class CustomerRepository : ICustomerRepository +{ + private readonly WhatsAppServiceContext _context; + + public IUnitOfWork UnitOfWork => _context; + + public CustomerRepository(WhatsAppServiceContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + public async Task AddAsync(Customer customer, CancellationToken cancellationToken = default) + { + var entry = await _context.Customers.AddAsync(customer, cancellationToken); + return entry.Entity; + } + + public void Update(Customer customer) + { + _context.Entry(customer).State = EntityState.Modified; + } + + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _context.Customers.FirstOrDefaultAsync(c => c.Id == id, cancellationToken); + } + + public async Task GetByWaIdAsync(Guid shopId, string waId, CancellationToken cancellationToken = default) + { + return await _context.Customers + .FirstOrDefaultAsync(c => + EF.Property(c, "_shopId") == shopId && + EF.Property(c, "_waId") == waId, + cancellationToken); + } + + public async Task> GetByShopIdAsync(Guid shopId, int skip, int take, CancellationToken cancellationToken = default) + { + return await _context.Customers + .Where(c => EF.Property(c, "_shopId") == shopId) + .OrderByDescending(c => EF.Property(c, "_createdAt")) + .Skip(skip) + .Take(take) + .ToListAsync(cancellationToken); + } + + public async Task> GetByTagsAsync(Guid shopId, IEnumerable tags, CancellationToken cancellationToken = default) + { + var tagList = tags.ToList(); + return await _context.Customers + .Where(c => EF.Property(c, "_shopId") == shopId) + .ToListAsync(cancellationToken); + // Note: Full JSONB tag filtering would require raw SQL for optimal performance + } +} diff --git a/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/Repositories/WhatsAppAccountRepository.cs b/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/Repositories/WhatsAppAccountRepository.cs new file mode 100644 index 00000000..96c80efd --- /dev/null +++ b/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/Repositories/WhatsAppAccountRepository.cs @@ -0,0 +1,53 @@ +using Microsoft.EntityFrameworkCore; +using WhatsAppService.Domain.AggregatesModel.WhatsAppAccountAggregate; +using WhatsAppService.Domain.SeedWork; + +namespace WhatsAppService.Infrastructure.Repositories; + +/// +/// EN: Repository implementation for WhatsAppAccount aggregate. +/// VI: Triển khai repository cho WhatsAppAccount aggregate. +/// +public class WhatsAppAccountRepository : IWhatsAppAccountRepository +{ + private readonly WhatsAppServiceContext _context; + + public IUnitOfWork UnitOfWork => _context; + + public WhatsAppAccountRepository(WhatsAppServiceContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + public async Task AddAsync(WhatsAppAccount account, CancellationToken cancellationToken = default) + { + var entry = await _context.WhatsAppAccounts.AddAsync(account, cancellationToken); + return entry.Entity; + } + + public void Update(WhatsAppAccount account) + { + _context.Entry(account).State = EntityState.Modified; + } + + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _context.WhatsAppAccounts + .Include(a => a.Status) + .FirstOrDefaultAsync(a => a.Id == id, cancellationToken); + } + + public async Task GetByShopIdAsync(Guid shopId, CancellationToken cancellationToken = default) + { + return await _context.WhatsAppAccounts + .Include(a => a.Status) + .FirstOrDefaultAsync(a => EF.Property(a, "_shopId") == shopId, cancellationToken); + } + + public async Task GetByPhoneNumberIdAsync(string phoneNumberId, CancellationToken cancellationToken = default) + { + return await _context.WhatsAppAccounts + .Include(a => a.Status) + .FirstOrDefaultAsync(a => EF.Property(a, "_phoneNumberId") == phoneNumberId, cancellationToken); + } +} diff --git a/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/WhatsAppService.Infrastructure.csproj b/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/WhatsAppService.Infrastructure.csproj index caea8048..a29ca9ed 100644 --- a/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/WhatsAppService.Infrastructure.csproj +++ b/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/WhatsAppService.Infrastructure.csproj @@ -23,6 +23,7 @@ + diff --git a/services/mkt-x-service-net/src/MktXService.Infrastructure/EntityConfigurations/AIConversationSessionEntityTypeConfiguration.cs b/services/mkt-x-service-net/src/MktXService.Infrastructure/EntityConfigurations/AIConversationSessionEntityTypeConfiguration.cs new file mode 100644 index 00000000..4dd2edb9 --- /dev/null +++ b/services/mkt-x-service-net/src/MktXService.Infrastructure/EntityConfigurations/AIConversationSessionEntityTypeConfiguration.cs @@ -0,0 +1,97 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using MktXService.Domain.AggregatesModel.AIConversationSessionAggregate; + +namespace MktXService.Infrastructure.EntityConfigurations; + +/// +/// EN: EF Core configuration for AIConversationSession entity with JSONB for context/slots. +/// VI: Cấu hình EF Core cho entity AIConversationSession với JSONB cho context/slots. +/// +public class AIConversationSessionEntityTypeConfiguration : IEntityTypeConfiguration +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; + + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("ai_conversation_sessions"); + + builder.HasKey(s => s.Id); + + builder.Ignore(s => s.DomainEvents); + + builder.Property(s => s.Id) + .HasColumnName("id") + .IsRequired(); + + builder.Property("_conversationId") + .HasColumnName("conversation_id") + .IsRequired(); + + builder.Property("_currentIntent") + .HasColumnName("current_intent") + .HasMaxLength(100); + + builder.Property("_isActive") + .HasColumnName("is_active") + .IsRequired(); + + builder.Property("_lastActivityAt") + .HasColumnName("last_activity_at"); + + builder.Property("_createdAt") + .HasColumnName("created_at") + .IsRequired(); + + // =========================================== + // EN: JSONB for conversation context (chat history) + // VI: JSONB cho ngữ cảnh hội thoại (lịch sử chat) + // =========================================== + builder.Property>("_context") + .HasColumnName("context") + .HasColumnType("jsonb") + .HasConversion( + v => JsonSerializer.Serialize(v, JsonOptions), + v => JsonSerializer.Deserialize>(v, JsonOptions) + ?? new List(), + new ValueComparer>( + (c1, c2) => c1!.SequenceEqual(c2!), + c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())), + c => c.ToList())); + + // EN: JSONB for slots (extracted entities from conversation) + // VI: JSONB cho slots (entities được trích xuất từ hội thoại) + builder.Property>("_slots") + .HasColumnName("slots") + .HasColumnType("jsonb") + .HasConversion( + v => JsonSerializer.Serialize(v, JsonOptions), + v => JsonSerializer.Deserialize>(v, JsonOptions) + ?? new Dictionary(), + new ValueComparer>( + (c1, c2) => c1!.SequenceEqual(c2!), + c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())), + c => new Dictionary(c))); + + // =========================================== + // EN: Indexes + // VI: Các index + // =========================================== + builder.HasIndex("_conversationId") + .HasDatabaseName("ix_ai_sessions_conversation_id"); + + builder.HasIndex("_isActive") + .HasDatabaseName("ix_ai_sessions_is_active"); + + // EN: Composite index for finding active session by conversation + // VI: Index kết hợp để tìm session active theo conversation + builder.HasIndex("_conversationId", "_isActive") + .HasDatabaseName("ix_ai_sessions_conversation_active"); + } +} diff --git a/services/mkt-x-service-net/src/MktXService.Infrastructure/EntityConfigurations/AutomationFlowEntityTypeConfiguration.cs b/services/mkt-x-service-net/src/MktXService.Infrastructure/EntityConfigurations/AutomationFlowEntityTypeConfiguration.cs new file mode 100644 index 00000000..6931df88 --- /dev/null +++ b/services/mkt-x-service-net/src/MktXService.Infrastructure/EntityConfigurations/AutomationFlowEntityTypeConfiguration.cs @@ -0,0 +1,218 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using MktXService.Domain.AggregatesModel.AutomationFlowAggregate; + +namespace MktXService.Infrastructure.EntityConfigurations; + +/// +/// EN: EF Core configuration for AutomationFlow entity with JSONB for trigger/config. +/// VI: Cấu hình EF Core cho entity AutomationFlow với JSONB cho trigger/config. +/// +public class AutomationFlowEntityTypeConfiguration : IEntityTypeConfiguration +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; + + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("automation_flows"); + + builder.HasKey(f => f.Id); + + builder.Ignore(f => f.DomainEvents); + + // EN: Basic properties + // VI: Các thuộc tính cơ bản + builder.Property(f => f.Id) + .HasColumnName("id") + .IsRequired(); + + builder.Property("_merchantId") + .HasColumnName("merchant_id") + .IsRequired(); + + builder.Property("_name") + .HasColumnName("name") + .HasMaxLength(200) + .IsRequired(); + + builder.Property(f => f.StatusId) + .HasColumnName("status_id") + .IsRequired(); + + builder.Property("_createdAt") + .HasColumnName("created_at") + .IsRequired(); + + builder.Property("_updatedAt") + .HasColumnName("updated_at"); + + // =========================================== + // EN: JSONB Column for FlowTrigger (Value Object) + // VI: Cột JSONB cho FlowTrigger (Value Object) + // =========================================== + builder.Property("_trigger") + .HasColumnName("trigger") + .HasColumnType("jsonb") + .HasConversion( + v => JsonSerializer.Serialize(v, JsonOptions), + v => JsonSerializer.Deserialize(v, JsonOptions)!) + .IsRequired(); + + // =========================================== + // EN: Relationships + // VI: Quan hệ + // =========================================== + builder.HasOne(f => f.Status) + .WithMany() + .HasForeignKey(f => f.StatusId) + .OnDelete(DeleteBehavior.Restrict); + + // EN: One-to-many with FlowNodes + // VI: Quan hệ một-nhiều với FlowNodes + builder.HasMany(f => f.Nodes) + .WithOne() + .HasForeignKey(n => n.FlowId) + .OnDelete(DeleteBehavior.Cascade); + + // EN: One-to-many with FlowConnections + // VI: Quan hệ một-nhiều với FlowConnections + builder.HasMany(f => f.Connections) + .WithOne() + .HasForeignKey(c => c.FlowId) + .OnDelete(DeleteBehavior.Cascade); + + // =========================================== + // EN: Indexes + // VI: Các index + // =========================================== + builder.HasIndex("_merchantId") + .HasDatabaseName("ix_automation_flows_merchant_id"); + + builder.HasIndex(f => f.StatusId) + .HasDatabaseName("ix_automation_flows_status_id"); + } +} + +/// +/// EN: EF Core configuration for FlowNode entity. +/// VI: Cấu hình EF Core cho entity FlowNode. +/// +public class FlowNodeEntityTypeConfiguration : IEntityTypeConfiguration +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; + + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("flow_nodes"); + + builder.HasKey(n => n.Id); + + builder.Property(n => n.Id) + .HasColumnName("id") + .IsRequired(); + + builder.Property(n => n.FlowId) + .HasColumnName("flow_id") + .IsRequired(); + + builder.Property(n => n.Type) + .HasColumnName("type") + .HasMaxLength(50) + .IsRequired(); + + builder.Property(n => n.PositionX) + .HasColumnName("position_x"); + + builder.Property(n => n.PositionY) + .HasColumnName("position_y"); + + // EN: Config stored as JSONB + // VI: Config lưu dưới dạng JSONB + builder.Property(n => n.Config) + .HasColumnName("config") + .HasColumnType("jsonb") + .HasConversion( + v => v == null ? null : JsonSerializer.Serialize(v, JsonOptions), + v => v == null ? null : JsonSerializer.Deserialize>(v, JsonOptions)); + + builder.HasIndex(n => n.FlowId) + .HasDatabaseName("ix_flow_nodes_flow_id"); + } +} + +/// +/// EN: EF Core configuration for FlowConnection entity. +/// VI: Cấu hình EF Core cho entity FlowConnection. +/// +public class FlowConnectionEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("flow_connections"); + + builder.HasKey(c => c.Id); + + builder.Property(c => c.Id) + .HasColumnName("id") + .IsRequired(); + + builder.Property(c => c.FlowId) + .HasColumnName("flow_id") + .IsRequired(); + + builder.Property(c => c.FromNodeId) + .HasColumnName("from_node_id") + .IsRequired(); + + builder.Property(c => c.ToNodeId) + .HasColumnName("to_node_id") + .IsRequired(); + + builder.Property(c => c.Condition) + .HasColumnName("condition") + .HasMaxLength(500); + + builder.HasIndex(c => c.FlowId) + .HasDatabaseName("ix_flow_connections_flow_id"); + } +} + +/// +/// EN: EF Core configuration for FlowStatus enumeration. +/// VI: Cấu hình EF Core cho enumeration FlowStatus. +/// +public class FlowStatusEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("flow_statuses"); + + builder.HasKey(s => s.Id); + + builder.Property(s => s.Id) + .HasColumnName("id") + .ValueGeneratedNever() + .IsRequired(); + + builder.Property(s => s.Name) + .HasColumnName("name") + .HasMaxLength(50) + .IsRequired(); + + builder.HasData( + FlowStatus.Draft, + FlowStatus.Active, + FlowStatus.Inactive + ); + } +} diff --git a/services/mkt-x-service-net/src/MktXService.Infrastructure/EntityConfigurations/CampaignEntityTypeConfiguration.cs b/services/mkt-x-service-net/src/MktXService.Infrastructure/EntityConfigurations/CampaignEntityTypeConfiguration.cs new file mode 100644 index 00000000..44284aa5 --- /dev/null +++ b/services/mkt-x-service-net/src/MktXService.Infrastructure/EntityConfigurations/CampaignEntityTypeConfiguration.cs @@ -0,0 +1,173 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using MktXService.Domain.AggregatesModel.CampaignAggregate; + +namespace MktXService.Infrastructure.EntityConfigurations; + +/// +/// EN: EF Core configuration for Campaign entity with JSONB support. +/// VI: Cấu hình EF Core cho entity Campaign với hỗ trợ JSONB. +/// +public class CampaignEntityTypeConfiguration : IEntityTypeConfiguration +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; + + public void Configure(EntityTypeBuilder builder) + { + // EN: Table name (snake_case for PostgreSQL) + // VI: Tên bảng (snake_case cho PostgreSQL) + builder.ToTable("campaigns"); + + // EN: Primary key + // VI: Khóa chính + builder.HasKey(c => c.Id); + + // EN: Ignore domain events + // VI: Bỏ qua domain events + builder.Ignore(c => c.DomainEvents); + + // EN: Basic properties + // VI: Các thuộc tính cơ bản + builder.Property(c => c.Id) + .HasColumnName("id") + .IsRequired(); + + builder.Property("_merchantId") + .HasColumnName("merchant_id") + .IsRequired(); + + builder.Property("_name") + .HasColumnName("name") + .HasMaxLength(200) + .IsRequired(); + + builder.Property("_type") + .HasColumnName("type") + .HasMaxLength(50) + .IsRequired(); + + builder.Property(c => c.StatusId) + .HasColumnName("status_id") + .IsRequired(); + + builder.Property("_templateId") + .HasColumnName("template_id"); + + builder.Property("_createdAt") + .HasColumnName("created_at") + .IsRequired(); + + builder.Property("_updatedAt") + .HasColumnName("updated_at"); + + // =========================================== + // EN: JSONB Columns for Complex Types + // VI: Cột JSONB cho các kiểu phức tạp + // =========================================== + + // EN: SegmentIds stored as JSONB array + // VI: SegmentIds lưu dưới dạng mảng JSONB + builder.Property>("_segmentIds") + .HasColumnName("segment_ids") + .HasColumnType("jsonb") + .HasConversion( + v => JsonSerializer.Serialize(v, JsonOptions), + v => JsonSerializer.Deserialize>(v, JsonOptions) ?? new List(), + new ValueComparer>( + (c1, c2) => c1!.SequenceEqual(c2!), + c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())), + c => c.ToList())); + + // EN: Schedule stored as JSONB (Value Object) + // VI: Schedule lưu dưới dạng JSONB (Value Object) + builder.Property("_schedule") + .HasColumnName("schedule") + .HasColumnType("jsonb") + .HasConversion( + v => v == null ? null : JsonSerializer.Serialize(v, JsonOptions), + v => v == null ? null : JsonSerializer.Deserialize(v, JsonOptions)); + + // EN: Metrics stored as JSONB (Value Object) + // VI: Metrics lưu dưới dạng JSONB (Value Object) + builder.Property("_metrics") + .HasColumnName("metrics") + .HasColumnType("jsonb") + .HasConversion( + v => JsonSerializer.Serialize(v, JsonOptions), + v => JsonSerializer.Deserialize(v, JsonOptions) + ?? new CampaignMetrics(0, 0, 0, 0, 0, 0)) + .IsRequired(); + + // =========================================== + // EN: Relationships + // VI: Quan hệ + // =========================================== + + // EN: Status relationship (Enumeration pattern) + // VI: Quan hệ với Status (pattern Enumeration) + builder.HasOne(c => c.Status) + .WithMany() + .HasForeignKey(c => c.StatusId) + .OnDelete(DeleteBehavior.Restrict); + + // =========================================== + // EN: Indexes for commonly queried columns + // VI: Index cho các cột hay truy vấn + // =========================================== + builder.HasIndex("_merchantId") + .HasDatabaseName("ix_campaigns_merchant_id"); + + builder.HasIndex(c => c.StatusId) + .HasDatabaseName("ix_campaigns_status_id"); + + builder.HasIndex("_createdAt") + .HasDatabaseName("ix_campaigns_created_at") + .IsDescending(); + + // EN: Composite index for merchant + status queries + // VI: Index kết hợp cho truy vấn merchant + status + builder.HasIndex("_merchantId", "StatusId") + .HasDatabaseName("ix_campaigns_merchant_status"); + } +} + +/// +/// EN: EF Core configuration for CampaignStatus enumeration. +/// VI: Cấu hình EF Core cho enumeration CampaignStatus. +/// +public class CampaignStatusEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("campaign_statuses"); + + builder.HasKey(s => s.Id); + + builder.Property(s => s.Id) + .HasColumnName("id") + .ValueGeneratedNever() + .IsRequired(); + + builder.Property(s => s.Name) + .HasColumnName("name") + .HasMaxLength(50) + .IsRequired(); + + // EN: Seed data for enumeration values + // VI: Dữ liệu gốc cho các giá trị enumeration + builder.HasData( + CampaignStatus.Draft, + CampaignStatus.Scheduled, + CampaignStatus.Running, + CampaignStatus.Paused, + CampaignStatus.Completed, + CampaignStatus.Cancelled + ); + } +} diff --git a/services/mkt-x-service-net/src/MktXService.Infrastructure/EntityConfigurations/ContactEntityTypeConfiguration.cs b/services/mkt-x-service-net/src/MktXService.Infrastructure/EntityConfigurations/ContactEntityTypeConfiguration.cs new file mode 100644 index 00000000..df664754 --- /dev/null +++ b/services/mkt-x-service-net/src/MktXService.Infrastructure/EntityConfigurations/ContactEntityTypeConfiguration.cs @@ -0,0 +1,148 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using MktXService.Domain.AggregatesModel.ContactAggregate; + +namespace MktXService.Infrastructure.EntityConfigurations; + +/// +/// EN: EF Core configuration for Contact entity. +/// VI: Cấu hình EF Core cho entity Contact. +/// +public class ContactEntityTypeConfiguration : IEntityTypeConfiguration +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; + + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("contacts"); + + builder.HasKey(c => c.Id); + builder.Ignore(c => c.DomainEvents); + + builder.Property(c => c.Id) + .HasColumnName("id") + .IsRequired(); + + builder.Property("_accountId") + .HasColumnName("account_id") + .IsRequired(); + + builder.Property("_twitterUserId") + .HasColumnName("twitter_user_id") + .HasMaxLength(100) + .IsRequired(); + + builder.Property("_username") + .HasColumnName("username") + .HasMaxLength(100) + .IsRequired(); + + builder.Property("_displayName") + .HasColumnName("display_name") + .HasMaxLength(200); + + builder.Property("_profileImageUrl") + .HasColumnName("profile_image_url") + .HasMaxLength(500); + + builder.Property("_source") + .HasColumnName("source") + .HasMaxLength(50) + .IsRequired(); + + // EN: Custom attributes as JSONB + // VI: Thuộc tính tùy chỉnh dạng JSONB + builder.Property>("_attributes") + .HasColumnName("attributes") + .HasColumnType("jsonb") + .HasConversion( + v => JsonSerializer.Serialize(v, JsonOptions), + v => JsonSerializer.Deserialize>(v, JsonOptions) + ?? new Dictionary(), + new ValueComparer>( + (c1, c2) => c1!.SequenceEqual(c2!), + c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())), + c => new Dictionary(c))); + + builder.Property("_firstInteractionAt") + .HasColumnName("first_interaction_at") + .IsRequired(); + + builder.Property("_lastInteractionAt") + .HasColumnName("last_interaction_at"); + + builder.Property("_createdAt") + .HasColumnName("created_at") + .IsRequired(); + + builder.Property("_updatedAt") + .HasColumnName("updated_at"); + + // EN: One-to-many with ContactTags + // VI: Quan hệ một-nhiều với ContactTags + builder.HasMany(c => c.Tags) + .WithOne() + .HasForeignKey(t => t.ContactId) + .OnDelete(DeleteBehavior.Cascade); + + // EN: Unique constraint on account_id + twitter_user_id + // VI: Ràng buộc unique trên account_id + twitter_user_id + builder.HasIndex("_accountId", "_twitterUserId") + .IsUnique() + .HasDatabaseName("ix_contacts_account_twitter_user"); + + builder.HasIndex("_accountId") + .HasDatabaseName("ix_contacts_account_id"); + + builder.HasIndex("_username") + .HasDatabaseName("ix_contacts_username"); + + builder.HasIndex("_lastInteractionAt") + .HasDatabaseName("ix_contacts_last_interaction"); + } +} + +/// +/// EN: EF Core configuration for ContactTag entity. +/// VI: Cấu hình EF Core cho entity ContactTag. +/// +public class ContactTagEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("contact_tags"); + + builder.HasKey(t => t.Id); + + builder.Property(t => t.Id) + .HasColumnName("id") + .IsRequired(); + + builder.Property(t => t.ContactId) + .HasColumnName("contact_id") + .IsRequired(); + + builder.Property("_name") + .HasColumnName("name") + .HasMaxLength(100) + .IsRequired(); + + builder.Property("_createdAt") + .HasColumnName("created_at") + .IsRequired(); + + // EN: Unique per contact + builder.HasIndex(t => t.ContactId, "_name") + .IsUnique() + .HasDatabaseName("ix_contact_tags_contact_name"); + + builder.HasIndex("_name") + .HasDatabaseName("ix_contact_tags_name"); + } +} diff --git a/services/mkt-x-service-net/src/MktXService.Infrastructure/EntityConfigurations/ConversationEntityTypeConfiguration.cs b/services/mkt-x-service-net/src/MktXService.Infrastructure/EntityConfigurations/ConversationEntityTypeConfiguration.cs new file mode 100644 index 00000000..653de849 --- /dev/null +++ b/services/mkt-x-service-net/src/MktXService.Infrastructure/EntityConfigurations/ConversationEntityTypeConfiguration.cs @@ -0,0 +1,194 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using MktXService.Domain.AggregatesModel.ConversationAggregate; + +namespace MktXService.Infrastructure.EntityConfigurations; + +/// +/// EN: EF Core configuration for Conversation entity. +/// VI: Cấu hình EF Core cho entity Conversation. +/// +public class ConversationEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("conversations"); + + builder.HasKey(c => c.Id); + builder.Ignore(c => c.DomainEvents); + + builder.Property(c => c.Id) + .HasColumnName("id") + .IsRequired(); + + builder.Property("_contactId") + .HasColumnName("contact_id") + .IsRequired(); + + builder.Property("_accountId") + .HasColumnName("account_id") + .IsRequired(); + + builder.Property(c => c.StatusId) + .HasColumnName("status_id") + .IsRequired(); + + builder.Property("_channel") + .HasColumnName("channel") + .HasMaxLength(50) + .IsRequired(); + + builder.Property("_assignedToUserId") + .HasColumnName("assigned_to_user_id"); + + builder.Property("_startedAt") + .HasColumnName("started_at") + .IsRequired(); + + builder.Property("_closedAt") + .HasColumnName("closed_at"); + + builder.Property("_createdAt") + .HasColumnName("created_at") + .IsRequired(); + + builder.Property("_updatedAt") + .HasColumnName("updated_at"); + + // EN: Relationships + builder.HasOne(c => c.Status) + .WithMany() + .HasForeignKey(c => c.StatusId) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasMany(c => c.Messages) + .WithOne() + .HasForeignKey(m => m.ConversationId) + .OnDelete(DeleteBehavior.Cascade); + + // EN: Indexes + builder.HasIndex("_contactId") + .HasDatabaseName("ix_conversations_contact_id"); + + builder.HasIndex("_accountId") + .HasDatabaseName("ix_conversations_account_id"); + + builder.HasIndex(c => c.StatusId) + .HasDatabaseName("ix_conversations_status_id"); + + builder.HasIndex("_assignedToUserId") + .HasDatabaseName("ix_conversations_assigned_user"); + + // EN: Composite for finding open conversations per contact + builder.HasIndex("_contactId", "StatusId") + .HasDatabaseName("ix_conversations_contact_status"); + } +} + +/// +/// EN: EF Core configuration for ConversationStatus enumeration. +/// VI: Cấu hình EF Core cho enumeration ConversationStatus. +/// +public class ConversationStatusEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("conversation_statuses"); + + builder.HasKey(s => s.Id); + + builder.Property(s => s.Id) + .HasColumnName("id") + .ValueGeneratedNever() + .IsRequired(); + + builder.Property(s => s.Name) + .HasColumnName("name") + .HasMaxLength(50) + .IsRequired(); + + builder.HasData( + ConversationStatus.Open, + ConversationStatus.Closed, + ConversationStatus.Pending + ); + } +} + +/// +/// EN: EF Core configuration for Message entity. +/// VI: Cấu hình EF Core cho entity Message. +/// +public class MessageEntityTypeConfiguration : IEntityTypeConfiguration +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; + + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("messages"); + + builder.HasKey(m => m.Id); + + builder.Property(m => m.Id) + .HasColumnName("id") + .IsRequired(); + + builder.Property(m => m.ConversationId) + .HasColumnName("conversation_id") + .IsRequired(); + + builder.Property("_twitterMessageId") + .HasColumnName("twitter_message_id") + .HasMaxLength(100); + + builder.Property("_direction") + .HasColumnName("direction") + .HasMaxLength(20) + .IsRequired(); + + builder.Property("_type") + .HasColumnName("type") + .HasMaxLength(50) + .IsRequired(); + + builder.Property("_content") + .HasColumnName("content") + .IsRequired(); + + // EN: Attachments as JSONB + builder.Property?>("_attachments") + .HasColumnName("attachments") + .HasColumnType("jsonb") + .HasConversion( + v => v == null ? null : JsonSerializer.Serialize(v, JsonOptions), + v => v == null ? null : JsonSerializer.Deserialize>(v, JsonOptions)); + + builder.Property("_isFromBot") + .HasColumnName("is_from_bot") + .IsRequired(); + + builder.Property("_sentAt") + .HasColumnName("sent_at") + .IsRequired(); + + builder.Property("_createdAt") + .HasColumnName("created_at") + .IsRequired(); + + // EN: Indexes + builder.HasIndex(m => m.ConversationId) + .HasDatabaseName("ix_messages_conversation_id"); + + builder.HasIndex("_twitterMessageId") + .HasDatabaseName("ix_messages_twitter_id"); + + builder.HasIndex("_sentAt") + .HasDatabaseName("ix_messages_sent_at"); + } +} diff --git a/services/mkt-x-service-net/src/MktXService.Infrastructure/EntityConfigurations/SegmentEntityTypeConfiguration.cs b/services/mkt-x-service-net/src/MktXService.Infrastructure/EntityConfigurations/SegmentEntityTypeConfiguration.cs new file mode 100644 index 00000000..0be3de5b --- /dev/null +++ b/services/mkt-x-service-net/src/MktXService.Infrastructure/EntityConfigurations/SegmentEntityTypeConfiguration.cs @@ -0,0 +1,96 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using MktXService.Domain.AggregatesModel.SegmentAggregate; + +namespace MktXService.Infrastructure.EntityConfigurations; + +/// +/// EN: EF Core configuration for Segment entity. +/// VI: Cấu hình EF Core cho entity Segment. +/// +public class SegmentEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("segments"); + + builder.HasKey(s => s.Id); + builder.Ignore(s => s.DomainEvents); + + builder.Property(s => s.Id) + .HasColumnName("id") + .IsRequired(); + + builder.Property("_merchantId") + .HasColumnName("merchant_id") + .IsRequired(); + + builder.Property("_name") + .HasColumnName("name") + .HasMaxLength(200) + .IsRequired(); + + builder.Property("_contactCount") + .HasColumnName("contact_count") + .HasDefaultValue(0); + + builder.Property("_createdAt") + .HasColumnName("created_at") + .IsRequired(); + + builder.Property("_updatedAt") + .HasColumnName("updated_at"); + + // EN: One-to-many with SegmentConditions + builder.HasMany(s => s.Conditions) + .WithOne() + .HasForeignKey(c => c.SegmentId) + .OnDelete(DeleteBehavior.Cascade); + + // EN: Indexes + builder.HasIndex("_merchantId") + .HasDatabaseName("ix_segments_merchant_id"); + + builder.HasIndex("_name") + .HasDatabaseName("ix_segments_name"); + } +} + +/// +/// EN: EF Core configuration for SegmentCondition entity. +/// VI: Cấu hình EF Core cho entity SegmentCondition. +/// +public class SegmentConditionEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("segment_conditions"); + + builder.HasKey(c => c.Id); + + builder.Property(c => c.Id) + .HasColumnName("id") + .IsRequired(); + + builder.Property(c => c.SegmentId) + .HasColumnName("segment_id") + .IsRequired(); + + builder.Property("_field") + .HasColumnName("field") + .HasMaxLength(100) + .IsRequired(); + + builder.Property("_operator") + .HasColumnName("operator") + .HasMaxLength(50) + .IsRequired(); + + builder.Property("_value") + .HasColumnName("value") + .HasMaxLength(500); + + builder.HasIndex(c => c.SegmentId) + .HasDatabaseName("ix_segment_conditions_segment_id"); + } +} diff --git a/services/mkt-x-service-net/src/MktXService.Infrastructure/EntityConfigurations/TemplateEntityTypeConfiguration.cs b/services/mkt-x-service-net/src/MktXService.Infrastructure/EntityConfigurations/TemplateEntityTypeConfiguration.cs new file mode 100644 index 00000000..85c5e026 --- /dev/null +++ b/services/mkt-x-service-net/src/MktXService.Infrastructure/EntityConfigurations/TemplateEntityTypeConfiguration.cs @@ -0,0 +1,79 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using MktXService.Domain.AggregatesModel.TemplateAggregate; + +namespace MktXService.Infrastructure.EntityConfigurations; + +/// +/// EN: EF Core configuration for Template entity. +/// VI: Cấu hình EF Core cho entity Template. +/// +public class TemplateEntityTypeConfiguration : IEntityTypeConfiguration