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