feat: Remove sample-related code from Facebook and Zalo services while introducing new AI, chatbot, and conversation management features across Facebook, WhatsApp, and X services.

This commit is contained in:
Ho Ngoc Hai
2026-01-19 01:14:46 +07:00
parent 7d7355cb57
commit 2d731dbdb6
145 changed files with 9966 additions and 2183 deletions

View File

@@ -1,14 +0,0 @@
using MediatR;
namespace FacebookService.API.Application.Commands;
/// <summary>
/// EN: Command to change status of a Sample.
/// VI: Command để thay đổi trạng thái của Sample.
/// </summary>
/// <param name="SampleId">EN: Sample ID / VI: ID sample</param>
/// <param name="NewStatus">EN: New status (activate, complete, cancel) / VI: Trạng thái mới (activate, complete, cancel)</param>
public record ChangeSampleStatusCommand(
Guid SampleId,
string NewStatus
) : IRequest<bool>;

View File

@@ -1,70 +0,0 @@
using MediatR;
using FacebookService.Domain.AggregatesModel.SampleAggregate;
namespace FacebookService.API.Application.Commands;
/// <summary>
/// EN: Handler for ChangeSampleStatusCommand.
/// VI: Handler cho ChangeSampleStatusCommand.
/// </summary>
public class ChangeSampleStatusCommandHandler : IRequestHandler<ChangeSampleStatusCommand, bool>
{
private readonly ISampleRepository _sampleRepository;
private readonly ILogger<ChangeSampleStatusCommandHandler> _logger;
public ChangeSampleStatusCommandHandler(
ISampleRepository sampleRepository,
ILogger<ChangeSampleStatusCommandHandler> logger)
{
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<bool> 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;
}
}

View File

@@ -0,0 +1,25 @@
using MediatR;
using FacebookService.API.Application.Dtos;
namespace FacebookService.API.Application.Commands;
/// <summary>
/// EN: Command to create a new ChatbotFlow.
/// VI: Command để tạo một ChatbotFlow mới.
/// </summary>
/// <param name="ShopId">EN: Shop ID / VI: ID shop</param>
/// <param name="Name">EN: Flow name / VI: Tên flow</param>
/// <param name="TriggerType">EN: Trigger type (GetStarted, Keyword, Postback, Fallback) / VI: Loại trigger</param>
/// <param name="TriggerValue">EN: Trigger value (keyword, payload, etc.) / VI: Giá trị trigger</param>
public record CreateChatbotFlowCommand(
Guid ShopId,
string Name,
string TriggerType,
string TriggerValue
) : IRequest<CreateChatbotFlowCommandResult>;
/// <summary>
/// EN: Result of CreateChatbotFlowCommand.
/// VI: Kết quả của CreateChatbotFlowCommand.
/// </summary>
public record CreateChatbotFlowCommandResult(Guid Id, bool Success);

View File

@@ -0,0 +1,77 @@
using MediatR;
using FacebookService.Domain.AggregatesModel.ChatbotAggregate;
namespace FacebookService.API.Application.Commands;
/// <summary>
/// EN: Handler for CreateChatbotFlowCommand.
/// VI: Handler cho CreateChatbotFlowCommand.
/// </summary>
public class CreateChatbotFlowCommandHandler : IRequestHandler<CreateChatbotFlowCommand, CreateChatbotFlowCommandResult>
{
private readonly IChatbotFlowRepository _flowRepository;
private readonly ILogger<CreateChatbotFlowCommandHandler> _logger;
public CreateChatbotFlowCommandHandler(
IChatbotFlowRepository flowRepository,
ILogger<CreateChatbotFlowCommandHandler> logger)
{
_flowRepository = flowRepository ?? throw new ArgumentNullException(nameof(flowRepository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<CreateChatbotFlowCommandResult> 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
};
}
}

View File

@@ -0,0 +1,33 @@
using MediatR;
using FacebookService.API.Application.Dtos;
namespace FacebookService.API.Application.Commands;
/// <summary>
/// EN: Command to create a new Customer from Facebook user ID.
/// VI: Command để tạo một Customer mới từ Facebook user ID.
/// </summary>
/// <param name="FacebookUserId">EN: Facebook user ID / VI: ID người dùng Facebook</param>
/// <param name="Name">EN: Optional display name / VI: Tên hiển thị tùy chọn</param>
/// <param name="Email">EN: Optional email / VI: Email tùy chọn</param>
/// <param name="Phone">EN: Optional phone / VI: Điện thoại tùy chọn</param>
/// <param name="ProfilePicUrl">EN: Optional profile picture URL / VI: URL ảnh đại diện tùy chọn</param>
/// <param name="Locale">EN: Optional locale / VI: Locale tùy chọn</param>
/// <param name="Timezone">EN: Optional timezone / VI: Timezone tùy chọn</param>
public record CreateCustomerCommand(
string FacebookUserId,
string? Name = null,
string? Email = null,
string? Phone = null,
string? ProfilePicUrl = null,
string? Locale = null,
string? Timezone = null
) : IRequest<CreateCustomerCommandResult>;
/// <summary>
/// EN: Result of CreateCustomerCommand.
/// VI: Kết quả của CreateCustomerCommand.
/// </summary>
/// <param name="Id">EN: Created customer ID / VI: ID customer đã tạo</param>
/// <param name="IsNew">EN: True if customer was created, false if already existed / VI: True nếu customer được tạo mới</param>
public record CreateCustomerCommandResult(Guid Id, bool IsNew);

View File

@@ -0,0 +1,79 @@
using MediatR;
using FacebookService.Domain.AggregatesModel.CustomerAggregate;
namespace FacebookService.API.Application.Commands;
/// <summary>
/// EN: Handler for CreateCustomerCommand.
/// VI: Handler cho CreateCustomerCommand.
/// </summary>
public class CreateCustomerCommandHandler : IRequestHandler<CreateCustomerCommand, CreateCustomerCommandResult>
{
private readonly ICustomerRepository _customerRepository;
private readonly ILogger<CreateCustomerCommandHandler> _logger;
public CreateCustomerCommandHandler(
ICustomerRepository customerRepository,
ILogger<CreateCustomerCommandHandler> logger)
{
_customerRepository = customerRepository ?? throw new ArgumentNullException(nameof(customerRepository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<CreateCustomerCommandResult> 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);
}
}

View File

@@ -1,21 +0,0 @@
using MediatR;
namespace FacebookService.API.Application.Commands;
/// <summary>
/// EN: Command to create a new Sample.
/// VI: Command để tạo một Sample mới.
/// </summary>
/// <param name="Name">EN: Sample name / VI: Tên sample</param>
/// <param name="Description">EN: Optional description / VI: Mô tả tùy chọn</param>
public record CreateSampleCommand(
string Name,
string? Description
) : IRequest<CreateSampleCommandResult>;
/// <summary>
/// EN: Result of CreateSampleCommand.
/// VI: Kết quả của CreateSampleCommand.
/// </summary>
/// <param name="Id">EN: Created sample ID / VI: ID sample đã tạo</param>
public record CreateSampleCommandResult(Guid Id);

View File

@@ -1,46 +0,0 @@
using MediatR;
using FacebookService.Domain.AggregatesModel.SampleAggregate;
namespace FacebookService.API.Application.Commands;
/// <summary>
/// EN: Handler for CreateSampleCommand.
/// VI: Handler cho CreateSampleCommand.
/// </summary>
public class CreateSampleCommandHandler : IRequestHandler<CreateSampleCommand, CreateSampleCommandResult>
{
private readonly ISampleRepository _sampleRepository;
private readonly ILogger<CreateSampleCommandHandler> _logger;
public CreateSampleCommandHandler(
ISampleRepository sampleRepository,
ILogger<CreateSampleCommandHandler> logger)
{
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<CreateSampleCommandResult> 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);
}
}

View File

@@ -1,10 +0,0 @@
using MediatR;
namespace FacebookService.API.Application.Commands;
/// <summary>
/// EN: Command to delete a Sample.
/// VI: Command để xóa một Sample.
/// </summary>
/// <param name="SampleId">EN: Sample ID to delete / VI: ID sample cần xóa</param>
public record DeleteSampleCommand(Guid SampleId) : IRequest<bool>;

View File

@@ -1,54 +0,0 @@
using MediatR;
using FacebookService.Domain.AggregatesModel.SampleAggregate;
namespace FacebookService.API.Application.Commands;
/// <summary>
/// EN: Handler for DeleteSampleCommand.
/// VI: Handler cho DeleteSampleCommand.
/// </summary>
public class DeleteSampleCommandHandler : IRequestHandler<DeleteSampleCommand, bool>
{
private readonly ISampleRepository _sampleRepository;
private readonly ILogger<DeleteSampleCommandHandler> _logger;
public DeleteSampleCommandHandler(
ISampleRepository sampleRepository,
ILogger<DeleteSampleCommandHandler> logger)
{
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<bool> 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;
}
}

View File

@@ -0,0 +1,35 @@
using MediatR;
using FacebookService.API.Application.Dtos;
namespace FacebookService.API.Application.Commands;
/// <summary>
/// EN: Command to process an incoming message from Facebook Messenger.
/// VI: Command để xử lý tin nhắn đến từ Facebook Messenger.
/// </summary>
/// <param name="PageId">EN: Facebook Page ID / VI: ID trang Facebook</param>
/// <param name="SenderId">EN: Facebook sender (user) ID / VI: ID người gửi Facebook</param>
/// <param name="MessageText">EN: Message content / VI: Nội dung tin nhắn</param>
/// <param name="FacebookMessageId">EN: Facebook message ID (mid) / VI: ID tin nhắn Facebook</param>
/// <param name="Timestamp">EN: Message timestamp / VI: Thời gian tin nhắn</param>
/// <param name="QuickReplyPayload">EN: Quick reply payload if any / VI: Payload quick reply nếu có</param>
public record ProcessIncomingMessageCommand(
string PageId,
string SenderId,
string MessageText,
string? FacebookMessageId = null,
long? Timestamp = null,
string? QuickReplyPayload = null
) : IRequest<ProcessIncomingMessageResult>;
/// <summary>
/// EN: Result of ProcessIncomingMessageCommand.
/// VI: Kết quả của ProcessIncomingMessageCommand.
/// </summary>
public record ProcessIncomingMessageResult(
Guid ConversationId,
Guid CustomerId,
Guid MessageId,
bool IsNewConversation,
bool IsNewCustomer
);

View File

@@ -0,0 +1,98 @@
using MediatR;
using FacebookService.Domain.AggregatesModel.CustomerAggregate;
using FacebookService.Domain.AggregatesModel.ConversationAggregate;
namespace FacebookService.API.Application.Commands;
/// <summary>
/// EN: Handler for ProcessIncomingMessageCommand.
/// VI: Handler cho ProcessIncomingMessageCommand.
/// </summary>
public class ProcessIncomingMessageHandler : IRequestHandler<ProcessIncomingMessageCommand, ProcessIncomingMessageResult>
{
private readonly ICustomerRepository _customerRepository;
private readonly IConversationRepository _conversationRepository;
private readonly ILogger<ProcessIncomingMessageHandler> _logger;
public ProcessIncomingMessageHandler(
ICustomerRepository customerRepository,
IConversationRepository conversationRepository,
ILogger<ProcessIncomingMessageHandler> 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<ProcessIncomingMessageResult> 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<string, object> { ["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
);
}
}

View File

@@ -0,0 +1,25 @@
using MediatR;
using FacebookService.API.Application.Dtos;
namespace FacebookService.API.Application.Commands;
/// <summary>
/// EN: Command to update customer tags and custom fields.
/// VI: Command để cập nhật tags và custom fields của customer.
/// </summary>
/// <param name="CustomerId">EN: Customer ID / VI: ID customer</param>
/// <param name="Tags">EN: New tags (replaces existing) / VI: Tags mới (thay thế hiện có)</param>
/// <param name="CustomFields">EN: Custom fields to set / VI: Custom fields để đặt</param>
public record UpdateCustomerCommand(
Guid CustomerId,
IEnumerable<string>? Tags = null,
Dictionary<string, string>? CustomFields = null
) : IRequest<UpdateCustomerCommandResult>;
/// <summary>
/// EN: Result of UpdateCustomerCommand.
/// VI: Kết quả của UpdateCustomerCommand.
/// </summary>
/// <param name="Success">EN: Whether update was successful / VI: Cập nhật có thành công không</param>
/// <param name="CustomerId">EN: Updated customer ID / VI: ID customer đã cập nhật</param>
public record UpdateCustomerCommandResult(bool Success, Guid CustomerId);

View File

@@ -0,0 +1,72 @@
using MediatR;
using FacebookService.Domain.AggregatesModel.CustomerAggregate;
namespace FacebookService.API.Application.Commands;
/// <summary>
/// EN: Handler for UpdateCustomerCommand.
/// VI: Handler cho UpdateCustomerCommand.
/// </summary>
public class UpdateCustomerCommandHandler : IRequestHandler<UpdateCustomerCommand, UpdateCustomerCommandResult>
{
private readonly ICustomerRepository _customerRepository;
private readonly ILogger<UpdateCustomerCommandHandler> _logger;
public UpdateCustomerCommandHandler(
ICustomerRepository customerRepository,
ILogger<UpdateCustomerCommandHandler> logger)
{
_customerRepository = customerRepository ?? throw new ArgumentNullException(nameof(customerRepository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<UpdateCustomerCommandResult> 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);
}
}

View File

@@ -1,16 +0,0 @@
using MediatR;
namespace FacebookService.API.Application.Commands;
/// <summary>
/// EN: Command to update an existing Sample.
/// VI: Command để cập nhật một Sample đã tồn tại.
/// </summary>
/// <param name="SampleId">EN: Sample ID to update / VI: ID sample cần cập nhật</param>
/// <param name="Name">EN: New name / VI: Tên mới</param>
/// <param name="Description">EN: New description / VI: Mô tả mới</param>
public record UpdateSampleCommand(
Guid SampleId,
string Name,
string? Description
) : IRequest<bool>;

View File

@@ -1,54 +0,0 @@
using MediatR;
using FacebookService.Domain.AggregatesModel.SampleAggregate;
namespace FacebookService.API.Application.Commands;
/// <summary>
/// EN: Handler for UpdateSampleCommand.
/// VI: Handler cho UpdateSampleCommand.
/// </summary>
public class UpdateSampleCommandHandler : IRequestHandler<UpdateSampleCommand, bool>
{
private readonly ISampleRepository _sampleRepository;
private readonly ILogger<UpdateSampleCommandHandler> _logger;
public UpdateSampleCommandHandler(
ISampleRepository sampleRepository,
ILogger<UpdateSampleCommandHandler> logger)
{
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<bool> 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;
}
}

View File

@@ -0,0 +1,29 @@
using MediatR;
using FacebookService.API.Application.Dtos;
namespace FacebookService.API.Application.Commands;
/// <summary>
/// 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.
/// </summary>
/// <param name="ShopId">EN: Shop ID / VI: ID shop</param>
/// <param name="Provider">EN: AI provider (OpenAI, AzureOpenAI) / VI: Nhà cung cấp AI</param>
/// <param name="Model">EN: Model name (e.g., gpt-4-turbo) / VI: Tên model</param>
/// <param name="SystemPrompt">EN: System prompt for AI context / VI: System prompt cho ngữ cảnh AI</param>
/// <param name="Temperature">EN: Temperature (0.0 - 2.0) / VI: Temperature</param>
/// <param name="MaxTokens">EN: Max tokens for response / VI: Max tokens cho response</param>
public record UpsertAIChatbotConfigCommand(
Guid ShopId,
string Provider,
string Model,
string SystemPrompt,
float Temperature = 0.7f,
int MaxTokens = 500
) : IRequest<UpsertAIChatbotConfigResult>;
/// <summary>
/// EN: Result of UpsertAIChatbotConfigCommand.
/// VI: Kết quả của UpsertAIChatbotConfigCommand.
/// </summary>
public record UpsertAIChatbotConfigResult(Guid Id, bool IsNew, bool Success);

View File

@@ -0,0 +1,101 @@
using MediatR;
using FacebookService.Domain.AggregatesModel.ChatbotAggregate;
namespace FacebookService.API.Application.Commands;
/// <summary>
/// EN: Handler for UpsertAIChatbotConfigCommand.
/// VI: Handler cho UpsertAIChatbotConfigCommand.
/// </summary>
public class UpsertAIChatbotConfigCommandHandler : IRequestHandler<UpsertAIChatbotConfigCommand, UpsertAIChatbotConfigResult>
{
private readonly IAIChatbotConfigRepository _configRepository;
private readonly ILogger<UpsertAIChatbotConfigCommandHandler> _logger;
public UpsertAIChatbotConfigCommandHandler(
IAIChatbotConfigRepository configRepository,
ILogger<UpsertAIChatbotConfigCommandHandler> logger)
{
_configRepository = configRepository ?? throw new ArgumentNullException(nameof(configRepository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<UpsertAIChatbotConfigResult> 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
};
}
}

View File

@@ -0,0 +1,60 @@
namespace FacebookService.API.Application.Dtos;
/// <summary>
/// EN: ChatbotFlow DTO for API responses.
/// VI: ChatbotFlow DTO cho API responses.
/// </summary>
public record ChatbotFlowDto(
Guid Id,
Guid ShopId,
string Name,
string TriggerType,
string TriggerValue,
bool IsActive,
DateTime CreatedAt,
DateTime? UpdatedAt,
IReadOnlyList<FlowNodeDto>? Nodes = null
);
/// <summary>
/// EN: ChatbotFlow summary DTO for list responses.
/// VI: ChatbotFlow summary DTO cho list responses.
/// </summary>
public record ChatbotFlowSummaryDto(
Guid Id,
string Name,
string TriggerType,
string TriggerValue,
bool IsActive,
DateTime CreatedAt
);
/// <summary>
/// EN: FlowNode DTO for API responses.
/// VI: FlowNode DTO cho API responses.
/// </summary>
public record FlowNodeDto(
Guid Id,
string NodeType,
string? Content,
int OrderIndex,
IReadOnlyCollection<Guid> NextNodeIds,
IReadOnlyDictionary<string, object>? Config = null
);
/// <summary>
/// EN: AIChatbotConfig DTO for API responses.
/// VI: AIChatbotConfig DTO cho API responses.
/// </summary>
public record AIChatbotConfigDto(
Guid Id,
Guid ShopId,
string Provider,
string Model,
string SystemPrompt,
float Temperature,
int MaxTokens,
bool IsEnabled,
DateTime CreatedAt,
DateTime UpdatedAt
);

View File

@@ -0,0 +1,48 @@
namespace FacebookService.API.Application.Dtos;
/// <summary>
/// EN: Conversation DTO for API responses.
/// VI: Conversation DTO cho API responses.
/// </summary>
public record ConversationDto(
Guid Id,
Guid CustomerId,
CustomerSummaryDto? Customer,
string PageId,
string Status,
string Channel,
string? AssignedAgentId,
DateTime? LastMessageAt,
DateTime CreatedAt,
IReadOnlyList<MessageDto>? Messages = null
);
/// <summary>
/// EN: Conversation summary DTO for list responses.
/// VI: Conversation summary DTO cho list responses.
/// </summary>
public record ConversationSummaryDto(
Guid Id,
Guid CustomerId,
string? CustomerName,
string PageId,
string Status,
string? LastMessageText,
DateTime? LastMessageAt,
DateTime CreatedAt
);
/// <summary>
/// EN: Message DTO for API responses.
/// VI: Message DTO cho API responses.
/// </summary>
public record MessageDto(
Guid Id,
string SenderId,
string Content,
string MessageType,
string Direction,
DateTime SentAt,
string? FacebookMessageId = null,
IReadOnlyDictionary<string, object>? Metadata = null
);

View File

@@ -0,0 +1,33 @@
namespace FacebookService.API.Application.Dtos;
/// <summary>
/// EN: Customer DTO for API responses.
/// VI: Customer DTO cho API responses.
/// </summary>
public record CustomerDto(
Guid Id,
string FacebookUserId,
string? Name,
string? Email,
string? Phone,
string? ProfilePicUrl,
string? Locale,
string? Timezone,
IReadOnlyCollection<string> Tags,
IReadOnlyDictionary<string, string> CustomFields,
DateTime FirstSeenAt,
DateTime LastInteractionAt
);
/// <summary>
/// EN: Customer summary DTO for list responses.
/// VI: Customer summary DTO cho list responses.
/// </summary>
public record CustomerSummaryDto(
Guid Id,
string FacebookUserId,
string? Name,
string? ProfilePicUrl,
IReadOnlyCollection<string> Tags,
DateTime LastInteractionAt
);

View File

@@ -0,0 +1,11 @@
using MediatR;
using FacebookService.API.Application.Dtos;
namespace FacebookService.API.Application.Queries;
/// <summary>
/// EN: Query to get AI Chatbot config for a shop.
/// VI: Query để lấy cấu hình AI Chatbot cho shop.
/// </summary>
/// <param name="ShopId">EN: Shop ID / VI: ID shop</param>
public record GetAIChatbotConfigQuery(Guid ShopId) : IRequest<AIChatbotConfigDto?>;

View File

@@ -0,0 +1,44 @@
using MediatR;
using FacebookService.API.Application.Dtos;
using FacebookService.Domain.AggregatesModel.ChatbotAggregate;
namespace FacebookService.API.Application.Queries;
/// <summary>
/// EN: Handler for GetAIChatbotConfigQuery.
/// VI: Handler cho GetAIChatbotConfigQuery.
/// </summary>
public class GetAIChatbotConfigQueryHandler : IRequestHandler<GetAIChatbotConfigQuery, AIChatbotConfigDto?>
{
private readonly IAIChatbotConfigRepository _configRepository;
public GetAIChatbotConfigQueryHandler(IAIChatbotConfigRepository configRepository)
{
_configRepository = configRepository ?? throw new ArgumentNullException(nameof(configRepository));
}
public async Task<AIChatbotConfigDto?> 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
);
}
}

View File

@@ -0,0 +1,22 @@
using MediatR;
using FacebookService.API.Application.Dtos;
namespace FacebookService.API.Application.Queries;
/// <summary>
/// EN: Query to get a ChatbotFlow by ID with nodes.
/// VI: Query để lấy một ChatbotFlow theo ID với nodes.
/// </summary>
/// <param name="FlowId">EN: Flow ID / VI: ID flow</param>
public record GetChatbotFlowByIdQuery(Guid FlowId) : IRequest<ChatbotFlowDto?>;
/// <summary>
/// EN: Query to get chatbot flows for a shop.
/// VI: Query để lấy danh sách chatbot flows của shop.
/// </summary>
/// <param name="ShopId">EN: Shop ID / VI: ID shop</param>
/// <param name="ActiveOnly">EN: Only return active flows / VI: Chỉ lấy flows đang hoạt động</param>
public record GetChatbotFlowsQuery(
Guid ShopId,
bool ActiveOnly = false
) : IRequest<IReadOnlyList<ChatbotFlowSummaryDto>>;

View File

@@ -0,0 +1,97 @@
using MediatR;
using FacebookService.API.Application.Dtos;
using FacebookService.Domain.AggregatesModel.ChatbotAggregate;
namespace FacebookService.API.Application.Queries;
/// <summary>
/// EN: Handler for GetChatbotFlowByIdQuery.
/// VI: Handler cho GetChatbotFlowByIdQuery.
/// </summary>
public class GetChatbotFlowByIdQueryHandler : IRequestHandler<GetChatbotFlowByIdQuery, ChatbotFlowDto?>
{
private readonly IChatbotFlowRepository _flowRepository;
public GetChatbotFlowByIdQueryHandler(IChatbotFlowRepository flowRepository)
{
_flowRepository = flowRepository ?? throw new ArgumentNullException(nameof(flowRepository));
}
public async Task<ChatbotFlowDto?> 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
);
}
}
/// <summary>
/// EN: Handler for GetChatbotFlowsQuery.
/// VI: Handler cho GetChatbotFlowsQuery.
/// </summary>
public class GetChatbotFlowsQueryHandler : IRequestHandler<GetChatbotFlowsQuery, IReadOnlyList<ChatbotFlowSummaryDto>>
{
private readonly IChatbotFlowRepository _flowRepository;
public GetChatbotFlowsQueryHandler(IChatbotFlowRepository flowRepository)
{
_flowRepository = flowRepository ?? throw new ArgumentNullException(nameof(flowRepository));
}
public async Task<IReadOnlyList<ChatbotFlowSummaryDto>> 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();
}
}

View File

@@ -0,0 +1,39 @@
using MediatR;
using FacebookService.API.Application.Dtos;
namespace FacebookService.API.Application.Queries;
/// <summary>
/// EN: Query to get a Conversation by ID with messages.
/// VI: Query để lấy một Conversation theo ID với tin nhắn.
/// </summary>
/// <param name="ConversationId">EN: Conversation ID / VI: ID conversation</param>
/// <param name="IncludeMessages">EN: Whether to include messages / VI: Có bao gồm tin nhắn không</param>
public record GetConversationByIdQuery(
Guid ConversationId,
bool IncludeMessages = true
) : IRequest<ConversationDto?>;
/// <summary>
/// EN: Query to get conversations with pagination.
/// VI: Query để lấy danh sách conversations với phân trang.
/// </summary>
/// <param name="ShopId">EN: Shop ID / VI: ID shop</param>
/// <param name="Status">EN: Optional status filter / VI: Lọc theo status tùy chọn</param>
/// <param name="Skip">EN: Number to skip / VI: Số lượng bỏ qua</param>
/// <param name="Take">EN: Number to take / VI: Số lượng lấy</param>
public record GetConversationsQuery(
Guid? ShopId = null,
string? Status = null,
int Skip = 0,
int Take = 20
) : IRequest<GetConversationsQueryResult>;
/// <summary>
/// EN: Result of GetConversationsQuery.
/// VI: Kết quả của GetConversationsQuery.
/// </summary>
public record GetConversationsQueryResult(
IReadOnlyList<ConversationSummaryDto> Conversations,
int TotalCount
);

View File

@@ -0,0 +1,78 @@
using MediatR;
using FacebookService.API.Application.Dtos;
using FacebookService.Domain.AggregatesModel.ConversationAggregate;
namespace FacebookService.API.Application.Queries;
/// <summary>
/// EN: Handler for GetConversationByIdQuery.
/// VI: Handler cho GetConversationByIdQuery.
/// </summary>
public class GetConversationByIdQueryHandler : IRequestHandler<GetConversationByIdQuery, ConversationDto?>
{
private readonly IConversationRepository _conversationRepository;
public GetConversationByIdQueryHandler(IConversationRepository conversationRepository)
{
_conversationRepository = conversationRepository ?? throw new ArgumentNullException(nameof(conversationRepository));
}
public async Task<ConversationDto?> 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
);
}
}

View File

@@ -0,0 +1,44 @@
using MediatR;
using FacebookService.API.Application.Dtos;
namespace FacebookService.API.Application.Queries;
/// <summary>
/// EN: Query to get a Customer by ID.
/// VI: Query để lấy một Customer theo ID.
/// </summary>
/// <param name="CustomerId">EN: Customer ID / VI: ID customer</param>
public record GetCustomerByIdQuery(Guid CustomerId) : IRequest<CustomerDto?>;
/// <summary>
/// EN: Query to get a Customer by Facebook User ID.
/// VI: Query để lấy một Customer theo Facebook User ID.
/// </summary>
/// <param name="FacebookUserId">EN: Facebook User ID / VI: ID người dùng Facebook</param>
public record GetCustomerByFacebookIdQuery(string FacebookUserId) : IRequest<CustomerDto?>;
/// <summary>
/// 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.
/// </summary>
/// <param name="ShopId">EN: Shop ID / VI: ID shop</param>
/// <param name="Tags">EN: Optional tag filter / VI: Lọc theo tags tùy chọn</param>
/// <param name="Search">EN: Optional search term / VI: Từ khóa tìm kiếm tùy chọn</param>
/// <param name="Skip">EN: Number to skip / VI: Số lượng bỏ qua</param>
/// <param name="Take">EN: Number to take / VI: Số lượng lấy</param>
public record GetCustomersQuery(
Guid? ShopId = null,
IEnumerable<string>? Tags = null,
string? Search = null,
int Skip = 0,
int Take = 20
) : IRequest<GetCustomersQueryResult>;
/// <summary>
/// EN: Result of GetCustomersQuery with pagination info.
/// VI: Kết quả của GetCustomersQuery với thông tin phân trang.
/// </summary>
public record GetCustomersQueryResult(
IReadOnlyList<CustomerSummaryDto> Customers,
int TotalCount
);

View File

@@ -0,0 +1,92 @@
using MediatR;
using FacebookService.API.Application.Dtos;
using FacebookService.Domain.AggregatesModel.CustomerAggregate;
namespace FacebookService.API.Application.Queries;
/// <summary>
/// EN: Handler for GetCustomerByIdQuery.
/// VI: Handler cho GetCustomerByIdQuery.
/// </summary>
public class GetCustomerByIdQueryHandler : IRequestHandler<GetCustomerByIdQuery, CustomerDto?>
{
private readonly ICustomerRepository _customerRepository;
public GetCustomerByIdQueryHandler(ICustomerRepository customerRepository)
{
_customerRepository = customerRepository ?? throw new ArgumentNullException(nameof(customerRepository));
}
public async Task<CustomerDto?> 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
);
}
}
/// <summary>
/// EN: Handler for GetCustomerByFacebookIdQuery.
/// VI: Handler cho GetCustomerByFacebookIdQuery.
/// </summary>
public class GetCustomerByFacebookIdQueryHandler : IRequestHandler<GetCustomerByFacebookIdQuery, CustomerDto?>
{
private readonly ICustomerRepository _customerRepository;
public GetCustomerByFacebookIdQueryHandler(ICustomerRepository customerRepository)
{
_customerRepository = customerRepository ?? throw new ArgumentNullException(nameof(customerRepository));
}
public async Task<CustomerDto?> 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
);
}
}

View File

@@ -1,23 +0,0 @@
using MediatR;
namespace FacebookService.API.Application.Queries;
/// <summary>
/// EN: Query to get a Sample by ID.
/// VI: Query để lấy một Sample theo ID.
/// </summary>
/// <param name="SampleId">EN: Sample ID / VI: ID sample</param>
public record GetSampleQuery(Guid SampleId) : IRequest<SampleViewModel?>;
/// <summary>
/// EN: Sample view model for API responses.
/// VI: Sample view model cho API responses.
/// </summary>
public record SampleViewModel(
Guid Id,
string Name,
string? Description,
string Status,
DateTime CreatedAt,
DateTime? UpdatedAt
);

View File

@@ -1,39 +0,0 @@
using MediatR;
using FacebookService.Domain.AggregatesModel.SampleAggregate;
namespace FacebookService.API.Application.Queries;
/// <summary>
/// EN: Handler for GetSampleQuery.
/// VI: Handler cho GetSampleQuery.
/// </summary>
public class GetSampleQueryHandler : IRequestHandler<GetSampleQuery, SampleViewModel?>
{
private readonly ISampleRepository _sampleRepository;
public GetSampleQueryHandler(ISampleRepository sampleRepository)
{
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
}
public async Task<SampleViewModel?> 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
);
}
}

View File

@@ -1,9 +0,0 @@
using MediatR;
namespace FacebookService.API.Application.Queries;
/// <summary>
/// EN: Query to get all Samples.
/// VI: Query để lấy tất cả Samples.
/// </summary>
public record GetSamplesQuery : IRequest<IEnumerable<SampleViewModel>>;

View File

@@ -1,34 +0,0 @@
using MediatR;
using FacebookService.Domain.AggregatesModel.SampleAggregate;
namespace FacebookService.API.Application.Queries;
/// <summary>
/// EN: Handler for GetSamplesQuery.
/// VI: Handler cho GetSamplesQuery.
/// </summary>
public class GetSamplesQueryHandler : IRequestHandler<GetSamplesQuery, IEnumerable<SampleViewModel>>
{
private readonly ISampleRepository _sampleRepository;
public GetSamplesQueryHandler(ISampleRepository sampleRepository)
{
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
}
public async Task<IEnumerable<SampleViewModel>> 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
));
}
}

View File

@@ -0,0 +1,104 @@
using FluentValidation;
using FacebookService.API.Application.Commands;
namespace FacebookService.API.Application.Validations;
/// <summary>
/// EN: Validator for ProcessIncomingMessageCommand.
/// VI: Validator cho ProcessIncomingMessageCommand.
/// </summary>
public class ProcessIncomingMessageCommandValidator : AbstractValidator<ProcessIncomingMessageCommand>
{
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");
}
}
/// <summary>
/// EN: Validator for CreateChatbotFlowCommand.
/// VI: Validator cho CreateChatbotFlowCommand.
/// </summary>
public class CreateChatbotFlowCommandValidator : AbstractValidator<CreateChatbotFlowCommand>
{
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ự");
}
}
/// <summary>
/// EN: Validator for UpsertAIChatbotConfigCommand.
/// VI: Validator cho UpsertAIChatbotConfigCommand.
/// </summary>
public class UpsertAIChatbotConfigCommandValidator : AbstractValidator<UpsertAIChatbotConfigCommand>
{
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");
}
}

View File

@@ -0,0 +1,37 @@
using FluentValidation;
using FacebookService.API.Application.Commands;
namespace FacebookService.API.Application.Validations;
/// <summary>
/// EN: Validator for CreateCustomerCommand.
/// VI: Validator cho CreateCustomerCommand.
/// </summary>
public class CreateCustomerCommandValidator : AbstractValidator<CreateCustomerCommand>
{
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);
}
}

View File

@@ -1,25 +0,0 @@
using FluentValidation;
using FacebookService.API.Application.Commands;
namespace FacebookService.API.Application.Validations;
/// <summary>
/// EN: Validator for CreateSampleCommand.
/// VI: Validator cho CreateSampleCommand.
/// </summary>
public class CreateSampleCommandValidator : AbstractValidator<CreateSampleCommand>
{
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);
}
}

View File

@@ -1,29 +0,0 @@
using FluentValidation;
using FacebookService.API.Application.Commands;
namespace FacebookService.API.Application.Validations;
/// <summary>
/// EN: Validator for UpdateSampleCommand.
/// VI: Validator cho UpdateSampleCommand.
/// </summary>
public class UpdateSampleCommandValidator : AbstractValidator<UpdateSampleCommand>
{
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);
}
}

View File

@@ -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;
/// <summary>
/// EN: Controller for Chatbot Flow and AI Config management.
/// VI: Controller quản lý Chatbot Flow và AI Config.
/// </summary>
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/chatbots")]
[Produces("application/json")]
public class ChatbotsController : ControllerBase
{
private readonly IMediator _mediator;
private readonly ILogger<ChatbotsController> _logger;
public ChatbotsController(IMediator mediator, ILogger<ChatbotsController> logger)
{
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
#region Chatbot Flows
/// <summary>
/// EN: Get chatbot flows for a shop.
/// VI: Lấy danh sách chatbot flows của shop.
/// </summary>
[HttpGet("flows")]
[ProducesResponseType(typeof(IEnumerable<ChatbotFlowSummaryDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetFlows(
[FromQuery] Guid shopId,
[FromQuery] bool activeOnly = false)
{
var flows = await _mediator.Send(new GetChatbotFlowsQuery(shopId, activeOnly));
return Ok(new { success = true, data = flows });
}
/// <summary>
/// EN: Get a chatbot flow by ID.
/// VI: Lấy chatbot flow theo ID.
/// </summary>
[HttpGet("flows/{id:guid}")]
[ProducesResponseType(typeof(ChatbotFlowDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> 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 });
}
/// <summary>
/// EN: Create a new chatbot flow.
/// VI: Tạo chatbot flow mới.
/// </summary>
[HttpPost("flows")]
[ProducesResponseType(typeof(CreateChatbotFlowCommandResult), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> 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
/// <summary>
/// EN: Get AI chatbot config for a shop.
/// VI: Lấy cấu hình AI chatbot của shop.
/// </summary>
[HttpGet("ai-config")]
[ProducesResponseType(typeof(AIChatbotConfigDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> 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 });
}
/// <summary>
/// EN: Create or update AI chatbot config.
/// VI: Tạo hoặc cập nhật cấu hình AI chatbot.
/// </summary>
[HttpPut("ai-config")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> 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

View File

@@ -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;
/// <summary>
/// EN: Controller for Conversation management.
/// VI: Controller quản lý Conversation.
/// </summary>
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/conversations")]
[Produces("application/json")]
public class ConversationsController : ControllerBase
{
private readonly IMediator _mediator;
private readonly ILogger<ConversationsController> _logger;
public ConversationsController(IMediator mediator, ILogger<ConversationsController> logger)
{
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// EN: Get a conversation by ID.
/// VI: Lấy conversation theo ID.
/// </summary>
[HttpGet("{id:guid}")]
[ProducesResponseType(typeof(ConversationDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> 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 });
}
/// <summary>
/// EN: Get conversations for a shop with pagination.
/// VI: Lấy danh sách conversations của shop với phân trang.
/// </summary>
[HttpGet]
[ProducesResponseType(typeof(GetConversationsQueryResult), StatusCodes.Status200OK)]
public async Task<IActionResult> 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 });
}
}

View File

@@ -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;
/// <summary>
/// EN: Controller for Customer management.
/// VI: Controller quản lý Customer.
/// </summary>
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/customers")]
[Produces("application/json")]
public class CustomersController : ControllerBase
{
private readonly IMediator _mediator;
private readonly ILogger<CustomersController> _logger;
public CustomersController(IMediator mediator, ILogger<CustomersController> logger)
{
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// EN: Get a customer by ID.
/// VI: Lấy customer theo ID.
/// </summary>
[HttpGet("{id:guid}")]
[ProducesResponseType(typeof(CustomerDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> 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 });
}
/// <summary>
/// EN: Get a customer by Facebook User ID.
/// VI: Lấy customer theo Facebook User ID.
/// </summary>
[HttpGet("facebook/{facebookUserId}")]
[ProducesResponseType(typeof(CustomerDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> 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 });
}
/// <summary>
/// EN: Create or update a customer.
/// VI: Tạo hoặc cập nhật customer.
/// </summary>
[HttpPost]
[ProducesResponseType(typeof(CreateCustomerCommandResult), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> 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 });
}
/// <summary>
/// EN: Update customer tags and custom fields.
/// VI: Cập nhật tags và custom fields của customer.
/// </summary>
[HttpPatch("{id:guid}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> 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<string>? Tags = null,
Dictionary<string, string>? CustomFields = null
);
#endregion

View File

@@ -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;
/// <summary>
/// EN: Controller for Sample CRUD operations using CQRS pattern.
/// VI: Controller cho các thao tác CRUD Sample sử dụng pattern CQRS.
/// </summary>
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/[controller]")]
[Produces("application/json")]
public class SamplesController : ControllerBase
{
private readonly IMediator _mediator;
private readonly ILogger<SamplesController> _logger;
public SamplesController(IMediator mediator, ILogger<SamplesController> logger)
{
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// EN: Get all samples.
/// VI: Lấy tất cả samples.
/// </summary>
/// <returns>EN: List of samples / VI: Danh sách samples</returns>
[HttpGet]
[ProducesResponseType(typeof(IEnumerable<SampleViewModel>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetSamples()
{
var samples = await _mediator.Send(new GetSamplesQuery());
return Ok(new { success = true, data = samples });
}
/// <summary>
/// EN: Get a sample by ID.
/// VI: Lấy một sample theo ID.
/// </summary>
/// <param name="id">EN: Sample ID / VI: ID sample</param>
/// <returns>EN: Sample details / VI: Chi tiết sample</returns>
[HttpGet("{id:guid}")]
[ProducesResponseType(typeof(SampleViewModel), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> 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 });
}
/// <summary>
/// EN: Create a new sample.
/// VI: Tạo một sample mới.
/// </summary>
/// <param name="request">EN: Create request / VI: Request tạo</param>
/// <returns>EN: Created sample ID / VI: ID sample đã tạo</returns>
[HttpPost]
[ProducesResponseType(typeof(CreateSampleCommandResult), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> 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 });
}
/// <summary>
/// EN: Update an existing sample.
/// VI: Cập nhật một sample đã tồn tại.
/// </summary>
/// <param name="id">EN: Sample ID / VI: ID sample</param>
/// <param name="request">EN: Update request / VI: Request cập nhật</param>
/// <returns>EN: Success status / VI: Trạng thái thành công</returns>
[HttpPut("{id:guid}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> 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" });
}
/// <summary>
/// EN: Delete a sample.
/// VI: Xóa một sample.
/// </summary>
/// <param name="id">EN: Sample ID / VI: ID sample</param>
/// <returns>EN: Success status / VI: Trạng thái thành công</returns>
[HttpDelete("{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> 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();
}
/// <summary>
/// EN: Change sample status.
/// VI: Thay đổi trạng thái sample.
/// </summary>
/// <param name="id">EN: Sample ID / VI: ID sample</param>
/// <param name="request">EN: Status change request / VI: Request thay đổi trạng thái</param>
/// <returns>EN: Success status / VI: Trạng thái thành công</returns>
[HttpPatch("{id:guid}/status")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> 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" });
}
}
/// <summary>
/// EN: Request model for creating a sample.
/// VI: Model request để tạo sample.
/// </summary>
public record CreateSampleRequest(string Name, string? Description);
/// <summary>
/// EN: Request model for updating a sample.
/// VI: Model request để cập nhật sample.
/// </summary>
public record UpdateSampleRequest(string Name, string? Description);
/// <summary>
/// EN: Request model for changing sample status.
/// VI: Model request để thay đổi trạng thái sample.
/// </summary>
public record ChangeStatusRequest(string Status);

View File

@@ -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;
/// <summary>
/// EN: Controller for Facebook Messenger webhooks.
/// VI: Controller cho Facebook Messenger webhooks.
/// </summary>
[ApiController]
[Route("api/webhooks/facebook")]
[Produces("application/json")]
public class WebhooksController : ControllerBase
{
private readonly IMediator _mediator;
private readonly ILogger<WebhooksController> _logger;
private readonly IConfiguration _configuration;
public WebhooksController(
IMediator mediator,
ILogger<WebhooksController> logger,
IConfiguration configuration)
{
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
}
/// <summary>
/// EN: Webhook verification endpoint for Facebook.
/// VI: Endpoint xác thực webhook cho Facebook.
/// </summary>
[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();
}
/// <summary>
/// EN: Receive webhook events from Facebook Messenger.
/// VI: Nhận webhook events từ Facebook Messenger.
/// </summary>
[HttpPost]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> 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<WebhookEntry>())
{
foreach (var messaging in entry.Messaging ?? Enumerable.Empty<MessagingEvent>())
{
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<bool> 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
/// <summary>
/// EN: Facebook webhook payload model.
/// VI: Model payload webhook Facebook.
/// </summary>
public record FacebookWebhookPayload
{
public string? Object { get; init; }
public List<WebhookEntry>? Entry { get; init; }
}
public record WebhookEntry
{
public string Id { get; init; } = "";
public long Time { get; init; }
public List<MessagingEvent>? 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<AttachmentPayload>? 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

View File

@@ -1,61 +0,0 @@
using FacebookService.Domain.SeedWork;
namespace FacebookService.Domain.AggregatesModel.SampleAggregate;
/// <summary>
/// EN: Repository interface for Sample aggregate.
/// VI: Interface repository cho Sample aggregate.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public interface ISampleRepository : IRepository<Sample>
{
/// <summary>
/// EN: Get a sample by its ID.
/// VI: Lấy một sample theo ID.
/// </summary>
/// <param name="sampleId">EN: The sample ID / VI: ID của sample</param>
/// <returns>EN: The sample or null if not found / VI: Sample hoặc null nếu không tìm thấy</returns>
Task<Sample?> GetAsync(Guid sampleId);
/// <summary>
/// EN: Get all samples.
/// VI: Lấy tất cả samples.
/// </summary>
/// <returns>EN: List of samples / VI: Danh sách samples</returns>
Task<IEnumerable<Sample>> GetAllAsync();
/// <summary>
/// EN: Add a new sample.
/// VI: Thêm một sample mới.
/// </summary>
/// <param name="sample">EN: The sample to add / VI: Sample cần thêm</param>
/// <returns>EN: The added sample / VI: Sample đã thêm</returns>
Sample Add(Sample sample);
/// <summary>
/// EN: Update an existing sample.
/// VI: Cập nhật một sample đã tồn tại.
/// </summary>
/// <param name="sample">EN: The sample to update / VI: Sample cần cập nhật</param>
void Update(Sample sample);
/// <summary>
/// EN: Delete a sample.
/// VI: Xóa một sample.
/// </summary>
/// <param name="sample">EN: The sample to delete / VI: Sample cần xóa</param>
void Delete(Sample sample);
/// <summary>
/// EN: Get samples by status.
/// VI: Lấy samples theo trạng thái.
/// </summary>
/// <param name="statusId">EN: The status ID / VI: ID trạng thái</param>
/// <returns>EN: List of samples with given status / VI: Danh sách samples với trạng thái cho trước</returns>
Task<IEnumerable<Sample>> GetByStatusAsync(int statusId);
}

View File

@@ -1,158 +0,0 @@
using FacebookService.Domain.Events;
using FacebookService.Domain.Exceptions;
using FacebookService.Domain.SeedWork;
namespace FacebookService.Domain.AggregatesModel.SampleAggregate;
/// <summary>
/// EN: Sample aggregate root demonstrating DDD patterns.
/// VI: Sample aggregate root minh họa các pattern DDD.
/// </summary>
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;
/// <summary>
/// EN: Sample name (required).
/// VI: Tên sample (bắt buộc).
/// </summary>
public string Name => _name;
/// <summary>
/// EN: Optional description.
/// VI: Mô tả tùy chọn.
/// </summary>
public string? Description => _description;
/// <summary>
/// EN: Current status.
/// VI: Trạng thái hiện tại.
/// </summary>
public SampleStatus Status => _status;
/// <summary>
/// EN: Status ID for EF Core mapping.
/// VI: ID trạng thái cho EF Core mapping.
/// </summary>
public int StatusId { get; private set; }
/// <summary>
/// EN: Creation timestamp.
/// VI: Thời gian tạo.
/// </summary>
public DateTime CreatedAt => _createdAt;
/// <summary>
/// EN: Last update timestamp.
/// VI: Thời gian cập nhật cuối.
/// </summary>
public DateTime? UpdatedAt => _updatedAt;
/// <summary>
/// EN: Private constructor for EF Core.
/// VI: Constructor private cho EF Core.
/// </summary>
protected Sample()
{
}
/// <summary>
/// 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.
/// </summary>
/// <param name="name">EN: Sample name / VI: Tên sample</param>
/// <param name="description">EN: Optional description / VI: Mô tả tùy chọn</param>
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));
}
/// <summary>
/// EN: Update sample information.
/// VI: Cập nhật thông tin sample.
/// </summary>
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;
}
/// <summary>
/// EN: Activate the sample.
/// VI: Kích hoạt sample.
/// </summary>
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));
}
/// <summary>
/// EN: Complete the sample.
/// VI: Hoàn thành sample.
/// </summary>
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));
}
/// <summary>
/// EN: Cancel the sample.
/// VI: Hủy sample.
/// </summary>
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));
}
}

View File

@@ -1,77 +0,0 @@
using FacebookService.Domain.SeedWork;
namespace FacebookService.Domain.AggregatesModel.SampleAggregate;
/// <summary>
/// EN: Sample status enumeration following type-safe enum pattern.
/// VI: Enumeration trạng thái Sample theo pattern enum an toàn kiểu.
/// </summary>
public class SampleStatus : Enumeration
{
/// <summary>
/// EN: Draft status - initial state
/// VI: Trạng thái nháp - trạng thái ban đầu
/// </summary>
public static SampleStatus Draft = new(1, nameof(Draft));
/// <summary>
/// EN: Active status - ready for use
/// VI: Trạng thái hoạt động - sẵn sàng sử dụng
/// </summary>
public static SampleStatus Active = new(2, nameof(Active));
/// <summary>
/// EN: Completed status - finished processing
/// VI: Trạng thái hoàn thành - đã xử lý xong
/// </summary>
public static SampleStatus Completed = new(3, nameof(Completed));
/// <summary>
/// EN: Cancelled status - cancelled by user
/// VI: Trạng thái đã hủy - bị hủy bởi người dùng
/// </summary>
public static SampleStatus Cancelled = new(4, nameof(Cancelled));
public SampleStatus(int id, string name) : base(id, name)
{
}
/// <summary>
/// EN: Get all available statuses.
/// VI: Lấy tất cả các trạng thái có sẵn.
/// </summary>
public static IEnumerable<SampleStatus> List() => GetAll<SampleStatus>();
/// <summary>
/// EN: Parse status from name.
/// VI: Parse trạng thái từ tên.
/// </summary>
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;
}
/// <summary>
/// EN: Parse status from ID.
/// VI: Parse trạng thái từ ID.
/// </summary>
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;
}
}

View File

@@ -1,22 +0,0 @@
using MediatR;
using FacebookService.Domain.AggregatesModel.SampleAggregate;
namespace FacebookService.Domain.Events;
/// <summary>
/// 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.
/// </summary>
public class SampleCreatedDomainEvent : INotification
{
/// <summary>
/// EN: The newly created sample.
/// VI: Sample mới được tạo.
/// </summary>
public Sample Sample { get; }
public SampleCreatedDomainEvent(Sample sample)
{
Sample = sample;
}
}

View File

@@ -1,39 +0,0 @@
using MediatR;
using FacebookService.Domain.AggregatesModel.SampleAggregate;
namespace FacebookService.Domain.Events;
/// <summary>
/// EN: Domain event raised when Sample status changes.
/// VI: Domain event được phát ra khi trạng thái Sample thay đổi.
/// </summary>
public class SampleStatusChangedDomainEvent : INotification
{
/// <summary>
/// EN: The sample ID.
/// VI: ID của sample.
/// </summary>
public Guid SampleId { get; }
/// <summary>
/// EN: Previous status before the change.
/// VI: Trạng thái trước khi thay đổi.
/// </summary>
public SampleStatus PreviousStatus { get; }
/// <summary>
/// EN: New status after the change.
/// VI: Trạng thái mới sau khi thay đổi.
/// </summary>
public SampleStatus NewStatus { get; }
public SampleStatusChangedDomainEvent(
Guid sampleId,
SampleStatus previousStatus,
SampleStatus newStatus)
{
SampleId = sampleId;
PreviousStatus = previousStatus;
NewStatus = newStatus;
}
}

View File

@@ -1,21 +0,0 @@
namespace FacebookService.Domain.Exceptions;
/// <summary>
/// EN: Exception for Sample aggregate domain errors.
/// VI: Exception cho các lỗi domain của Sample aggregate.
/// </summary>
public class SampleDomainException : DomainException
{
public SampleDomainException()
{
}
public SampleDomainException(string message) : base(message)
{
}
public SampleDomainException(string message, Exception innerException)
: base(message, innerException)
{
}
}

View File

@@ -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<IRequestManager, RequestManager>();
// EN: Register Facebook Messenger client / VI: Đăng ký Facebook Messenger client
services.AddHttpClient<IFacebookMessengerClient, FacebookMessengerClient>()
.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<IAIClient, AzureOpenAIClient>()
.AddPolicyHandler(GetAIRetryPolicy());
}
else
{
services.AddHttpClient<IAIClient, OpenAIClient>()
.AddPolicyHandler(GetAIRetryPolicy());
}
return services;
}
private static IAsyncPolicy<HttpResponseMessage> GetAIRetryPolicy()
{
return HttpPolicyExtensions
.HandleTransientHttpError()
.OrResult(msg => msg.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
.WaitAndRetryAsync(3, retryAttempt =>
TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));
}
}

View File

@@ -1,61 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using FacebookService.Domain.AggregatesModel.SampleAggregate;
namespace FacebookService.Infrastructure.EntityConfigurations;
/// <summary>
/// EN: EF Core configuration for Sample entity.
/// VI: Cấu hình EF Core cho entity Sample.
/// </summary>
public class SampleEntityTypeConfiguration : IEntityTypeConfiguration<Sample>
{
public void Configure(EntityTypeBuilder<Sample> 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<string>("_name")
.HasColumnName("name")
.HasMaxLength(200)
.IsRequired();
builder.Property<string?>("_description")
.HasColumnName("description")
.HasMaxLength(1000);
builder.Property<DateTime>("_createdAt")
.HasColumnName("created_at")
.IsRequired();
builder.Property<DateTime?>("_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");
}
}

View File

@@ -1,39 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using FacebookService.Domain.AggregatesModel.SampleAggregate;
namespace FacebookService.Infrastructure.EntityConfigurations;
/// <summary>
/// EN: EF Core configuration for SampleStatus enumeration.
/// VI: Cấu hình EF Core cho enumeration SampleStatus.
/// </summary>
public class SampleStatusEntityTypeConfiguration : IEntityTypeConfiguration<SampleStatus>
{
public void Configure(EntityTypeBuilder<SampleStatus> 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
);
}
}

View File

@@ -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;
/// <summary>
/// EN: Facebook Messenger API client implementation.
/// VI: Triển khai client cho Facebook Messenger API.
/// </summary>
public class FacebookMessengerClient : IFacebookMessengerClient
{
private readonly HttpClient _httpClient;
private readonly ILogger<FacebookMessengerClient> _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<FacebookMessengerClient> 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}/");
}
/// <inheritdoc/>
public async Task<SendMessageResult> SendTextMessageAsync(
string recipientId,
string message,
CancellationToken cancellationToken = default)
{
var payload = new
{
recipient = new { id = recipientId },
message = new { text = message }
};
return await SendRequestAsync(payload, cancellationToken);
}
/// <inheritdoc/>
public async Task<SendMessageResult> SendQuickRepliesAsync(
string recipientId,
string message,
IEnumerable<QuickReply> 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);
}
/// <inheritdoc/>
public async Task<SendMessageResult> SendGenericTemplateAsync(
string recipientId,
IEnumerable<TemplateElement> 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);
}
/// <inheritdoc/>
public async Task<SendMessageResult> SendButtonTemplateAsync(
string recipientId,
string text,
IEnumerable<TemplateButton> 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);
}
/// <inheritdoc/>
public async Task<FacebookUserProfile?> 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<JsonElement>(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;
}
}
/// <inheritdoc/>
public async Task<bool> 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;
}
/// <inheritdoc/>
public async Task<bool> 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<SendMessageResult> 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<JsonElement>(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 }
};
}
/// <summary>
/// EN: Create retry policy for HTTP requests.
/// VI: Tạo retry policy cho HTTP requests.
/// </summary>
public static IAsyncPolicy<HttpResponseMessage> GetRetryPolicy()
{
return HttpPolicyExtensions
.HandleTransientHttpError()
.WaitAndRetryAsync(3, retryAttempt =>
TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));
}
}

View File

@@ -0,0 +1,51 @@
namespace FacebookService.Infrastructure.ExternalServices;
/// <summary>
/// EN: Interface for AI/LLM service client.
/// VI: Interface cho AI/LLM service client.
/// </summary>
public interface IAIClient
{
/// <summary>
/// EN: Generate a chat completion response.
/// VI: Tạo response chat completion.
/// </summary>
Task<AICompletionResult> GenerateChatCompletionAsync(
string systemPrompt,
IEnumerable<ChatMessage> conversationHistory,
AICompletionOptions options,
CancellationToken cancellationToken = default);
/// <summary>
/// EN: Check if the AI service is available.
/// VI: Kiểm tra AI service có sẵn sàng không.
/// </summary>
Task<bool> 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

View File

@@ -0,0 +1,113 @@
namespace FacebookService.Infrastructure.ExternalServices;
/// <summary>
/// EN: Interface for Facebook Messenger API client.
/// VI: Interface cho Facebook Messenger API client.
/// </summary>
public interface IFacebookMessengerClient
{
/// <summary>
/// EN: Send a text message to a user.
/// VI: Gửi tin nhắn văn bản đến người dùng.
/// </summary>
Task<SendMessageResult> SendTextMessageAsync(
string recipientId,
string message,
CancellationToken cancellationToken = default);
/// <summary>
/// EN: Send a message with quick replies.
/// VI: Gửi tin nhắn với quick replies.
/// </summary>
Task<SendMessageResult> SendQuickRepliesAsync(
string recipientId,
string message,
IEnumerable<QuickReply> quickReplies,
CancellationToken cancellationToken = default);
/// <summary>
/// EN: Send a generic template (cards).
/// VI: Gửi template generic (cards).
/// </summary>
Task<SendMessageResult> SendGenericTemplateAsync(
string recipientId,
IEnumerable<TemplateElement> elements,
CancellationToken cancellationToken = default);
/// <summary>
/// EN: Send a button template.
/// VI: Gửi template button.
/// </summary>
Task<SendMessageResult> SendButtonTemplateAsync(
string recipientId,
string text,
IEnumerable<TemplateButton> buttons,
CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get user profile from Facebook.
/// VI: Lấy thông tin người dùng từ Facebook.
/// </summary>
Task<FacebookUserProfile?> GetUserProfileAsync(
string userId,
CancellationToken cancellationToken = default);
/// <summary>
/// EN: Mark message as seen.
/// VI: Đánh dấu tin nhắn đã xem.
/// </summary>
Task<bool> MarkAsSeenAsync(
string recipientId,
CancellationToken cancellationToken = default);
/// <summary>
/// EN: Show typing indicator.
/// VI: Hiển thị indicator đang gõ.
/// </summary>
Task<bool> 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<TemplateButton>? 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

View File

@@ -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;
/// <summary>
/// EN: OpenAI API client implementation.
/// VI: Triển khai client cho OpenAI API.
/// </summary>
public class OpenAIClient : IAIClient
{
private readonly HttpClient _httpClient;
private readonly ILogger<OpenAIClient> _logger;
private readonly string _apiKey;
public OpenAIClient(
HttpClient httpClient,
IConfiguration configuration,
ILogger<OpenAIClient> 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}");
}
/// <inheritdoc/>
public async Task<AICompletionResult> GenerateChatCompletionAsync(
string systemPrompt,
IEnumerable<ChatMessage> conversationHistory,
AICompletionOptions options,
CancellationToken cancellationToken = default)
{
try
{
var messages = new List<object>
{
new { role = "system", content = systemPrompt }
};
messages.AddRange(conversationHistory.Select(m => new
{
role = m.Role,
content = m.Content
}));
var payload = new
{
model = options.Model,
messages = messages,
temperature = options.Temperature,
max_tokens = options.MaxTokens,
top_p = options.TopP,
frequency_penalty = options.FrequencyPenalty,
presence_penalty = options.PresencePenalty
};
var response = await _httpClient.PostAsJsonAsync(
"chat/completions", payload, cancellationToken);
var content = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("OpenAI API error: {Response}", content);
return new AICompletionResult(Success: false, Error: content);
}
var data = JsonSerializer.Deserialize<JsonElement>(content);
var choice = data.GetProperty("choices")[0];
var message = choice.GetProperty("message");
var usage = data.GetProperty("usage");
return new AICompletionResult(
Success: true,
Content: message.GetProperty("content").GetString(),
PromptTokens: usage.GetProperty("prompt_tokens").GetInt32(),
CompletionTokens: usage.GetProperty("completion_tokens").GetInt32(),
FinishReason: choice.GetProperty("finish_reason").GetString()
);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error calling OpenAI API");
return new AICompletionResult(Success: false, Error: ex.Message);
}
}
/// <inheritdoc/>
public async Task<bool> IsAvailableAsync(CancellationToken cancellationToken = default)
{
try
{
var response = await _httpClient.GetAsync("models", cancellationToken);
return response.IsSuccessStatusCode;
}
catch
{
return false;
}
}
}
/// <summary>
/// EN: Azure OpenAI API client implementation.
/// VI: Triển khai client cho Azure OpenAI API.
/// </summary>
public class AzureOpenAIClient : IAIClient
{
private readonly HttpClient _httpClient;
private readonly ILogger<AzureOpenAIClient> _logger;
private readonly string _deploymentName;
private readonly string _apiVersion;
public AzureOpenAIClient(
HttpClient httpClient,
IConfiguration configuration,
ILogger<AzureOpenAIClient> logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
var endpoint = configuration["AzureOpenAI:Endpoint"]
?? configuration["AZURE_OPENAI_ENDPOINT"]
?? throw new InvalidOperationException("Azure OpenAI Endpoint not configured");
var apiKey = configuration["AzureOpenAI:ApiKey"]
?? configuration["AZURE_OPENAI_KEY"]
?? throw new InvalidOperationException("Azure OpenAI API Key not configured");
_deploymentName = configuration["AzureOpenAI:DeploymentName"]
?? configuration["AZURE_OPENAI_DEPLOYMENT_NAME"]
?? "gpt-4";
_apiVersion = configuration["AzureOpenAI:ApiVersion"]
?? "2024-02-01";
_httpClient.BaseAddress = new Uri(endpoint.TrimEnd('/') + "/");
_httpClient.DefaultRequestHeaders.Add("api-key", apiKey);
}
/// <inheritdoc/>
public async Task<AICompletionResult> GenerateChatCompletionAsync(
string systemPrompt,
IEnumerable<ChatMessage> conversationHistory,
AICompletionOptions options,
CancellationToken cancellationToken = default)
{
try
{
var messages = new List<object>
{
new { role = "system", content = systemPrompt }
};
messages.AddRange(conversationHistory.Select(m => new
{
role = m.Role,
content = m.Content
}));
var payload = new
{
messages = messages,
temperature = options.Temperature,
max_tokens = options.MaxTokens,
top_p = options.TopP,
frequency_penalty = options.FrequencyPenalty,
presence_penalty = options.PresencePenalty
};
var url = $"openai/deployments/{_deploymentName}/chat/completions?api-version={_apiVersion}";
var response = await _httpClient.PostAsJsonAsync(url, payload, cancellationToken);
var content = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("Azure OpenAI API error: {Response}", content);
return new AICompletionResult(Success: false, Error: content);
}
var data = JsonSerializer.Deserialize<JsonElement>(content);
var choice = data.GetProperty("choices")[0];
var message = choice.GetProperty("message");
var usage = data.GetProperty("usage");
return new AICompletionResult(
Success: true,
Content: message.GetProperty("content").GetString(),
PromptTokens: usage.GetProperty("prompt_tokens").GetInt32(),
CompletionTokens: usage.GetProperty("completion_tokens").GetInt32(),
FinishReason: choice.GetProperty("finish_reason").GetString()
);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error calling Azure OpenAI API");
return new AICompletionResult(Success: false, Error: ex.Message);
}
}
/// <inheritdoc/>
public async Task<bool> IsAvailableAsync(CancellationToken cancellationToken = default)
{
try
{
var url = $"openai/deployments/{_deploymentName}?api-version={_apiVersion}";
var response = await _httpClient.GetAsync(url, cancellationToken);
return response.IsSuccessStatusCode;
}
catch
{
return false;
}
}
}

View File

@@ -1,65 +0,0 @@
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Moq;
using FacebookService.API.Application.Commands;
using FacebookService.Domain.AggregatesModel.SampleAggregate;
using FacebookService.Domain.SeedWork;
using Xunit;
namespace FacebookService.UnitTests.Application;
/// <summary>
/// EN: Unit tests for CreateSampleCommandHandler.
/// VI: Unit tests cho CreateSampleCommandHandler.
/// </summary>
public class CreateSampleCommandHandlerTests
{
private readonly Mock<ISampleRepository> _mockRepository;
private readonly Mock<ILogger<CreateSampleCommandHandler>> _mockLogger;
private readonly CreateSampleCommandHandler _handler;
public CreateSampleCommandHandlerTests()
{
_mockRepository = new Mock<ISampleRepository>();
_mockLogger = new Mock<ILogger<CreateSampleCommandHandler>>();
var mockUnitOfWork = new Mock<IUnitOfWork>();
mockUnitOfWork.Setup(u => u.SaveEntitiesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
_mockRepository.SetupGet(r => r.UnitOfWork).Returns(mockUnitOfWork.Object);
_handler = new CreateSampleCommandHandler(_mockRepository.Object, _mockLogger.Object);
}
[Fact]
public async Task Handle_WithValidCommand_ShouldCreateSampleAndReturnId()
{
// Arrange
var command = new CreateSampleCommand("Test Sample", "Test Description");
_mockRepository.Setup(r => r.Add(It.IsAny<Sample>()))
.Returns((Sample s) => s);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.Id.Should().NotBeEmpty();
_mockRepository.Verify(r => r.Add(It.IsAny<Sample>()), Times.Once);
}
[Fact]
public async Task Handle_WithValidCommand_ShouldCallSaveEntities()
{
// Arrange
var command = new CreateSampleCommand("Test Sample", null);
// Act
await _handler.Handle(command, CancellationToken.None);
// Assert
_mockRepository.Verify(r => r.UnitOfWork.SaveEntitiesAsync(It.IsAny<CancellationToken>()), Times.Once);
}
}

View File

@@ -1,151 +0,0 @@
using FluentAssertions;
using FacebookService.Domain.AggregatesModel.SampleAggregate;
using FacebookService.Domain.Exceptions;
using Xunit;
namespace FacebookService.UnitTests.Domain;
/// <summary>
/// EN: Unit tests for Sample aggregate.
/// VI: Unit tests cho Sample aggregate.
/// </summary>
public class SampleAggregateTests
{
[Fact]
public void CreateSample_WithValidName_ShouldCreateWithDraftStatus()
{
// Arrange
var name = "Test Sample";
var description = "Test Description";
// Act
var sample = new Sample(name, description);
// Assert
sample.Name.Should().Be(name);
sample.Description.Should().Be(description);
sample.Status.Should().Be(SampleStatus.Draft);
sample.Id.Should().NotBeEmpty();
sample.DomainEvents.Should().ContainSingle(); // SampleCreatedDomainEvent
}
[Fact]
public void CreateSample_WithEmptyName_ShouldThrowException()
{
// Arrange
var name = "";
// Act
var act = () => new Sample(name);
// Assert
act.Should().Throw<SampleDomainException>()
.WithMessage("Sample name cannot be empty");
}
[Fact]
public void Activate_WhenDraft_ShouldChangeToActive()
{
// Arrange
var sample = new Sample("Test Sample");
sample.ClearDomainEvents();
// Act
sample.Activate();
// Assert
sample.Status.Should().Be(SampleStatus.Active);
sample.DomainEvents.Should().ContainSingle(); // SampleStatusChangedDomainEvent
}
[Fact]
public void Activate_WhenNotDraft_ShouldThrowException()
{
// Arrange
var sample = new Sample("Test Sample");
sample.Activate();
// Act
var act = () => sample.Activate();
// Assert
act.Should().Throw<SampleDomainException>()
.WithMessage("Only draft samples can be activated");
}
[Fact]
public void Complete_WhenActive_ShouldChangeToCompleted()
{
// Arrange
var sample = new Sample("Test Sample");
sample.Activate();
sample.ClearDomainEvents();
// Act
sample.Complete();
// Assert
sample.Status.Should().Be(SampleStatus.Completed);
}
[Fact]
public void Cancel_WhenDraftOrActive_ShouldChangeToCancelled()
{
// Arrange
var sample = new Sample("Test Sample");
// Act
sample.Cancel();
// Assert
sample.Status.Should().Be(SampleStatus.Cancelled);
}
[Fact]
public void Cancel_WhenCompleted_ShouldThrowException()
{
// Arrange
var sample = new Sample("Test Sample");
sample.Activate();
sample.Complete();
// Act
var act = () => sample.Cancel();
// Assert
act.Should().Throw<SampleDomainException>()
.WithMessage("Cannot cancel a completed sample");
}
[Fact]
public void Update_WhenNotCancelled_ShouldUpdateNameAndDescription()
{
// Arrange
var sample = new Sample("Original Name", "Original Description");
var newName = "Updated Name";
var newDescription = "Updated Description";
// Act
sample.Update(newName, newDescription);
// Assert
sample.Name.Should().Be(newName);
sample.Description.Should().Be(newDescription);
sample.UpdatedAt.Should().NotBeNull();
}
[Fact]
public void Update_WhenCancelled_ShouldThrowException()
{
// Arrange
var sample = new Sample("Test Sample");
sample.Cancel();
// Act
var act = () => sample.Update("New Name", null);
// Assert
act.Should().Throw<SampleDomainException>()
.WithMessage("Cannot update a cancelled sample");
}
}

View File

@@ -0,0 +1,23 @@
using MediatR;
namespace WhatsAppService.API.Application.Commands;
/// <summary>
/// EN: Command to connect a WhatsApp Business Account.
/// VI: Command để kết nối WhatsApp Business Account.
/// </summary>
public record ConnectWhatsAppAccountCommand(
Guid ShopId,
string PhoneNumberId,
string AccessToken,
string? WebhookUrl
) : IRequest<ConnectWhatsAppAccountResult>;
/// <summary>
/// EN: Result of connecting a WhatsApp account.
/// VI: Kết quả kết nối WhatsApp account.
/// </summary>
public record ConnectWhatsAppAccountResult(
bool Success,
Guid? AccountId,
string? Error);

View File

@@ -0,0 +1,68 @@
using MediatR;
using Microsoft.Extensions.Logging;
using WhatsAppService.Domain.AggregatesModel.WhatsAppAccountAggregate;
namespace WhatsAppService.API.Application.Commands;
/// <summary>
/// EN: Handler for ConnectWhatsAppAccountCommand.
/// VI: Handler cho ConnectWhatsAppAccountCommand.
/// </summary>
public class ConnectWhatsAppAccountCommandHandler : IRequestHandler<ConnectWhatsAppAccountCommand, ConnectWhatsAppAccountResult>
{
private readonly IWhatsAppAccountRepository _repository;
private readonly ILogger<ConnectWhatsAppAccountCommandHandler> _logger;
public ConnectWhatsAppAccountCommandHandler(
IWhatsAppAccountRepository repository,
ILogger<ConnectWhatsAppAccountCommandHandler> logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<ConnectWhatsAppAccountResult> Handle(
ConnectWhatsAppAccountCommand request,
CancellationToken cancellationToken)
{
try
{
// Check if account already exists for this shop
var existing = await _repository.GetByShopIdAsync(request.ShopId, cancellationToken);
if (existing != null)
{
return new ConnectWhatsAppAccountResult(false, null, "Shop already has a connected WhatsApp account");
}
// Check if phone number is already in use
var existingPhone = await _repository.GetByPhoneNumberIdAsync(request.PhoneNumberId, cancellationToken);
if (existingPhone != null)
{
return new ConnectWhatsAppAccountResult(false, null, "Phone number is already connected to another shop");
}
// TODO: Encrypt access token before storing
var encryptedToken = request.AccessToken; // Placeholder - should use encryption service
var account = new WhatsAppAccount(
request.ShopId,
request.PhoneNumberId,
encryptedToken,
request.WebhookUrl);
await _repository.AddAsync(account, cancellationToken);
await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
_logger.LogInformation(
"WhatsApp account connected for shop {ShopId}. AccountId: {AccountId}",
request.ShopId, account.Id);
return new ConnectWhatsAppAccountResult(true, account.Id, null);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to connect WhatsApp account for shop {ShopId}", request.ShopId);
return new ConnectWhatsAppAccountResult(false, null, ex.Message);
}
}
}

View File

@@ -0,0 +1,30 @@
using MediatR;
namespace WhatsAppService.API.Application.Commands;
/// <summary>
/// EN: Command to process an incoming WhatsApp message from webhook.
/// VI: Command để xử lý tin nhắn WhatsApp đến từ webhook.
/// </summary>
public record ProcessIncomingMessageCommand(
string PhoneNumberId,
string SenderWaId,
string MessageId,
string MessageType,
string? TextBody,
string? MediaId,
string? MediaMimeType,
object? Interactive,
DateTime Timestamp
) : IRequest<ProcessIncomingMessageResult>;
/// <summary>
/// EN: Result of processing incoming message.
/// VI: Kết quả xử lý tin nhắn đến.
/// </summary>
public record ProcessIncomingMessageResult(
bool Success,
Guid? ConversationId,
Guid? MessageId,
bool IsNewConversation,
string? Error);

View File

@@ -0,0 +1,98 @@
using MediatR;
using Microsoft.Extensions.Logging;
using WhatsAppService.Domain.AggregatesModel.ConversationAggregate;
using WhatsAppService.Domain.AggregatesModel.CustomerAggregate;
using WhatsAppService.Domain.AggregatesModel.WhatsAppAccountAggregate;
namespace WhatsAppService.API.Application.Commands;
/// <summary>
/// EN: Handler for ProcessIncomingMessageCommand.
/// VI: Handler cho ProcessIncomingMessageCommand.
/// </summary>
public class ProcessIncomingMessageCommandHandler : IRequestHandler<ProcessIncomingMessageCommand, ProcessIncomingMessageResult>
{
private readonly IWhatsAppAccountRepository _accountRepository;
private readonly IConversationRepository _conversationRepository;
private readonly ICustomerRepository _customerRepository;
private readonly ILogger<ProcessIncomingMessageCommandHandler> _logger;
public ProcessIncomingMessageCommandHandler(
IWhatsAppAccountRepository accountRepository,
IConversationRepository conversationRepository,
ICustomerRepository customerRepository,
ILogger<ProcessIncomingMessageCommandHandler> logger)
{
_accountRepository = accountRepository;
_conversationRepository = conversationRepository;
_customerRepository = customerRepository;
_logger = logger;
}
public async Task<ProcessIncomingMessageResult> Handle(
ProcessIncomingMessageCommand request,
CancellationToken cancellationToken)
{
try
{
// Find WhatsApp account by phone number ID
var account = await _accountRepository.GetByPhoneNumberIdAsync(request.PhoneNumberId, cancellationToken);
if (account == null)
{
_logger.LogWarning("No WhatsApp account found for phone number ID: {PhoneNumberId}", request.PhoneNumberId);
return new ProcessIncomingMessageResult(false, null, null, false, "Account not found");
}
var shopId = account.ShopId;
var isNewConversation = false;
// Get or create customer
var customer = await _customerRepository.GetByWaIdAsync(shopId, request.SenderWaId, cancellationToken);
if (customer == null)
{
customer = new Customer(shopId, request.SenderWaId);
await _customerRepository.AddAsync(customer, cancellationToken);
}
// Get or create conversation
var conversation = await _conversationRepository.GetActiveByCustomerAsync(shopId, request.SenderWaId, cancellationToken);
if (conversation == null)
{
conversation = new Conversation(shopId, request.SenderWaId, account.Id);
await _conversationRepository.AddAsync(conversation, cancellationToken);
isNewConversation = true;
}
// Create message content based on type
var content = CreateMessageContent(request);
// Add message to conversation
var message = conversation.AddMessage(content, Message.Directions.Inbound, request.MessageId);
await _conversationRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
_logger.LogInformation(
"Processed incoming message. ConversationId: {ConversationId}, MessageId: {MessageId}, IsNew: {IsNew}",
conversation.Id, message.Id, isNewConversation);
return new ProcessIncomingMessageResult(true, conversation.Id, message.Id, isNewConversation, null);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to process incoming message from {SenderWaId}", request.SenderWaId);
return new ProcessIncomingMessageResult(false, null, null, false, ex.Message);
}
}
private static MessageContent CreateMessageContent(ProcessIncomingMessageCommand request)
{
return request.MessageType.ToLowerInvariant() switch
{
"text" => MessageContent.CreateText(request.TextBody ?? ""),
"image" or "video" or "audio" or "document" =>
MessageContent.CreateMedia(request.MessageType, request.MediaId ?? "", null, request.MediaMimeType),
"interactive" => MessageContent.CreateInteractive(request.Interactive ?? new { }),
_ => MessageContent.CreateText(request.TextBody ?? $"[Unsupported: {request.MessageType}]")
};
}
}

View File

@@ -0,0 +1,26 @@
using MediatR;
namespace WhatsAppService.API.Application.Commands;
/// <summary>
/// EN: Command to send a WhatsApp message.
/// VI: Command để gửi tin nhắn WhatsApp.
/// </summary>
public record SendMessageCommand(
Guid ConversationId,
string MessageType,
string? Text,
string? MediaUrl,
string? Caption,
object? Interactive
) : IRequest<SendMessageResult>;
/// <summary>
/// EN: Result of sending a message.
/// VI: Kết quả gửi tin nhắn.
/// </summary>
public record SendMessageResult(
bool Success,
Guid? MessageId,
string? WhatsAppMessageId,
string? Error);

View File

@@ -0,0 +1,120 @@
using MediatR;
using Microsoft.Extensions.Logging;
using WhatsAppService.Domain.AggregatesModel.ConversationAggregate;
using WhatsAppService.Domain.AggregatesModel.WhatsAppAccountAggregate;
using WhatsAppService.Infrastructure.ExternalServices;
namespace WhatsAppService.API.Application.Commands;
/// <summary>
/// EN: Handler for SendMessageCommand.
/// VI: Handler cho SendMessageCommand.
/// </summary>
public class SendMessageCommandHandler : IRequestHandler<SendMessageCommand, SendMessageResult>
{
private readonly IConversationRepository _conversationRepository;
private readonly IWhatsAppAccountRepository _accountRepository;
private readonly IWhatsAppCloudApiClient _whatsAppClient;
private readonly ILogger<SendMessageCommandHandler> _logger;
public SendMessageCommandHandler(
IConversationRepository conversationRepository,
IWhatsAppAccountRepository accountRepository,
IWhatsAppCloudApiClient whatsAppClient,
ILogger<SendMessageCommandHandler> logger)
{
_conversationRepository = conversationRepository;
_accountRepository = accountRepository;
_whatsAppClient = whatsAppClient;
_logger = logger;
}
public async Task<SendMessageResult> Handle(
SendMessageCommand request,
CancellationToken cancellationToken)
{
try
{
// Get conversation
var conversation = await _conversationRepository.GetByIdAsync(request.ConversationId, cancellationToken);
if (conversation == null)
{
return new SendMessageResult(false, null, null, "Conversation not found");
}
// Check 24h window
if (!conversation.IsWithinMessagingWindow())
{
return new SendMessageResult(false, null, null, "Conversation outside 24h messaging window. Use template message.");
}
// Get WhatsApp account
var account = await _accountRepository.GetByIdAsync(conversation.WhatsAppAccountId, cancellationToken);
if (account == null || account.Status != WhatsAppAccountStatus.Active)
{
return new SendMessageResult(false, null, null, "WhatsApp account not available");
}
// TODO: Decrypt access token
var accessToken = account.AccessTokenEncrypted;
// Create message content
var content = CreateMessageContent(request);
// Send via WhatsApp API
var apiResult = await SendViaApiAsync(account.PhoneNumberId, accessToken, conversation.CustomerWaId, request, cancellationToken);
if (!apiResult.Success)
{
return new SendMessageResult(false, null, null, apiResult.ErrorMessage);
}
// Add message to conversation
var message = conversation.AddMessage(content, Message.Directions.Outbound, apiResult.MessageId);
await _conversationRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
_logger.LogInformation(
"Sent message. ConversationId: {ConversationId}, MessageId: {MessageId}",
conversation.Id, message.Id);
return new SendMessageResult(true, message.Id, apiResult.MessageId, null);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send message for conversation {ConversationId}", request.ConversationId);
return new SendMessageResult(false, null, null, ex.Message);
}
}
private static MessageContent CreateMessageContent(SendMessageCommand request)
{
return request.MessageType.ToLowerInvariant() switch
{
"text" => MessageContent.CreateText(request.Text ?? ""),
"image" or "video" or "audio" or "document" =>
MessageContent.CreateMedia(request.MessageType, request.MediaUrl ?? "", request.Caption),
"interactive" => MessageContent.CreateInteractive(request.Interactive ?? new { }),
_ => MessageContent.CreateText(request.Text ?? "")
};
}
private async Task<Infrastructure.ExternalServices.SendMessageResult> SendViaApiAsync(
string phoneNumberId,
string accessToken,
string recipientWaId,
SendMessageCommand request,
CancellationToken cancellationToken)
{
return request.MessageType.ToLowerInvariant() switch
{
"text" => await _whatsAppClient.SendTextMessageAsync(
phoneNumberId, accessToken, recipientWaId, request.Text ?? "", cancellationToken),
"image" or "video" or "audio" or "document" => await _whatsAppClient.SendMediaMessageAsync(
phoneNumberId, accessToken, recipientWaId, request.MessageType, request.MediaUrl ?? "", request.Caption, cancellationToken),
"interactive" => await _whatsAppClient.SendInteractiveMessageAsync(
phoneNumberId, accessToken, recipientWaId, request.Interactive ?? new { }, cancellationToken),
_ => await _whatsAppClient.SendTextMessageAsync(
phoneNumberId, accessToken, recipientWaId, request.Text ?? "", cancellationToken)
};
}
}

View File

@@ -0,0 +1,36 @@
using MediatR;
namespace WhatsAppService.API.Application.Queries;
/// <summary>
/// EN: Query to get conversations by shop with pagination.
/// VI: Query để lấy conversations theo shop với phân trang.
/// </summary>
public record GetConversationsQuery(
Guid ShopId,
int? StatusId,
int Skip = 0,
int Take = 20
) : IRequest<GetConversationsResult>;
/// <summary>
/// EN: Result of GetConversationsQuery.
/// VI: Kết quả của GetConversationsQuery.
/// </summary>
public record GetConversationsResult(
List<ConversationViewModel> Conversations,
int TotalCount);
/// <summary>
/// EN: Conversation view model.
/// VI: View model cho conversation.
/// </summary>
public record ConversationViewModel(
Guid Id,
string CustomerWaId,
string Status,
Guid? AssignedAgentId,
DateTime? LastMessageAt,
DateTime CreatedAt,
DateTime? ExpiresAt,
int MessageCount);

View File

@@ -0,0 +1,61 @@
using MediatR;
using WhatsAppService.Domain.AggregatesModel.AutomationFlowAggregate;
using WhatsAppService.Domain.Events;
namespace WhatsAppService.API.BackgroundJobs;
/// <summary>
/// EN: Domain event handler for processing automation triggers on new messages.
/// VI: Domain event handler để xử lý automation triggers khi có tin nhắn mới.
/// </summary>
public class AutomationTriggerHandler : INotificationHandler<MessageReceivedEvent>
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<AutomationTriggerHandler> _logger;
public AutomationTriggerHandler(
IServiceProvider serviceProvider,
ILogger<AutomationTriggerHandler> logger)
{
_serviceProvider = serviceProvider;
_logger = logger;
}
public async Task Handle(MessageReceivedEvent notification, CancellationToken cancellationToken)
{
try
{
using var scope = _serviceProvider.CreateScope();
var flowRepository = scope.ServiceProvider.GetRequiredService<IAutomationFlowRepository>();
// Get active keyword-triggered flows for this shop
var flows = await flowRepository.GetByTriggerTypeAsync(
notification.ShopId,
TriggerType.Keyword,
cancellationToken);
if (flows.Count == 0)
{
return;
}
var messageText = notification.Content.Text?.ToLowerInvariant() ?? "";
foreach (var flow in flows.OrderByDescending(f => f.Priority))
{
// TODO: Parse trigger config and check for keyword matches
// This is a placeholder - actual implementation would parse TriggerConfig JSON
_logger.LogDebug(
"Checking flow {FlowId} for keyword triggers on message from {CustomerWaId}",
flow.Id, notification.CustomerWaId);
// If matched, execute flow steps
// await ExecuteFlowAsync(flow, notification, cancellationToken);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing automation triggers for message {MessageId}", notification.MessageId);
}
}
}

View File

@@ -0,0 +1,67 @@
using WhatsAppService.Domain.AggregatesModel.ConversationAggregate;
namespace WhatsAppService.API.BackgroundJobs;
/// <summary>
/// EN: Background job to check and expire conversations outside 24h window.
/// VI: Background job để kiểm tra và hết hạn conversations ngoài cửa sổ 24h.
/// </summary>
public class ConversationExpiryJob : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<ConversationExpiryJob> _logger;
private readonly TimeSpan _checkInterval = TimeSpan.FromMinutes(5);
public ConversationExpiryJob(
IServiceProvider serviceProvider,
ILogger<ConversationExpiryJob> logger)
{
_serviceProvider = serviceProvider;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("ConversationExpiryJob started");
while (!stoppingToken.IsCancellationRequested)
{
try
{
await ProcessExpiredConversationsAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in ConversationExpiryJob");
}
await Task.Delay(_checkInterval, stoppingToken);
}
_logger.LogInformation("ConversationExpiryJob stopped");
}
private async Task ProcessExpiredConversationsAsync(CancellationToken cancellationToken)
{
using var scope = _serviceProvider.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<IConversationRepository>();
var expiredConversations = await repository.GetExpiredConversationsAsync(cancellationToken);
if (expiredConversations.Count == 0)
{
return;
}
_logger.LogInformation("Found {Count} expired conversations", expiredConversations.Count);
foreach (var conversation in expiredConversations)
{
conversation.CheckExpiry();
}
await repository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
_logger.LogInformation("Processed {Count} expired conversations", expiredConversations.Count);
}
}

View File

@@ -0,0 +1,170 @@
using MediatR;
using Microsoft.AspNetCore.Mvc;
using WhatsAppService.API.Application.Commands;
using WhatsAppService.Domain.AggregatesModel.ConversationAggregate;
namespace WhatsAppService.API.Controllers;
/// <summary>
/// EN: Controller for managing conversations.
/// VI: Controller để quản lý conversations.
/// </summary>
[ApiController]
[Route("api/[controller]")]
[Produces("application/json")]
public class ConversationsController : ControllerBase
{
private readonly IMediator _mediator;
private readonly IConversationRepository _repository;
private readonly ILogger<ConversationsController> _logger;
public ConversationsController(
IMediator mediator,
IConversationRepository repository,
ILogger<ConversationsController> logger)
{
_mediator = mediator;
_repository = repository;
_logger = logger;
}
/// <summary>
/// EN: Get conversations by shop with pagination.
/// VI: Lấy conversations theo shop với phân trang.
/// </summary>
[HttpGet]
[ProducesResponseType(typeof(object), StatusCodes.Status200OK)]
public async Task<IActionResult> GetConversations(
[FromQuery] Guid shopId,
[FromQuery] string? status,
[FromQuery] int skip = 0,
[FromQuery] int take = 20)
{
ConversationStatus? statusFilter = null;
if (!string.IsNullOrEmpty(status))
{
statusFilter = ConversationStatus.FromName(status);
}
var conversations = await _repository.GetByShopIdAsync(shopId, skip, take, statusFilter);
return Ok(new
{
success = true,
data = conversations.Select(c => new
{
id = c.Id,
customerWaId = c.CustomerWaId,
status = c.Status.Name,
assignedAgentId = c.AssignedAgentId,
lastMessageAt = c.LastMessageAt,
createdAt = c.CreatedAt,
expiresAt = c.ExpiresAt,
messageCount = c.Messages.Count
})
});
}
/// <summary>
/// EN: Get conversation by ID with messages.
/// VI: Lấy conversation theo ID kèm messages.
/// </summary>
[HttpGet("{id:guid}")]
[ProducesResponseType(typeof(object), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById(Guid id)
{
var conversation = await _repository.GetByIdAsync(id);
if (conversation == null)
{
return NotFound(new { success = false, error = "Conversation not found" });
}
return Ok(new
{
success = true,
data = new
{
id = conversation.Id,
customerWaId = conversation.CustomerWaId,
status = conversation.Status.Name,
assignedAgentId = conversation.AssignedAgentId,
lastMessageAt = conversation.LastMessageAt,
createdAt = conversation.CreatedAt,
expiresAt = conversation.ExpiresAt,
messages = conversation.Messages.OrderBy(m => m.Timestamp).Select(m => new
{
id = m.Id,
direction = m.Direction,
contentType = m.Content.Type,
text = m.Content.Text,
status = m.Status,
timestamp = m.Timestamp
})
}
});
}
/// <summary>
/// EN: Send a message in conversation.
/// VI: Gửi tin nhắn trong conversation.
/// </summary>
[HttpPost("{id:guid}/messages")]
[ProducesResponseType(typeof(object), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> SendMessage(Guid id, [FromBody] SendMessageRequest request)
{
var command = new SendMessageCommand(
id,
request.Type,
request.Text,
request.MediaUrl,
request.Caption,
request.Interactive);
var result = await _mediator.Send(command);
if (!result.Success)
{
return BadRequest(new { success = false, error = result.Error });
}
return Ok(new
{
success = true,
data = new
{
messageId = result.MessageId,
whatsAppMessageId = result.WhatsAppMessageId
}
});
}
/// <summary>
/// EN: Close a conversation.
/// VI: Đóng conversation.
/// </summary>
[HttpPost("{id:guid}/close")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Close(Guid id)
{
var conversation = await _repository.GetByIdAsync(id);
if (conversation == null)
{
return NotFound(new { success = false, error = "Conversation not found" });
}
conversation.Close();
await _repository.UnitOfWork.SaveEntitiesAsync();
return NoContent();
}
}
public record SendMessageRequest(
string Type,
string? Text,
string? MediaUrl,
string? Caption,
object? Interactive);

View File

@@ -0,0 +1,173 @@
using MediatR;
using Microsoft.AspNetCore.Mvc;
using WhatsAppService.Domain.AggregatesModel.CustomerAggregate;
namespace WhatsAppService.API.Controllers;
/// <summary>
/// EN: Controller for managing customers.
/// VI: Controller để quản lý khách hàng.
/// </summary>
[ApiController]
[Route("api/[controller]")]
[Produces("application/json")]
public class CustomersController : ControllerBase
{
private readonly ICustomerRepository _repository;
private readonly ILogger<CustomersController> _logger;
public CustomersController(
ICustomerRepository repository,
ILogger<CustomersController> logger)
{
_repository = repository;
_logger = logger;
}
/// <summary>
/// EN: Get customers by shop with pagination.
/// VI: Lấy khách hàng theo shop với phân trang.
/// </summary>
[HttpGet]
[ProducesResponseType(typeof(object), StatusCodes.Status200OK)]
public async Task<IActionResult> GetCustomers(
[FromQuery] Guid shopId,
[FromQuery] int skip = 0,
[FromQuery] int take = 20)
{
var customers = await _repository.GetByShopIdAsync(shopId, skip, take);
return Ok(new
{
success = true,
data = customers.Select(c => new
{
id = c.Id,
waId = c.WaId,
name = c.Name,
profilePictureUrl = c.ProfilePictureUrl,
optInStatus = c.Consent.Status,
tags = c.Tags,
firstContactedAt = c.FirstContactedAt,
createdAt = c.CreatedAt
})
});
}
/// <summary>
/// EN: Get customer by ID.
/// VI: Lấy khách hàng theo ID.
/// </summary>
[HttpGet("{id:guid}")]
[ProducesResponseType(typeof(object), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById(Guid id)
{
var customer = await _repository.GetByIdAsync(id);
if (customer == null)
{
return NotFound(new { success = false, error = "Customer not found" });
}
return Ok(new
{
success = true,
data = new
{
id = customer.Id,
waId = customer.WaId,
name = customer.Name,
profilePictureUrl = customer.ProfilePictureUrl,
optInStatus = customer.Consent.Status,
optInTimestamp = customer.Consent.Timestamp,
optInSource = customer.Consent.Source,
tags = customer.Tags,
customFields = customer.CustomFields,
firstContactedAt = customer.FirstContactedAt,
createdAt = customer.CreatedAt
}
});
}
/// <summary>
/// EN: Update customer profile.
/// VI: Cập nhật hồ sơ khách hàng.
/// </summary>
[HttpPatch("{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateCustomerRequest request)
{
var customer = await _repository.GetByIdAsync(id);
if (customer == null)
{
return NotFound(new { success = false, error = "Customer not found" });
}
if (!string.IsNullOrEmpty(request.Name))
{
customer.UpdateName(request.Name);
}
if (request.Tags != null)
{
// Clear and re-add tags
foreach (var tag in customer.Tags.ToList())
{
customer.RemoveTag(tag);
}
foreach (var tag in request.Tags)
{
customer.AddTag(tag);
}
}
if (request.CustomFields != null)
{
foreach (var field in request.CustomFields)
{
customer.SetCustomField(field.Key, field.Value);
}
}
await _repository.UnitOfWork.SaveEntitiesAsync();
return NoContent();
}
/// <summary>
/// EN: Update customer opt-in consent.
/// VI: Cập nhật đồng ý opt-in của khách hàng.
/// </summary>
[HttpPost("{id:guid}/consent")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> UpdateConsent(Guid id, [FromBody] UpdateConsentRequest request)
{
var customer = await _repository.GetByIdAsync(id);
if (customer == null)
{
return NotFound(new { success = false, error = "Customer not found" });
}
if (request.OptIn)
{
customer.OptIn(request.Source ?? "api");
}
else
{
customer.OptOut(request.Source ?? "api");
}
await _repository.UnitOfWork.SaveEntitiesAsync();
return NoContent();
}
}
public record UpdateCustomerRequest(
string? Name,
List<string>? Tags,
Dictionary<string, string>? CustomFields);
public record UpdateConsentRequest(
bool OptIn,
string? Source);

View File

@@ -0,0 +1,188 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using WhatsAppService.API.Application.Commands;
namespace WhatsAppService.API.Controllers;
/// <summary>
/// EN: Controller for handling WhatsApp webhooks.
/// VI: Controller để xử lý webhooks WhatsApp.
/// </summary>
[ApiController]
[Route("api/webhooks")]
public class WebhooksController : ControllerBase
{
private readonly IMediator _mediator;
private readonly ILogger<WebhooksController> _logger;
private readonly IConfiguration _configuration;
public WebhooksController(
IMediator mediator,
ILogger<WebhooksController> logger,
IConfiguration configuration)
{
_mediator = mediator;
_logger = logger;
_configuration = configuration;
}
/// <summary>
/// EN: Webhook verification endpoint (GET).
/// VI: Endpoint xác minh webhook (GET).
/// </summary>
[HttpGet("whatsapp")]
public IActionResult VerifyWebhook(
[FromQuery(Name = "hub.mode")] string? mode,
[FromQuery(Name = "hub.verify_token")] string? verifyToken,
[FromQuery(Name = "hub.challenge")] string? challenge)
{
_logger.LogInformation("Webhook verification request. Mode: {Mode}", mode);
if (mode != "subscribe")
{
return Forbid();
}
var expectedToken = _configuration["WhatsApp:WebhookVerifyToken"];
if (verifyToken != expectedToken)
{
_logger.LogWarning("Invalid verify token received");
return Forbid();
}
_logger.LogInformation("Webhook verified successfully");
return Ok(challenge);
}
/// <summary>
/// EN: Webhook event handler (POST).
/// VI: Handler sự kiện webhook (POST).
/// </summary>
[HttpPost("whatsapp")]
public async Task<IActionResult> HandleWebhook(
[FromHeader(Name = "X-Hub-Signature-256")] string? signature,
[FromBody] JsonElement payload)
{
// EN: Verify signature
// VI: Xác minh chữ ký
var appSecret = _configuration["WhatsApp:AppSecret"];
if (!string.IsNullOrEmpty(appSecret) && !string.IsNullOrEmpty(signature))
{
var payloadBytes = Encoding.UTF8.GetBytes(payload.GetRawText());
var expectedSignature = ComputeHmacSha256(payloadBytes, appSecret);
if (!$"sha256={expectedSignature}".Equals(signature, StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("Invalid webhook signature");
return Unauthorized();
}
}
// EN: Return 200 immediately, process async
// VI: Trả về 200 ngay lập tức, xử lý bất đồng bộ
_ = Task.Run(async () => await ProcessWebhookAsync(payload));
return Ok();
}
private async Task ProcessWebhookAsync(JsonElement payload)
{
try
{
var objectType = payload.GetProperty("object").GetString();
if (objectType != "whatsapp_business_account")
{
return;
}
var entries = payload.GetProperty("entry");
foreach (var entry in entries.EnumerateArray())
{
var changes = entry.GetProperty("changes");
foreach (var change in changes.EnumerateArray())
{
var field = change.GetProperty("field").GetString();
if (field != "messages")
{
continue;
}
var value = change.GetProperty("value");
await ProcessMessagesAsync(value);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing webhook payload");
}
}
private async Task ProcessMessagesAsync(JsonElement value)
{
if (!value.TryGetProperty("messages", out var messages))
{
return;
}
var metadata = value.GetProperty("metadata");
var phoneNumberId = metadata.GetProperty("phone_number_id").GetString() ?? "";
foreach (var message in messages.EnumerateArray())
{
var from = message.GetProperty("from").GetString() ?? "";
var messageId = message.GetProperty("id").GetString() ?? "";
var timestamp = message.GetProperty("timestamp").GetString() ?? "";
var type = message.GetProperty("type").GetString() ?? "text";
string? textBody = null;
string? mediaId = null;
string? mimeType = null;
object? interactive = null;
if (type == "text" && message.TryGetProperty("text", out var text))
{
textBody = text.GetProperty("body").GetString();
}
else if (type is "image" or "video" or "audio" or "document")
{
if (message.TryGetProperty(type, out var media))
{
mediaId = media.TryGetProperty("id", out var id) ? id.GetString() : null;
mimeType = media.TryGetProperty("mime_type", out var mime) ? mime.GetString() : null;
}
}
else if (type == "interactive" && message.TryGetProperty("interactive", out var interactiveEl))
{
interactive = JsonSerializer.Deserialize<object>(interactiveEl.GetRawText());
}
var command = new ProcessIncomingMessageCommand(
phoneNumberId,
from,
messageId,
type,
textBody,
mediaId,
mimeType,
interactive,
DateTimeOffset.FromUnixTimeSeconds(long.Parse(timestamp)).UtcDateTime
);
var result = await _mediator.Send(command);
_logger.LogInformation(
"Message processed. Success: {Success}, ConversationId: {ConversationId}",
result.Success, result.ConversationId);
}
}
private static string ComputeHmacSha256(byte[] data, string key)
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(key));
var hash = hmac.ComputeHash(data);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,113 @@
using MediatR;
using Microsoft.AspNetCore.Mvc;
using WhatsAppService.API.Application.Commands;
using WhatsAppService.Domain.AggregatesModel.WhatsAppAccountAggregate;
namespace WhatsAppService.API.Controllers;
/// <summary>
/// EN: Controller for managing WhatsApp accounts.
/// VI: Controller để quản lý WhatsApp accounts.
/// </summary>
[ApiController]
[Route("api/[controller]")]
[Produces("application/json")]
public class WhatsAppAccountsController : ControllerBase
{
private readonly IMediator _mediator;
private readonly IWhatsAppAccountRepository _repository;
private readonly ILogger<WhatsAppAccountsController> _logger;
public WhatsAppAccountsController(
IMediator mediator,
IWhatsAppAccountRepository repository,
ILogger<WhatsAppAccountsController> logger)
{
_mediator = mediator;
_repository = repository;
_logger = logger;
}
/// <summary>
/// EN: Connect a WhatsApp Business Account.
/// VI: Kết nối WhatsApp Business Account.
/// </summary>
[HttpPost]
[ProducesResponseType(typeof(object), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> Connect([FromBody] ConnectAccountRequest request)
{
var command = new ConnectWhatsAppAccountCommand(
request.ShopId,
request.PhoneNumberId,
request.AccessToken,
request.WebhookUrl);
var result = await _mediator.Send(command);
if (!result.Success)
{
return BadRequest(new { success = false, error = result.Error });
}
return Ok(new { success = true, data = new { accountId = result.AccountId } });
}
/// <summary>
/// EN: Get WhatsApp account by shop ID.
/// VI: Lấy WhatsApp account theo shop ID.
/// </summary>
[HttpGet("shop/{shopId:guid}")]
[ProducesResponseType(typeof(object), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetByShopId(Guid shopId)
{
var account = await _repository.GetByShopIdAsync(shopId);
if (account == null)
{
return NotFound(new { success = false, error = "Account not found" });
}
return Ok(new
{
success = true,
data = new
{
id = account.Id,
shopId = account.ShopId,
phoneNumberId = account.PhoneNumberId,
status = account.Status.Name,
messageTier = account.MessageTier,
webhookUrl = account.WebhookUrl,
createdAt = account.CreatedAt
}
});
}
/// <summary>
/// EN: Disconnect WhatsApp account.
/// VI: Ngắt kết nối WhatsApp account.
/// </summary>
[HttpDelete("{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Disconnect(Guid id)
{
var account = await _repository.GetByIdAsync(id);
if (account == null)
{
return NotFound(new { success = false, error = "Account not found" });
}
account.Disconnect();
await _repository.UnitOfWork.SaveEntitiesAsync();
return NoContent();
}
}
public record ConnectAccountRequest(
Guid ShopId,
string PhoneNumberId,
string AccessToken,
string? WebhookUrl);

View File

@@ -0,0 +1,50 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using WhatsAppService.Domain.AggregatesModel.AIAgentAggregate;
namespace WhatsAppService.Infrastructure.EntityConfigurations;
/// <summary>
/// EN: EF Core configuration for AIAgent entity.
/// VI: Cấu hình EF Core cho entity AIAgent.
/// </summary>
public class AIAgentEntityTypeConfiguration : IEntityTypeConfiguration<AIAgent>
{
public void Configure(EntityTypeBuilder<AIAgent> builder)
{
builder.ToTable("ai_agents");
builder.HasKey(e => e.Id);
builder.Ignore(e => e.DomainEvents);
builder.Property(e => e.Id).HasColumnName("id").IsRequired();
builder.Property<Guid>("_shopId").HasColumnName("shop_id").IsRequired();
builder.Property<string>("_agentName").HasColumnName("agent_name").HasMaxLength(200).IsRequired();
builder.Property<Guid?>("_knowledgeBaseId").HasColumnName("knowledge_base_id");
builder.Property<bool>("_isActive").HasColumnName("is_active").HasDefaultValue(false);
builder.Property<int?>("_maxPromptTokens").HasColumnName("max_prompt_tokens");
builder.Property<int?>("_maxCompletionTokens").HasColumnName("max_completion_tokens");
builder.Property<decimal?>("_dailyBudgetUsd").HasColumnName("daily_budget_usd").HasPrecision(10, 2);
builder.Property<decimal?>("_monthlyBudgetUsd").HasColumnName("monthly_budget_usd").HasPrecision(10, 2);
builder.Property<DateTime>("_createdAt").HasColumnName("created_at").IsRequired();
builder.Property<DateTime?>("_updatedAt").HasColumnName("updated_at");
// EN: AgentPersonality as owned entity / VI: AgentPersonality như owned entity
builder.OwnsOne<AgentPersonality>("_personality", personality =>
{
personality.Property(p => p.Tone).HasColumnName("personality_tone").HasMaxLength(50).HasDefaultValue("friendly");
personality.Property(p => p.Language).HasColumnName("personality_language").HasMaxLength(20).HasDefaultValue("vietnamese");
personality.Property(p => p.PromptTemplate).HasColumnName("prompt_template");
// EN: Constraints stored as JSONB / VI: Constraints lưu dưới dạng JSONB
personality.Property<string>("ConstraintsJson")
.HasColumnName("personality_constraints")
.HasColumnType("jsonb")
.HasDefaultValueSql("'[]'::jsonb");
personality.Ignore(p => p.Constraints);
});
builder.HasIndex("_shopId").HasDatabaseName("idx_ai_agents_shop");
builder.HasIndex("_shopId", "_isActive").HasDatabaseName("idx_ai_agents_shop_active");
}
}

View File

@@ -0,0 +1,82 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using WhatsAppService.Domain.AggregatesModel.AutomationFlowAggregate;
namespace WhatsAppService.Infrastructure.EntityConfigurations;
/// <summary>
/// EN: EF Core configuration for AutomationFlow entity.
/// VI: Cấu hình EF Core cho entity AutomationFlow.
/// </summary>
public class AutomationFlowEntityTypeConfiguration : IEntityTypeConfiguration<AutomationFlow>
{
public void Configure(EntityTypeBuilder<AutomationFlow> builder)
{
builder.ToTable("automation_flows");
builder.HasKey(e => e.Id);
builder.Ignore(e => e.DomainEvents);
builder.Property(e => e.Id).HasColumnName("id").IsRequired();
builder.Property<Guid>("_shopId").HasColumnName("shop_id").IsRequired();
builder.Property<string>("_flowName").HasColumnName("flow_name").HasMaxLength(200).IsRequired();
builder.Property<string>("_triggerConfig").HasColumnName("trigger_config").HasColumnType("jsonb").IsRequired();
builder.Property<bool>("_isActive").HasColumnName("is_active").HasDefaultValue(false);
builder.Property<int>("_priority").HasColumnName("priority").HasDefaultValue(50);
builder.Property<int>("_executionCount").HasColumnName("execution_count").HasDefaultValue(0);
builder.Property<DateTime>("_createdAt").HasColumnName("created_at").IsRequired();
builder.Property<DateTime?>("_updatedAt").HasColumnName("updated_at");
builder.Property(e => e.TriggerTypeId).HasColumnName("trigger_type_id").IsRequired();
builder.HasOne(e => e.TriggerType).WithMany().HasForeignKey(e => e.TriggerTypeId).OnDelete(DeleteBehavior.Restrict);
var stepsNav = builder.Metadata.FindNavigation(nameof(AutomationFlow.Steps))!;
stepsNav.SetPropertyAccessMode(PropertyAccessMode.Field);
builder.HasIndex("_shopId", "_isActive").HasDatabaseName("idx_flows_shop_active");
builder.HasIndex(e => e.TriggerTypeId).HasDatabaseName("idx_flows_trigger");
}
}
/// <summary>
/// EN: EF Core configuration for FlowStep entity.
/// VI: Cấu hình EF Core cho entity FlowStep.
/// </summary>
public class FlowStepEntityTypeConfiguration : IEntityTypeConfiguration<FlowStep>
{
public void Configure(EntityTypeBuilder<FlowStep> builder)
{
builder.ToTable("flow_steps");
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).HasColumnName("id").IsRequired();
builder.Property<Guid>("_flowId").HasColumnName("flow_id").IsRequired();
builder.Property<int>("_order").HasColumnName("step_order").IsRequired();
builder.Property<string>("_action").HasColumnName("action").HasMaxLength(50).IsRequired();
builder.Property<string>("_actionConfig").HasColumnName("action_config").HasColumnType("jsonb").IsRequired();
builder.Property<string?>("_conditions").HasColumnName("conditions").HasColumnType("jsonb");
builder.Property<string?>("_nextStepMapping").HasColumnName("next_step_mapping").HasColumnType("jsonb");
builder.HasIndex("_flowId", "_order").IsUnique().HasDatabaseName("idx_flow_steps_order");
}
}
/// <summary>
/// EN: EF Core configuration for TriggerType enumeration.
/// VI: Cấu hình EF Core cho enumeration TriggerType.
/// </summary>
public class TriggerTypeEntityTypeConfiguration : IEntityTypeConfiguration<TriggerType>
{
public void Configure(EntityTypeBuilder<TriggerType> builder)
{
builder.ToTable("trigger_types");
builder.HasKey(s => s.Id);
builder.Property(s => s.Id).HasColumnName("id").ValueGeneratedNever().IsRequired();
builder.Property(s => s.Name).HasColumnName("name").HasMaxLength(50).IsRequired();
builder.HasData(
TriggerType.Keyword,
TriggerType.Event,
TriggerType.Schedule
);
}
}

View File

@@ -0,0 +1,99 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using WhatsAppService.Domain.AggregatesModel.ConversationAggregate;
namespace WhatsAppService.Infrastructure.EntityConfigurations;
/// <summary>
/// EN: EF Core configuration for Conversation entity.
/// VI: Cấu hình EF Core cho entity Conversation.
/// </summary>
public class ConversationEntityTypeConfiguration : IEntityTypeConfiguration<Conversation>
{
public void Configure(EntityTypeBuilder<Conversation> builder)
{
builder.ToTable("conversations");
builder.HasKey(e => e.Id);
builder.Ignore(e => e.DomainEvents);
builder.Property(e => e.Id).HasColumnName("id").IsRequired();
builder.Property<Guid>("_shopId").HasColumnName("shop_id").IsRequired();
builder.Property<string>("_customerWaId").HasColumnName("customer_wa_id").HasMaxLength(20).IsRequired();
builder.Property<Guid>("_whatsAppAccountId").HasColumnName("whatsapp_account_id").IsRequired();
builder.Property<Guid?>("_assignedAgentId").HasColumnName("assigned_agent_id");
builder.Property<DateTime?>("_lastMessageAt").HasColumnName("last_message_at");
builder.Property<DateTime>("_createdAt").HasColumnName("created_at").IsRequired();
builder.Property<DateTime?>("_expiresAt").HasColumnName("expires_at");
builder.Property<List<string>>("_tags")
.HasColumnName("tags")
.HasColumnType("jsonb")
.HasDefaultValueSql("'[]'::jsonb");
builder.Property(e => e.StatusId).HasColumnName("status_id").IsRequired();
builder.HasOne(e => e.Status).WithMany().HasForeignKey(e => e.StatusId).OnDelete(DeleteBehavior.Restrict);
// EN: Messages as owned collection / VI: Messages như collection sở hữu
var messagesNav = builder.Metadata.FindNavigation(nameof(Conversation.Messages))!;
messagesNav.SetPropertyAccessMode(PropertyAccessMode.Field);
builder.HasIndex("_shopId", "StatusId").HasDatabaseName("idx_conversations_shop_status");
builder.HasIndex("_customerWaId").HasDatabaseName("idx_conversations_customer");
builder.HasIndex("_expiresAt").HasDatabaseName("idx_conversations_expires");
}
}
/// <summary>
/// EN: EF Core configuration for Message entity.
/// VI: Cấu hình EF Core cho entity Message.
/// </summary>
public class MessageEntityTypeConfiguration : IEntityTypeConfiguration<Message>
{
public void Configure(EntityTypeBuilder<Message> builder)
{
builder.ToTable("messages");
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).HasColumnName("id").IsRequired();
builder.Property<Guid>("_conversationId").HasColumnName("conversation_id").IsRequired();
builder.Property<string?>("_whatsAppMessageId").HasColumnName("whatsapp_message_id").HasMaxLength(100);
builder.Property<string>("_direction").HasColumnName("direction").HasMaxLength(10).IsRequired();
builder.Property<string>("_status").HasColumnName("status").HasMaxLength(20).HasDefaultValue("sent");
builder.Property<DateTime>("_timestamp").HasColumnName("timestamp").IsRequired();
// EN: MessageContent as owned entity / VI: MessageContent như owned entity
builder.OwnsOne<MessageContent>("_content", content =>
{
content.Property(c => c.Type).HasColumnName("content_type").HasMaxLength(20).IsRequired();
content.Property(c => c.Text).HasColumnName("content_text");
content.Property(c => c.MediaUrl).HasColumnName("media_url").HasMaxLength(500);
content.Property(c => c.Caption).HasColumnName("caption").HasMaxLength(1000);
content.Property(c => c.MimeType).HasColumnName("mime_type").HasMaxLength(100);
content.Property(c => c.InteractiveJson).HasColumnName("interactive_json").HasColumnType("jsonb");
});
builder.HasIndex("_conversationId", "_timestamp").HasDatabaseName("idx_messages_conversation_timestamp");
builder.HasIndex("_whatsAppMessageId").IsUnique().HasDatabaseName("idx_messages_wa_id");
}
}
/// <summary>
/// EN: EF Core configuration for ConversationStatus enumeration.
/// VI: Cấu hình EF Core cho enumeration ConversationStatus.
/// </summary>
public class ConversationStatusEntityTypeConfiguration : IEntityTypeConfiguration<ConversationStatus>
{
public void Configure(EntityTypeBuilder<ConversationStatus> builder)
{
builder.ToTable("conversation_statuses");
builder.HasKey(s => s.Id);
builder.Property(s => s.Id).HasColumnName("id").ValueGeneratedNever().IsRequired();
builder.Property(s => s.Name).HasColumnName("name").HasMaxLength(50).IsRequired();
builder.HasData(
ConversationStatus.Active,
ConversationStatus.Closed,
ConversationStatus.Expired
);
}
}

View File

@@ -0,0 +1,51 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using WhatsAppService.Domain.AggregatesModel.CustomerAggregate;
namespace WhatsAppService.Infrastructure.EntityConfigurations;
/// <summary>
/// EN: EF Core configuration for Customer entity.
/// VI: Cấu hình EF Core cho entity Customer.
/// </summary>
public class CustomerEntityTypeConfiguration : IEntityTypeConfiguration<Customer>
{
public void Configure(EntityTypeBuilder<Customer> builder)
{
builder.ToTable("customers");
builder.HasKey(e => e.Id);
builder.Ignore(e => e.DomainEvents);
builder.Property(e => e.Id).HasColumnName("id").IsRequired();
builder.Property<Guid>("_shopId").HasColumnName("shop_id").IsRequired();
builder.Property<string>("_waId").HasColumnName("wa_id").HasMaxLength(20).IsRequired();
builder.Property<string?>("_name").HasColumnName("name").HasMaxLength(200);
builder.Property<string?>("_profilePictureUrl").HasColumnName("profile_picture_url").HasMaxLength(500);
builder.Property<DateTime>("_firstContactedAt").HasColumnName("first_contacted_at").IsRequired();
builder.Property<DateTime>("_createdAt").HasColumnName("created_at").IsRequired();
builder.Property<DateTime?>("_updatedAt").HasColumnName("updated_at");
// EN: Tags stored as JSONB / VI: Tags lưu dưới dạng JSONB
builder.Property<List<string>>("_tags")
.HasColumnName("tags")
.HasColumnType("jsonb")
.HasDefaultValueSql("'[]'::jsonb");
// EN: Custom fields stored as JSONB / VI: Custom fields lưu dưới dạng JSONB
builder.Property<Dictionary<string, string>>("_customFields")
.HasColumnName("custom_fields")
.HasColumnType("jsonb")
.HasDefaultValueSql("'{}'::jsonb");
// EN: OptInConsent as owned entity / VI: OptInConsent như owned entity
builder.OwnsOne<OptInConsent>("_consent", consent =>
{
consent.Property(c => c.Status).HasColumnName("opt_in_status").HasMaxLength(20).HasDefaultValue("pending");
consent.Property(c => c.Timestamp).HasColumnName("opt_in_timestamp");
consent.Property(c => c.Source).HasColumnName("opt_in_source").HasMaxLength(50);
});
builder.HasIndex("_shopId", "_waId").IsUnique().HasDatabaseName("idx_customers_shop_waid");
builder.HasIndex("_shopId").HasDatabaseName("idx_customers_shop");
}
}

View File

@@ -0,0 +1,57 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using WhatsAppService.Domain.AggregatesModel.WhatsAppAccountAggregate;
namespace WhatsAppService.Infrastructure.EntityConfigurations;
/// <summary>
/// EN: EF Core configuration for WhatsAppAccount entity.
/// VI: Cấu hình EF Core cho entity WhatsAppAccount.
/// </summary>
public class WhatsAppAccountEntityTypeConfiguration : IEntityTypeConfiguration<WhatsAppAccount>
{
public void Configure(EntityTypeBuilder<WhatsAppAccount> builder)
{
builder.ToTable("whatsapp_accounts");
builder.HasKey(e => e.Id);
builder.Ignore(e => e.DomainEvents);
builder.Property(e => e.Id).HasColumnName("id").IsRequired();
builder.Property<Guid>("_shopId").HasColumnName("shop_id").IsRequired();
builder.Property<string>("_phoneNumberId").HasColumnName("phone_number_id").HasMaxLength(50).IsRequired();
builder.Property<string>("_accessTokenEncrypted").HasColumnName("access_token_encrypted").IsRequired();
builder.Property<string?>("_webhookUrl").HasColumnName("webhook_url").HasMaxLength(500);
builder.Property<string?>("_webhookVerifyToken").HasColumnName("webhook_verify_token").HasMaxLength(100);
builder.Property<string>("_messageTier").HasColumnName("message_tier").HasMaxLength(20).HasDefaultValue("tier_1");
builder.Property<DateTime>("_createdAt").HasColumnName("created_at").IsRequired();
builder.Property<DateTime?>("_updatedAt").HasColumnName("updated_at");
builder.Property(e => e.StatusId).HasColumnName("status_id").IsRequired();
builder.HasOne(e => e.Status).WithMany().HasForeignKey(e => e.StatusId).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex("_shopId").HasDatabaseName("idx_whatsapp_accounts_shop");
builder.HasIndex("_phoneNumberId").IsUnique().HasDatabaseName("idx_whatsapp_accounts_phone");
}
}
/// <summary>
/// EN: EF Core configuration for WhatsAppAccountStatus enumeration.
/// VI: Cấu hình EF Core cho enumeration WhatsAppAccountStatus.
/// </summary>
public class WhatsAppAccountStatusEntityTypeConfiguration : IEntityTypeConfiguration<WhatsAppAccountStatus>
{
public void Configure(EntityTypeBuilder<WhatsAppAccountStatus> builder)
{
builder.ToTable("whatsapp_account_statuses");
builder.HasKey(s => s.Id);
builder.Property(s => s.Id).HasColumnName("id").ValueGeneratedNever().IsRequired();
builder.Property(s => s.Name).HasColumnName("name").HasMaxLength(50).IsRequired();
builder.HasData(
WhatsAppAccountStatus.Pending,
WhatsAppAccountStatus.Active,
WhatsAppAccountStatus.Suspended,
WhatsAppAccountStatus.Disconnected
);
}
}

View File

@@ -0,0 +1,64 @@
namespace WhatsAppService.Infrastructure.ExternalServices;
/// <summary>
/// EN: Interface for LLM (Large Language Model) service.
/// VI: Interface cho dịch vụ LLM (Large Language Model).
/// </summary>
public interface ILlmService
{
/// <summary>
/// EN: Generate a response based on conversation context.
/// VI: Tạo phản hồi dựa trên ngữ cảnh hội thoại.
/// </summary>
Task<LlmResponse> GenerateResponseAsync(
LlmRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// EN: Extract intent from user message.
/// VI: Trích xuất ý định từ tin nhắn người dùng.
/// </summary>
Task<IntentResult> ExtractIntentAsync(
string message,
string[] possibleIntents,
CancellationToken cancellationToken = default);
}
/// <summary>
/// EN: LLM request with conversation context.
/// VI: Yêu cầu LLM với ngữ cảnh hội thoại.
/// </summary>
public record LlmRequest(
string SystemPrompt,
List<ConversationMessage> Messages,
int? MaxTokens = null,
double? Temperature = null);
/// <summary>
/// EN: Message in conversation context.
/// VI: Tin nhắn trong ngữ cảnh hội thoại.
/// </summary>
public record ConversationMessage(
string Role, // "user", "assistant", "system"
string Content);
/// <summary>
/// EN: LLM response with usage stats.
/// VI: Phản hồi LLM với thống kê sử dụng.
/// </summary>
public record LlmResponse(
bool Success,
string? Content,
int PromptTokens,
int CompletionTokens,
string? Error);
/// <summary>
/// EN: Intent extraction result.
/// VI: Kết quả trích xuất ý định.
/// </summary>
public record IntentResult(
bool Success,
string? Intent,
double Confidence,
string? Error);

View File

@@ -0,0 +1,85 @@
namespace WhatsAppService.Infrastructure.ExternalServices;
/// <summary>
/// EN: Interface for WhatsApp Cloud API client.
/// VI: Interface cho WhatsApp Cloud API client.
/// </summary>
public interface IWhatsAppCloudApiClient
{
/// <summary>
/// EN: Send a text message.
/// VI: Gửi tin nhắn văn bản.
/// </summary>
Task<SendMessageResult> SendTextMessageAsync(
string phoneNumberId,
string accessToken,
string recipientWaId,
string text,
CancellationToken cancellationToken = default);
/// <summary>
/// EN: Send a template message (for outside 24h window).
/// VI: Gửi tin nhắn template (cho ngoài cửa sổ 24h).
/// </summary>
Task<SendMessageResult> SendTemplateMessageAsync(
string phoneNumberId,
string accessToken,
string recipientWaId,
string templateName,
string languageCode,
object[]? components = null,
CancellationToken cancellationToken = default);
/// <summary>
/// EN: Send a media message (image, video, document, audio).
/// VI: Gửi tin nhắn media (ảnh, video, tài liệu, audio).
/// </summary>
Task<SendMessageResult> SendMediaMessageAsync(
string phoneNumberId,
string accessToken,
string recipientWaId,
string mediaType,
string mediaUrl,
string? caption = null,
CancellationToken cancellationToken = default);
/// <summary>
/// EN: Send an interactive message (buttons, list).
/// VI: Gửi tin nhắn interactive (buttons, list).
/// </summary>
Task<SendMessageResult> SendInteractiveMessageAsync(
string phoneNumberId,
string accessToken,
string recipientWaId,
object interactiveContent,
CancellationToken cancellationToken = default);
/// <summary>
/// EN: Mark message as read.
/// VI: Đánh dấu tin nhắn đã đọc.
/// </summary>
Task<bool> MarkMessageAsReadAsync(
string phoneNumberId,
string accessToken,
string messageId,
CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get media URL by media ID.
/// VI: Lấy URL media theo media ID.
/// </summary>
Task<string?> GetMediaUrlAsync(
string accessToken,
string mediaId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// EN: Result of sending a message.
/// VI: Kết quả gửi tin nhắn.
/// </summary>
public record SendMessageResult(
bool Success,
string? MessageId,
string? ErrorCode,
string? ErrorMessage);

View File

@@ -0,0 +1,152 @@
using System.ClientModel;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using OpenAI;
using OpenAI.Chat;
namespace WhatsAppService.Infrastructure.ExternalServices;
/// <summary>
/// EN: OpenAI LLM service implementation.
/// VI: Triển khai dịch vụ OpenAI LLM.
/// </summary>
public class OpenAILlmService : ILlmService
{
private readonly ChatClient _chatClient;
private readonly ILogger<OpenAILlmService> _logger;
private readonly OpenAISettings _settings;
public OpenAILlmService(
IOptions<OpenAISettings> settings,
ILogger<OpenAILlmService> logger)
{
_settings = settings.Value;
_logger = logger;
var apiKey = _settings.ApiKey ?? throw new InvalidOperationException("OpenAI API key is not configured");
var client = new OpenAIClient(apiKey);
_chatClient = client.GetChatClient(_settings.Model ?? "gpt-4o-mini");
}
public async Task<LlmResponse> GenerateResponseAsync(
LlmRequest request,
CancellationToken cancellationToken = default)
{
try
{
var messages = new List<ChatMessage>();
// Add system prompt
if (!string.IsNullOrEmpty(request.SystemPrompt))
{
messages.Add(ChatMessage.CreateSystemMessage(request.SystemPrompt));
}
// Add conversation history
foreach (var msg in request.Messages)
{
messages.Add(msg.Role.ToLowerInvariant() switch
{
"user" => ChatMessage.CreateUserMessage(msg.Content),
"assistant" => ChatMessage.CreateAssistantMessage(msg.Content),
"system" => ChatMessage.CreateSystemMessage(msg.Content),
_ => ChatMessage.CreateUserMessage(msg.Content)
});
}
var options = new ChatCompletionOptions
{
MaxOutputTokenCount = request.MaxTokens ?? _settings.MaxTokens ?? 500,
Temperature = (float?)(request.Temperature ?? _settings.Temperature ?? 0.7)
};
_logger.LogDebug("Sending request to OpenAI with {MessageCount} messages", messages.Count);
var response = await _chatClient.CompleteChatAsync(messages, options, cancellationToken);
var completion = response.Value;
var content = completion.Content.FirstOrDefault()?.Text;
var usage = completion.Usage;
_logger.LogInformation(
"OpenAI response received. Prompt tokens: {PromptTokens}, Completion tokens: {CompletionTokens}",
usage.InputTokenCount, usage.OutputTokenCount);
return new LlmResponse(
Success: true,
Content: content,
PromptTokens: usage.InputTokenCount,
CompletionTokens: usage.OutputTokenCount,
Error: null);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to generate response from OpenAI");
return new LlmResponse(
Success: false,
Content: null,
PromptTokens: 0,
CompletionTokens: 0,
Error: ex.Message);
}
}
public async Task<IntentResult> ExtractIntentAsync(
string message,
string[] possibleIntents,
CancellationToken cancellationToken = default)
{
try
{
var intentsStr = string.Join(", ", possibleIntents);
var systemPrompt = $"You are an intent classifier. Analyze the user message and classify it into one of these intents: {intentsStr}. Respond with JSON only in this format: {{\"intent\": \"intent_name\", \"confidence\": 0.0-1.0}}";
var messages = new List<ChatMessage>
{
ChatMessage.CreateSystemMessage(systemPrompt),
ChatMessage.CreateUserMessage(message)
};
var options = new ChatCompletionOptions
{
MaxOutputTokenCount = 100,
Temperature = 0.1f
};
var response = await _chatClient.CompleteChatAsync(messages, options, cancellationToken);
var content = response.Value.Content.FirstOrDefault()?.Text ?? "{}";
var result = JsonSerializer.Deserialize<IntentExtraction>(content);
return new IntentResult(
Success: true,
Intent: result?.Intent,
Confidence: result?.Confidence ?? 0,
Error: null);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to extract intent");
return new IntentResult(
Success: false,
Intent: null,
Confidence: 0,
Error: ex.Message);
}
}
private record IntentExtraction(string? Intent, double Confidence);
}
/// <summary>
/// EN: OpenAI configuration settings.
/// VI: Cấu hình OpenAI.
/// </summary>
public class OpenAISettings
{
public string? ApiKey { get; set; }
public string? Model { get; set; } = "gpt-4o-mini";
public int? MaxTokens { get; set; } = 500;
public double? Temperature { get; set; } = 0.7;
}

View File

@@ -0,0 +1,238 @@
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
namespace WhatsAppService.Infrastructure.ExternalServices;
/// <summary>
/// EN: WhatsApp Cloud API client implementation with resilience.
/// VI: Triển khai WhatsApp Cloud API client với khả năng phục hồi.
/// </summary>
public class WhatsAppCloudApiClient : IWhatsAppCloudApiClient
{
private readonly HttpClient _httpClient;
private readonly ILogger<WhatsAppCloudApiClient> _logger;
private readonly JsonSerializerOptions _jsonOptions;
private const string BaseUrl = "https://graph.facebook.com";
private const string ApiVersion = "v18.0";
public WhatsAppCloudApiClient(HttpClient httpClient, ILogger<WhatsAppCloudApiClient> logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
}
public async Task<SendMessageResult> SendTextMessageAsync(
string phoneNumberId,
string accessToken,
string recipientWaId,
string text,
CancellationToken cancellationToken = default)
{
var payload = new
{
messaging_product = "whatsapp",
recipient_type = "individual",
to = recipientWaId,
type = "text",
text = new { body = text }
};
return await SendMessageAsync(phoneNumberId, accessToken, payload, cancellationToken);
}
public async Task<SendMessageResult> SendTemplateMessageAsync(
string phoneNumberId,
string accessToken,
string recipientWaId,
string templateName,
string languageCode,
object[]? components = null,
CancellationToken cancellationToken = default)
{
var template = new Dictionary<string, object>
{
["name"] = templateName,
["language"] = new { code = languageCode }
};
if (components != null)
{
template["components"] = components;
}
var payload = new
{
messaging_product = "whatsapp",
recipient_type = "individual",
to = recipientWaId,
type = "template",
template
};
return await SendMessageAsync(phoneNumberId, accessToken, payload, cancellationToken);
}
public async Task<SendMessageResult> SendMediaMessageAsync(
string phoneNumberId,
string accessToken,
string recipientWaId,
string mediaType,
string mediaUrl,
string? caption = null,
CancellationToken cancellationToken = default)
{
var mediaContent = new Dictionary<string, object> { ["link"] = mediaUrl };
if (!string.IsNullOrEmpty(caption))
{
mediaContent["caption"] = caption;
}
var payload = new Dictionary<string, object>
{
["messaging_product"] = "whatsapp",
["recipient_type"] = "individual",
["to"] = recipientWaId,
["type"] = mediaType,
[mediaType] = mediaContent
};
return await SendMessageAsync(phoneNumberId, accessToken, payload, cancellationToken);
}
public async Task<SendMessageResult> SendInteractiveMessageAsync(
string phoneNumberId,
string accessToken,
string recipientWaId,
object interactiveContent,
CancellationToken cancellationToken = default)
{
var payload = new
{
messaging_product = "whatsapp",
recipient_type = "individual",
to = recipientWaId,
type = "interactive",
interactive = interactiveContent
};
return await SendMessageAsync(phoneNumberId, accessToken, payload, cancellationToken);
}
public async Task<bool> MarkMessageAsReadAsync(
string phoneNumberId,
string accessToken,
string messageId,
CancellationToken cancellationToken = default)
{
try
{
var url = $"{BaseUrl}/{ApiVersion}/{phoneNumberId}/messages";
var payload = new
{
messaging_product = "whatsapp",
status = "read",
message_id = messageId
};
using var request = new HttpRequestMessage(HttpMethod.Post, url);
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken);
request.Content = JsonContent.Create(payload, options: _jsonOptions);
var response = await _httpClient.SendAsync(request, cancellationToken);
return response.IsSuccessStatusCode;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to mark message {MessageId} as read", messageId);
return false;
}
}
public async Task<string?> GetMediaUrlAsync(
string accessToken,
string mediaId,
CancellationToken cancellationToken = default)
{
try
{
var url = $"{BaseUrl}/{ApiVersion}/{mediaId}";
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken);
var response = await _httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode) return null;
var result = await response.Content.ReadFromJsonAsync<MediaResponse>(cancellationToken: cancellationToken);
return result?.Url;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get media URL for {MediaId}", mediaId);
return null;
}
}
private async Task<SendMessageResult> SendMessageAsync(
string phoneNumberId,
string accessToken,
object payload,
CancellationToken cancellationToken)
{
try
{
var url = $"{BaseUrl}/{ApiVersion}/{phoneNumberId}/messages";
using var request = new HttpRequestMessage(HttpMethod.Post, url);
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken);
request.Content = JsonContent.Create(payload, options: _jsonOptions);
_logger.LogDebug("Sending message to WhatsApp API: {Url}", url);
var response = await _httpClient.SendAsync(request, cancellationToken);
var content = await response.Content.ReadAsStringAsync(cancellationToken);
if (response.IsSuccessStatusCode)
{
var result = JsonSerializer.Deserialize<WhatsAppSendResponse>(content, _jsonOptions);
var messageId = result?.Messages?.FirstOrDefault()?.Id;
_logger.LogInformation("Message sent successfully. MessageId: {MessageId}", messageId);
return new SendMessageResult(true, messageId, null, null);
}
var error = JsonSerializer.Deserialize<WhatsAppErrorResponse>(content, _jsonOptions);
_logger.LogWarning("WhatsApp API error: {Code} - {Message}", error?.Error?.Code, error?.Error?.Message);
return new SendMessageResult(false, null, error?.Error?.Code?.ToString(), error?.Error?.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send message via WhatsApp API");
return new SendMessageResult(false, null, "EXCEPTION", ex.Message);
}
}
private record WhatsAppSendResponse(
[property: JsonPropertyName("messages")] List<MessageInfo>? Messages);
private record MessageInfo(
[property: JsonPropertyName("id")] string? Id);
private record WhatsAppErrorResponse(
[property: JsonPropertyName("error")] ErrorInfo? Error);
private record ErrorInfo(
[property: JsonPropertyName("code")] int? Code,
[property: JsonPropertyName("message")] string? Message);
private record MediaResponse(
[property: JsonPropertyName("url")] string? Url);
}

View File

@@ -1,7 +1,12 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using WhatsAppService.Domain.AggregatesModel.AIAgentAggregate;
using WhatsAppService.Domain.AggregatesModel.AutomationFlowAggregate;
using WhatsAppService.Domain.AggregatesModel.ConversationAggregate;
using WhatsAppService.Domain.AggregatesModel.CustomerAggregate;
using WhatsAppService.Domain.AggregatesModel.SampleAggregate;
using WhatsAppService.Domain.AggregatesModel.WhatsAppAccountAggregate;
using WhatsAppService.Domain.SeedWork;
using WhatsAppService.Infrastructure.EntityConfigurations;
@@ -16,12 +21,26 @@ public class WhatsAppServiceContext : DbContext, IUnitOfWork
private readonly IMediator _mediator;
private IDbContextTransaction? _currentTransaction;
/// <summary>
/// EN: Samples table.
/// VI: Bảng Samples.
/// </summary>
// EN: Sample (template) / VI: Sample (mẫu)
public DbSet<Sample> Samples => Set<Sample>();
// EN: WhatsApp Accounts / VI: Tài khoản WhatsApp
public DbSet<WhatsAppAccount> WhatsAppAccounts => Set<WhatsAppAccount>();
// EN: Customers / VI: Khách hàng
public DbSet<Customer> Customers => Set<Customer>();
// EN: Conversations & Messages / VI: Hội thoại & Tin nhắn
public DbSet<Conversation> Conversations => Set<Conversation>();
public DbSet<Message> Messages => Set<Message>();
// EN: Automation Flows / VI: Luồng tự động
public DbSet<AutomationFlow> AutomationFlows => Set<AutomationFlow>();
public DbSet<FlowStep> FlowSteps => Set<FlowStep>();
// EN: AI Agents / VI: AI Agents
public DbSet<AIAgent> AIAgents => Set<AIAgent>();
/// <summary>
/// EN: Read-only access to current transaction.
/// VI: Truy cập chỉ đọc đến transaction hiện tại.
@@ -50,8 +69,30 @@ public class WhatsAppServiceContext : DbContext, IUnitOfWork
{
// EN: Apply entity configurations
// VI: Áp dụng các cấu hình entity
// Sample (template)
modelBuilder.ApplyConfiguration(new SampleEntityTypeConfiguration());
modelBuilder.ApplyConfiguration(new SampleStatusEntityTypeConfiguration());
// WhatsApp Account
modelBuilder.ApplyConfiguration(new WhatsAppAccountEntityTypeConfiguration());
modelBuilder.ApplyConfiguration(new WhatsAppAccountStatusEntityTypeConfiguration());
// Customer
modelBuilder.ApplyConfiguration(new CustomerEntityTypeConfiguration());
// Conversation & Message
modelBuilder.ApplyConfiguration(new ConversationEntityTypeConfiguration());
modelBuilder.ApplyConfiguration(new MessageEntityTypeConfiguration());
modelBuilder.ApplyConfiguration(new ConversationStatusEntityTypeConfiguration());
// Automation Flow
modelBuilder.ApplyConfiguration(new AutomationFlowEntityTypeConfiguration());
modelBuilder.ApplyConfiguration(new FlowStepEntityTypeConfiguration());
modelBuilder.ApplyConfiguration(new TriggerTypeEntityTypeConfiguration());
// AI Agent
modelBuilder.ApplyConfiguration(new AIAgentEntityTypeConfiguration());
}
/// <summary>

View File

@@ -0,0 +1,54 @@
using Microsoft.EntityFrameworkCore;
using WhatsAppService.Domain.AggregatesModel.AIAgentAggregate;
using WhatsAppService.Domain.SeedWork;
namespace WhatsAppService.Infrastructure.Repositories;
/// <summary>
/// EN: Repository implementation for AIAgent aggregate.
/// VI: Triển khai repository cho AIAgent aggregate.
/// </summary>
public class AIAgentRepository : IAIAgentRepository
{
private readonly WhatsAppServiceContext _context;
public IUnitOfWork UnitOfWork => _context;
public AIAgentRepository(WhatsAppServiceContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
public async Task<AIAgent> AddAsync(AIAgent agent, CancellationToken cancellationToken = default)
{
var entry = await _context.AIAgents.AddAsync(agent, cancellationToken);
return entry.Entity;
}
public void Update(AIAgent agent)
{
_context.Entry(agent).State = EntityState.Modified;
}
public async Task<AIAgent?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
return await _context.AIAgents.FirstOrDefaultAsync(a => a.Id == id, cancellationToken);
}
public async Task<AIAgent?> GetActiveByShopIdAsync(Guid shopId, CancellationToken cancellationToken = default)
{
return await _context.AIAgents
.FirstOrDefaultAsync(a =>
EF.Property<Guid>(a, "_shopId") == shopId &&
EF.Property<bool>(a, "_isActive") == true,
cancellationToken);
}
public async Task<IReadOnlyList<AIAgent>> GetByShopIdAsync(Guid shopId, CancellationToken cancellationToken = default)
{
return await _context.AIAgents
.Where(a => EF.Property<Guid>(a, "_shopId") == shopId)
.OrderByDescending(a => EF.Property<DateTime>(a, "_createdAt"))
.ToListAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,65 @@
using Microsoft.EntityFrameworkCore;
using WhatsAppService.Domain.AggregatesModel.AutomationFlowAggregate;
using WhatsAppService.Domain.SeedWork;
namespace WhatsAppService.Infrastructure.Repositories;
/// <summary>
/// EN: Repository implementation for AutomationFlow aggregate.
/// VI: Triển khai repository cho AutomationFlow aggregate.
/// </summary>
public class AutomationFlowRepository : IAutomationFlowRepository
{
private readonly WhatsAppServiceContext _context;
public IUnitOfWork UnitOfWork => _context;
public AutomationFlowRepository(WhatsAppServiceContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
public async Task<AutomationFlow> AddAsync(AutomationFlow flow, CancellationToken cancellationToken = default)
{
var entry = await _context.AutomationFlows.AddAsync(flow, cancellationToken);
return entry.Entity;
}
public void Update(AutomationFlow flow)
{
_context.Entry(flow).State = EntityState.Modified;
}
public async Task<AutomationFlow?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
return await _context.AutomationFlows
.Include(f => f.TriggerType)
.Include(f => f.Steps.OrderBy(s => EF.Property<int>(s, "_order")))
.FirstOrDefaultAsync(f => f.Id == id, cancellationToken);
}
public async Task<IReadOnlyList<AutomationFlow>> GetActiveByShopIdAsync(Guid shopId, CancellationToken cancellationToken = default)
{
return await _context.AutomationFlows
.Include(f => f.TriggerType)
.Include(f => f.Steps.OrderBy(s => EF.Property<int>(s, "_order")))
.Where(f =>
EF.Property<Guid>(f, "_shopId") == shopId &&
EF.Property<bool>(f, "_isActive") == true)
.OrderByDescending(f => EF.Property<int>(f, "_priority"))
.ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<AutomationFlow>> GetByTriggerTypeAsync(Guid shopId, TriggerType triggerType, CancellationToken cancellationToken = default)
{
return await _context.AutomationFlows
.Include(f => f.TriggerType)
.Include(f => f.Steps.OrderBy(s => EF.Property<int>(s, "_order")))
.Where(f =>
EF.Property<Guid>(f, "_shopId") == shopId &&
f.TriggerTypeId == triggerType.Id &&
EF.Property<bool>(f, "_isActive") == true)
.OrderByDescending(f => EF.Property<int>(f, "_priority"))
.ToListAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,81 @@
using Microsoft.EntityFrameworkCore;
using WhatsAppService.Domain.AggregatesModel.ConversationAggregate;
using WhatsAppService.Domain.SeedWork;
namespace WhatsAppService.Infrastructure.Repositories;
/// <summary>
/// EN: Repository implementation for Conversation aggregate.
/// VI: Triển khai repository cho Conversation aggregate.
/// </summary>
public class ConversationRepository : IConversationRepository
{
private readonly WhatsAppServiceContext _context;
public IUnitOfWork UnitOfWork => _context;
public ConversationRepository(WhatsAppServiceContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
public async Task<Conversation> AddAsync(Conversation conversation, CancellationToken cancellationToken = default)
{
var entry = await _context.Conversations.AddAsync(conversation, cancellationToken);
return entry.Entity;
}
public void Update(Conversation conversation)
{
_context.Entry(conversation).State = EntityState.Modified;
}
public async Task<Conversation?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
return await _context.Conversations
.Include(c => c.Status)
.Include(c => c.Messages)
.FirstOrDefaultAsync(c => c.Id == id, cancellationToken);
}
public async Task<Conversation?> GetActiveByCustomerAsync(Guid shopId, string customerWaId, CancellationToken cancellationToken = default)
{
return await _context.Conversations
.Include(c => c.Status)
.Include(c => c.Messages)
.FirstOrDefaultAsync(c =>
EF.Property<Guid>(c, "_shopId") == shopId &&
EF.Property<string>(c, "_customerWaId") == customerWaId &&
c.StatusId == ConversationStatus.Active.Id,
cancellationToken);
}
public async Task<IReadOnlyList<Conversation>> GetByShopIdAsync(Guid shopId, int skip, int take, ConversationStatus? status = null, CancellationToken cancellationToken = default)
{
var query = _context.Conversations
.Include(c => c.Status)
.Where(c => EF.Property<Guid>(c, "_shopId") == shopId);
if (status != null)
{
query = query.Where(c => c.StatusId == status.Id);
}
return await query
.OrderByDescending(c => EF.Property<DateTime?>(c, "_lastMessageAt"))
.Skip(skip)
.Take(take)
.ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<Conversation>> GetExpiredConversationsAsync(CancellationToken cancellationToken = default)
{
var now = DateTime.UtcNow;
return await _context.Conversations
.Include(c => c.Status)
.Where(c =>
c.StatusId == ConversationStatus.Active.Id &&
EF.Property<DateTime?>(c, "_expiresAt") < now)
.ToListAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,65 @@
using Microsoft.EntityFrameworkCore;
using WhatsAppService.Domain.AggregatesModel.CustomerAggregate;
using WhatsAppService.Domain.SeedWork;
namespace WhatsAppService.Infrastructure.Repositories;
/// <summary>
/// EN: Repository implementation for Customer aggregate.
/// VI: Triển khai repository cho Customer aggregate.
/// </summary>
public class CustomerRepository : ICustomerRepository
{
private readonly WhatsAppServiceContext _context;
public IUnitOfWork UnitOfWork => _context;
public CustomerRepository(WhatsAppServiceContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
public async Task<Customer> AddAsync(Customer customer, CancellationToken cancellationToken = default)
{
var entry = await _context.Customers.AddAsync(customer, cancellationToken);
return entry.Entity;
}
public void Update(Customer customer)
{
_context.Entry(customer).State = EntityState.Modified;
}
public async Task<Customer?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
return await _context.Customers.FirstOrDefaultAsync(c => c.Id == id, cancellationToken);
}
public async Task<Customer?> GetByWaIdAsync(Guid shopId, string waId, CancellationToken cancellationToken = default)
{
return await _context.Customers
.FirstOrDefaultAsync(c =>
EF.Property<Guid>(c, "_shopId") == shopId &&
EF.Property<string>(c, "_waId") == waId,
cancellationToken);
}
public async Task<IReadOnlyList<Customer>> GetByShopIdAsync(Guid shopId, int skip, int take, CancellationToken cancellationToken = default)
{
return await _context.Customers
.Where(c => EF.Property<Guid>(c, "_shopId") == shopId)
.OrderByDescending(c => EF.Property<DateTime>(c, "_createdAt"))
.Skip(skip)
.Take(take)
.ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<Customer>> GetByTagsAsync(Guid shopId, IEnumerable<string> tags, CancellationToken cancellationToken = default)
{
var tagList = tags.ToList();
return await _context.Customers
.Where(c => EF.Property<Guid>(c, "_shopId") == shopId)
.ToListAsync(cancellationToken);
// Note: Full JSONB tag filtering would require raw SQL for optimal performance
}
}

View File

@@ -0,0 +1,53 @@
using Microsoft.EntityFrameworkCore;
using WhatsAppService.Domain.AggregatesModel.WhatsAppAccountAggregate;
using WhatsAppService.Domain.SeedWork;
namespace WhatsAppService.Infrastructure.Repositories;
/// <summary>
/// EN: Repository implementation for WhatsAppAccount aggregate.
/// VI: Triển khai repository cho WhatsAppAccount aggregate.
/// </summary>
public class WhatsAppAccountRepository : IWhatsAppAccountRepository
{
private readonly WhatsAppServiceContext _context;
public IUnitOfWork UnitOfWork => _context;
public WhatsAppAccountRepository(WhatsAppServiceContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
public async Task<WhatsAppAccount> AddAsync(WhatsAppAccount account, CancellationToken cancellationToken = default)
{
var entry = await _context.WhatsAppAccounts.AddAsync(account, cancellationToken);
return entry.Entity;
}
public void Update(WhatsAppAccount account)
{
_context.Entry(account).State = EntityState.Modified;
}
public async Task<WhatsAppAccount?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
return await _context.WhatsAppAccounts
.Include(a => a.Status)
.FirstOrDefaultAsync(a => a.Id == id, cancellationToken);
}
public async Task<WhatsAppAccount?> GetByShopIdAsync(Guid shopId, CancellationToken cancellationToken = default)
{
return await _context.WhatsAppAccounts
.Include(a => a.Status)
.FirstOrDefaultAsync(a => EF.Property<Guid>(a, "_shopId") == shopId, cancellationToken);
}
public async Task<WhatsAppAccount?> GetByPhoneNumberIdAsync(string phoneNumberId, CancellationToken cancellationToken = default)
{
return await _context.WhatsAppAccounts
.Include(a => a.Status)
.FirstOrDefaultAsync(a => EF.Property<string>(a, "_phoneNumberId") == phoneNumberId, cancellationToken);
}
}

View File

@@ -23,6 +23,7 @@
<!-- EN: Resilience with Polly / VI: Resilience với Polly -->
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="9.0.0" />
<PackageReference Include="OpenAI" Version="2.1.0" />
<PackageReference Include="Polly" Version="8.5.0" />
<!-- EN: Redis cache / VI: Redis cache -->

View File

@@ -0,0 +1,97 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using MktXService.Domain.AggregatesModel.AIConversationSessionAggregate;
namespace MktXService.Infrastructure.EntityConfigurations;
/// <summary>
/// EN: EF Core configuration for AIConversationSession entity with JSONB for context/slots.
/// VI: Cấu hình EF Core cho entity AIConversationSession với JSONB cho context/slots.
/// </summary>
public class AIConversationSessionEntityTypeConfiguration : IEntityTypeConfiguration<AIConversationSession>
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
public void Configure(EntityTypeBuilder<AIConversationSession> builder)
{
builder.ToTable("ai_conversation_sessions");
builder.HasKey(s => s.Id);
builder.Ignore(s => s.DomainEvents);
builder.Property(s => s.Id)
.HasColumnName("id")
.IsRequired();
builder.Property<Guid>("_conversationId")
.HasColumnName("conversation_id")
.IsRequired();
builder.Property<string?>("_currentIntent")
.HasColumnName("current_intent")
.HasMaxLength(100);
builder.Property<bool>("_isActive")
.HasColumnName("is_active")
.IsRequired();
builder.Property<DateTime?>("_lastActivityAt")
.HasColumnName("last_activity_at");
builder.Property<DateTime>("_createdAt")
.HasColumnName("created_at")
.IsRequired();
// ===========================================
// EN: JSONB for conversation context (chat history)
// VI: JSONB cho ngữ cảnh hội thoại (lịch sử chat)
// ===========================================
builder.Property<List<ContextItem>>("_context")
.HasColumnName("context")
.HasColumnType("jsonb")
.HasConversion(
v => JsonSerializer.Serialize(v, JsonOptions),
v => JsonSerializer.Deserialize<List<ContextItem>>(v, JsonOptions)
?? new List<ContextItem>(),
new ValueComparer<List<ContextItem>>(
(c1, c2) => c1!.SequenceEqual(c2!),
c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())),
c => c.ToList()));
// EN: JSONB for slots (extracted entities from conversation)
// VI: JSONB cho slots (entities được trích xuất từ hội thoại)
builder.Property<Dictionary<string, object>>("_slots")
.HasColumnName("slots")
.HasColumnType("jsonb")
.HasConversion(
v => JsonSerializer.Serialize(v, JsonOptions),
v => JsonSerializer.Deserialize<Dictionary<string, object>>(v, JsonOptions)
?? new Dictionary<string, object>(),
new ValueComparer<Dictionary<string, object>>(
(c1, c2) => c1!.SequenceEqual(c2!),
c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())),
c => new Dictionary<string, object>(c)));
// ===========================================
// EN: Indexes
// VI: Các index
// ===========================================
builder.HasIndex("_conversationId")
.HasDatabaseName("ix_ai_sessions_conversation_id");
builder.HasIndex("_isActive")
.HasDatabaseName("ix_ai_sessions_is_active");
// EN: Composite index for finding active session by conversation
// VI: Index kết hợp để tìm session active theo conversation
builder.HasIndex("_conversationId", "_isActive")
.HasDatabaseName("ix_ai_sessions_conversation_active");
}
}

View File

@@ -0,0 +1,218 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using MktXService.Domain.AggregatesModel.AutomationFlowAggregate;
namespace MktXService.Infrastructure.EntityConfigurations;
/// <summary>
/// EN: EF Core configuration for AutomationFlow entity with JSONB for trigger/config.
/// VI: Cấu hình EF Core cho entity AutomationFlow với JSONB cho trigger/config.
/// </summary>
public class AutomationFlowEntityTypeConfiguration : IEntityTypeConfiguration<AutomationFlow>
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
public void Configure(EntityTypeBuilder<AutomationFlow> builder)
{
builder.ToTable("automation_flows");
builder.HasKey(f => f.Id);
builder.Ignore(f => f.DomainEvents);
// EN: Basic properties
// VI: Các thuộc tính cơ bản
builder.Property(f => f.Id)
.HasColumnName("id")
.IsRequired();
builder.Property<Guid>("_merchantId")
.HasColumnName("merchant_id")
.IsRequired();
builder.Property<string>("_name")
.HasColumnName("name")
.HasMaxLength(200)
.IsRequired();
builder.Property(f => f.StatusId)
.HasColumnName("status_id")
.IsRequired();
builder.Property<DateTime>("_createdAt")
.HasColumnName("created_at")
.IsRequired();
builder.Property<DateTime?>("_updatedAt")
.HasColumnName("updated_at");
// ===========================================
// EN: JSONB Column for FlowTrigger (Value Object)
// VI: Cột JSONB cho FlowTrigger (Value Object)
// ===========================================
builder.Property<FlowTrigger>("_trigger")
.HasColumnName("trigger")
.HasColumnType("jsonb")
.HasConversion(
v => JsonSerializer.Serialize(v, JsonOptions),
v => JsonSerializer.Deserialize<FlowTrigger>(v, JsonOptions)!)
.IsRequired();
// ===========================================
// EN: Relationships
// VI: Quan hệ
// ===========================================
builder.HasOne(f => f.Status)
.WithMany()
.HasForeignKey(f => f.StatusId)
.OnDelete(DeleteBehavior.Restrict);
// EN: One-to-many with FlowNodes
// VI: Quan hệ một-nhiều với FlowNodes
builder.HasMany(f => f.Nodes)
.WithOne()
.HasForeignKey(n => n.FlowId)
.OnDelete(DeleteBehavior.Cascade);
// EN: One-to-many with FlowConnections
// VI: Quan hệ một-nhiều với FlowConnections
builder.HasMany(f => f.Connections)
.WithOne()
.HasForeignKey(c => c.FlowId)
.OnDelete(DeleteBehavior.Cascade);
// ===========================================
// EN: Indexes
// VI: Các index
// ===========================================
builder.HasIndex("_merchantId")
.HasDatabaseName("ix_automation_flows_merchant_id");
builder.HasIndex(f => f.StatusId)
.HasDatabaseName("ix_automation_flows_status_id");
}
}
/// <summary>
/// EN: EF Core configuration for FlowNode entity.
/// VI: Cấu hình EF Core cho entity FlowNode.
/// </summary>
public class FlowNodeEntityTypeConfiguration : IEntityTypeConfiguration<FlowNode>
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
public void Configure(EntityTypeBuilder<FlowNode> builder)
{
builder.ToTable("flow_nodes");
builder.HasKey(n => n.Id);
builder.Property(n => n.Id)
.HasColumnName("id")
.IsRequired();
builder.Property(n => n.FlowId)
.HasColumnName("flow_id")
.IsRequired();
builder.Property(n => n.Type)
.HasColumnName("type")
.HasMaxLength(50)
.IsRequired();
builder.Property(n => n.PositionX)
.HasColumnName("position_x");
builder.Property(n => n.PositionY)
.HasColumnName("position_y");
// EN: Config stored as JSONB
// VI: Config lưu dưới dạng JSONB
builder.Property(n => n.Config)
.HasColumnName("config")
.HasColumnType("jsonb")
.HasConversion(
v => v == null ? null : JsonSerializer.Serialize(v, JsonOptions),
v => v == null ? null : JsonSerializer.Deserialize<Dictionary<string, object>>(v, JsonOptions));
builder.HasIndex(n => n.FlowId)
.HasDatabaseName("ix_flow_nodes_flow_id");
}
}
/// <summary>
/// EN: EF Core configuration for FlowConnection entity.
/// VI: Cấu hình EF Core cho entity FlowConnection.
/// </summary>
public class FlowConnectionEntityTypeConfiguration : IEntityTypeConfiguration<FlowConnection>
{
public void Configure(EntityTypeBuilder<FlowConnection> builder)
{
builder.ToTable("flow_connections");
builder.HasKey(c => c.Id);
builder.Property(c => c.Id)
.HasColumnName("id")
.IsRequired();
builder.Property(c => c.FlowId)
.HasColumnName("flow_id")
.IsRequired();
builder.Property(c => c.FromNodeId)
.HasColumnName("from_node_id")
.IsRequired();
builder.Property(c => c.ToNodeId)
.HasColumnName("to_node_id")
.IsRequired();
builder.Property(c => c.Condition)
.HasColumnName("condition")
.HasMaxLength(500);
builder.HasIndex(c => c.FlowId)
.HasDatabaseName("ix_flow_connections_flow_id");
}
}
/// <summary>
/// EN: EF Core configuration for FlowStatus enumeration.
/// VI: Cấu hình EF Core cho enumeration FlowStatus.
/// </summary>
public class FlowStatusEntityTypeConfiguration : IEntityTypeConfiguration<FlowStatus>
{
public void Configure(EntityTypeBuilder<FlowStatus> builder)
{
builder.ToTable("flow_statuses");
builder.HasKey(s => s.Id);
builder.Property(s => s.Id)
.HasColumnName("id")
.ValueGeneratedNever()
.IsRequired();
builder.Property(s => s.Name)
.HasColumnName("name")
.HasMaxLength(50)
.IsRequired();
builder.HasData(
FlowStatus.Draft,
FlowStatus.Active,
FlowStatus.Inactive
);
}
}

View File

@@ -0,0 +1,173 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using MktXService.Domain.AggregatesModel.CampaignAggregate;
namespace MktXService.Infrastructure.EntityConfigurations;
/// <summary>
/// EN: EF Core configuration for Campaign entity with JSONB support.
/// VI: Cấu hình EF Core cho entity Campaign với hỗ trợ JSONB.
/// </summary>
public class CampaignEntityTypeConfiguration : IEntityTypeConfiguration<Campaign>
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
public void Configure(EntityTypeBuilder<Campaign> builder)
{
// EN: Table name (snake_case for PostgreSQL)
// VI: Tên bảng (snake_case cho PostgreSQL)
builder.ToTable("campaigns");
// EN: Primary key
// VI: Khóa chính
builder.HasKey(c => c.Id);
// EN: Ignore domain events
// VI: Bỏ qua domain events
builder.Ignore(c => c.DomainEvents);
// EN: Basic properties
// VI: Các thuộc tính cơ bản
builder.Property(c => c.Id)
.HasColumnName("id")
.IsRequired();
builder.Property<Guid>("_merchantId")
.HasColumnName("merchant_id")
.IsRequired();
builder.Property<string>("_name")
.HasColumnName("name")
.HasMaxLength(200)
.IsRequired();
builder.Property<string>("_type")
.HasColumnName("type")
.HasMaxLength(50)
.IsRequired();
builder.Property(c => c.StatusId)
.HasColumnName("status_id")
.IsRequired();
builder.Property<Guid?>("_templateId")
.HasColumnName("template_id");
builder.Property<DateTime>("_createdAt")
.HasColumnName("created_at")
.IsRequired();
builder.Property<DateTime?>("_updatedAt")
.HasColumnName("updated_at");
// ===========================================
// EN: JSONB Columns for Complex Types
// VI: Cột JSONB cho các kiểu phức tạp
// ===========================================
// EN: SegmentIds stored as JSONB array
// VI: SegmentIds lưu dưới dạng mảng JSONB
builder.Property<List<Guid>>("_segmentIds")
.HasColumnName("segment_ids")
.HasColumnType("jsonb")
.HasConversion(
v => JsonSerializer.Serialize(v, JsonOptions),
v => JsonSerializer.Deserialize<List<Guid>>(v, JsonOptions) ?? new List<Guid>(),
new ValueComparer<List<Guid>>(
(c1, c2) => c1!.SequenceEqual(c2!),
c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())),
c => c.ToList()));
// EN: Schedule stored as JSONB (Value Object)
// VI: Schedule lưu dưới dạng JSONB (Value Object)
builder.Property<CampaignSchedule?>("_schedule")
.HasColumnName("schedule")
.HasColumnType("jsonb")
.HasConversion(
v => v == null ? null : JsonSerializer.Serialize(v, JsonOptions),
v => v == null ? null : JsonSerializer.Deserialize<CampaignSchedule>(v, JsonOptions));
// EN: Metrics stored as JSONB (Value Object)
// VI: Metrics lưu dưới dạng JSONB (Value Object)
builder.Property<CampaignMetrics>("_metrics")
.HasColumnName("metrics")
.HasColumnType("jsonb")
.HasConversion(
v => JsonSerializer.Serialize(v, JsonOptions),
v => JsonSerializer.Deserialize<CampaignMetrics>(v, JsonOptions)
?? new CampaignMetrics(0, 0, 0, 0, 0, 0))
.IsRequired();
// ===========================================
// EN: Relationships
// VI: Quan hệ
// ===========================================
// EN: Status relationship (Enumeration pattern)
// VI: Quan hệ với Status (pattern Enumeration)
builder.HasOne(c => c.Status)
.WithMany()
.HasForeignKey(c => c.StatusId)
.OnDelete(DeleteBehavior.Restrict);
// ===========================================
// EN: Indexes for commonly queried columns
// VI: Index cho các cột hay truy vấn
// ===========================================
builder.HasIndex("_merchantId")
.HasDatabaseName("ix_campaigns_merchant_id");
builder.HasIndex(c => c.StatusId)
.HasDatabaseName("ix_campaigns_status_id");
builder.HasIndex("_createdAt")
.HasDatabaseName("ix_campaigns_created_at")
.IsDescending();
// EN: Composite index for merchant + status queries
// VI: Index kết hợp cho truy vấn merchant + status
builder.HasIndex("_merchantId", "StatusId")
.HasDatabaseName("ix_campaigns_merchant_status");
}
}
/// <summary>
/// EN: EF Core configuration for CampaignStatus enumeration.
/// VI: Cấu hình EF Core cho enumeration CampaignStatus.
/// </summary>
public class CampaignStatusEntityTypeConfiguration : IEntityTypeConfiguration<CampaignStatus>
{
public void Configure(EntityTypeBuilder<CampaignStatus> builder)
{
builder.ToTable("campaign_statuses");
builder.HasKey(s => s.Id);
builder.Property(s => s.Id)
.HasColumnName("id")
.ValueGeneratedNever()
.IsRequired();
builder.Property(s => s.Name)
.HasColumnName("name")
.HasMaxLength(50)
.IsRequired();
// EN: Seed data for enumeration values
// VI: Dữ liệu gốc cho các giá trị enumeration
builder.HasData(
CampaignStatus.Draft,
CampaignStatus.Scheduled,
CampaignStatus.Running,
CampaignStatus.Paused,
CampaignStatus.Completed,
CampaignStatus.Cancelled
);
}
}

View File

@@ -0,0 +1,148 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using MktXService.Domain.AggregatesModel.ContactAggregate;
namespace MktXService.Infrastructure.EntityConfigurations;
/// <summary>
/// EN: EF Core configuration for Contact entity.
/// VI: Cấu hình EF Core cho entity Contact.
/// </summary>
public class ContactEntityTypeConfiguration : IEntityTypeConfiguration<Contact>
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
public void Configure(EntityTypeBuilder<Contact> builder)
{
builder.ToTable("contacts");
builder.HasKey(c => c.Id);
builder.Ignore(c => c.DomainEvents);
builder.Property(c => c.Id)
.HasColumnName("id")
.IsRequired();
builder.Property<Guid>("_accountId")
.HasColumnName("account_id")
.IsRequired();
builder.Property<string>("_twitterUserId")
.HasColumnName("twitter_user_id")
.HasMaxLength(100)
.IsRequired();
builder.Property<string>("_username")
.HasColumnName("username")
.HasMaxLength(100)
.IsRequired();
builder.Property<string?>("_displayName")
.HasColumnName("display_name")
.HasMaxLength(200);
builder.Property<string?>("_profileImageUrl")
.HasColumnName("profile_image_url")
.HasMaxLength(500);
builder.Property<string>("_source")
.HasColumnName("source")
.HasMaxLength(50)
.IsRequired();
// EN: Custom attributes as JSONB
// VI: Thuộc tính tùy chỉnh dạng JSONB
builder.Property<Dictionary<string, object>>("_attributes")
.HasColumnName("attributes")
.HasColumnType("jsonb")
.HasConversion(
v => JsonSerializer.Serialize(v, JsonOptions),
v => JsonSerializer.Deserialize<Dictionary<string, object>>(v, JsonOptions)
?? new Dictionary<string, object>(),
new ValueComparer<Dictionary<string, object>>(
(c1, c2) => c1!.SequenceEqual(c2!),
c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())),
c => new Dictionary<string, object>(c)));
builder.Property<DateTime>("_firstInteractionAt")
.HasColumnName("first_interaction_at")
.IsRequired();
builder.Property<DateTime?>("_lastInteractionAt")
.HasColumnName("last_interaction_at");
builder.Property<DateTime>("_createdAt")
.HasColumnName("created_at")
.IsRequired();
builder.Property<DateTime?>("_updatedAt")
.HasColumnName("updated_at");
// EN: One-to-many with ContactTags
// VI: Quan hệ một-nhiều với ContactTags
builder.HasMany(c => c.Tags)
.WithOne()
.HasForeignKey(t => t.ContactId)
.OnDelete(DeleteBehavior.Cascade);
// EN: Unique constraint on account_id + twitter_user_id
// VI: Ràng buộc unique trên account_id + twitter_user_id
builder.HasIndex("_accountId", "_twitterUserId")
.IsUnique()
.HasDatabaseName("ix_contacts_account_twitter_user");
builder.HasIndex("_accountId")
.HasDatabaseName("ix_contacts_account_id");
builder.HasIndex("_username")
.HasDatabaseName("ix_contacts_username");
builder.HasIndex("_lastInteractionAt")
.HasDatabaseName("ix_contacts_last_interaction");
}
}
/// <summary>
/// EN: EF Core configuration for ContactTag entity.
/// VI: Cấu hình EF Core cho entity ContactTag.
/// </summary>
public class ContactTagEntityTypeConfiguration : IEntityTypeConfiguration<ContactTag>
{
public void Configure(EntityTypeBuilder<ContactTag> builder)
{
builder.ToTable("contact_tags");
builder.HasKey(t => t.Id);
builder.Property(t => t.Id)
.HasColumnName("id")
.IsRequired();
builder.Property(t => t.ContactId)
.HasColumnName("contact_id")
.IsRequired();
builder.Property<string>("_name")
.HasColumnName("name")
.HasMaxLength(100)
.IsRequired();
builder.Property<DateTime>("_createdAt")
.HasColumnName("created_at")
.IsRequired();
// EN: Unique per contact
builder.HasIndex(t => t.ContactId, "_name")
.IsUnique()
.HasDatabaseName("ix_contact_tags_contact_name");
builder.HasIndex("_name")
.HasDatabaseName("ix_contact_tags_name");
}
}

View File

@@ -0,0 +1,194 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using MktXService.Domain.AggregatesModel.ConversationAggregate;
namespace MktXService.Infrastructure.EntityConfigurations;
/// <summary>
/// EN: EF Core configuration for Conversation entity.
/// VI: Cấu hình EF Core cho entity Conversation.
/// </summary>
public class ConversationEntityTypeConfiguration : IEntityTypeConfiguration<Conversation>
{
public void Configure(EntityTypeBuilder<Conversation> builder)
{
builder.ToTable("conversations");
builder.HasKey(c => c.Id);
builder.Ignore(c => c.DomainEvents);
builder.Property(c => c.Id)
.HasColumnName("id")
.IsRequired();
builder.Property<Guid>("_contactId")
.HasColumnName("contact_id")
.IsRequired();
builder.Property<Guid>("_accountId")
.HasColumnName("account_id")
.IsRequired();
builder.Property(c => c.StatusId)
.HasColumnName("status_id")
.IsRequired();
builder.Property<string>("_channel")
.HasColumnName("channel")
.HasMaxLength(50)
.IsRequired();
builder.Property<Guid?>("_assignedToUserId")
.HasColumnName("assigned_to_user_id");
builder.Property<DateTime>("_startedAt")
.HasColumnName("started_at")
.IsRequired();
builder.Property<DateTime?>("_closedAt")
.HasColumnName("closed_at");
builder.Property<DateTime>("_createdAt")
.HasColumnName("created_at")
.IsRequired();
builder.Property<DateTime?>("_updatedAt")
.HasColumnName("updated_at");
// EN: Relationships
builder.HasOne(c => c.Status)
.WithMany()
.HasForeignKey(c => c.StatusId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasMany(c => c.Messages)
.WithOne()
.HasForeignKey(m => m.ConversationId)
.OnDelete(DeleteBehavior.Cascade);
// EN: Indexes
builder.HasIndex("_contactId")
.HasDatabaseName("ix_conversations_contact_id");
builder.HasIndex("_accountId")
.HasDatabaseName("ix_conversations_account_id");
builder.HasIndex(c => c.StatusId)
.HasDatabaseName("ix_conversations_status_id");
builder.HasIndex("_assignedToUserId")
.HasDatabaseName("ix_conversations_assigned_user");
// EN: Composite for finding open conversations per contact
builder.HasIndex("_contactId", "StatusId")
.HasDatabaseName("ix_conversations_contact_status");
}
}
/// <summary>
/// EN: EF Core configuration for ConversationStatus enumeration.
/// VI: Cấu hình EF Core cho enumeration ConversationStatus.
/// </summary>
public class ConversationStatusEntityTypeConfiguration : IEntityTypeConfiguration<ConversationStatus>
{
public void Configure(EntityTypeBuilder<ConversationStatus> builder)
{
builder.ToTable("conversation_statuses");
builder.HasKey(s => s.Id);
builder.Property(s => s.Id)
.HasColumnName("id")
.ValueGeneratedNever()
.IsRequired();
builder.Property(s => s.Name)
.HasColumnName("name")
.HasMaxLength(50)
.IsRequired();
builder.HasData(
ConversationStatus.Open,
ConversationStatus.Closed,
ConversationStatus.Pending
);
}
}
/// <summary>
/// EN: EF Core configuration for Message entity.
/// VI: Cấu hình EF Core cho entity Message.
/// </summary>
public class MessageEntityTypeConfiguration : IEntityTypeConfiguration<Message>
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
public void Configure(EntityTypeBuilder<Message> builder)
{
builder.ToTable("messages");
builder.HasKey(m => m.Id);
builder.Property(m => m.Id)
.HasColumnName("id")
.IsRequired();
builder.Property(m => m.ConversationId)
.HasColumnName("conversation_id")
.IsRequired();
builder.Property<string?>("_twitterMessageId")
.HasColumnName("twitter_message_id")
.HasMaxLength(100);
builder.Property<string>("_direction")
.HasColumnName("direction")
.HasMaxLength(20)
.IsRequired();
builder.Property<string>("_type")
.HasColumnName("type")
.HasMaxLength(50)
.IsRequired();
builder.Property<string>("_content")
.HasColumnName("content")
.IsRequired();
// EN: Attachments as JSONB
builder.Property<List<MessageAttachment>?>("_attachments")
.HasColumnName("attachments")
.HasColumnType("jsonb")
.HasConversion(
v => v == null ? null : JsonSerializer.Serialize(v, JsonOptions),
v => v == null ? null : JsonSerializer.Deserialize<List<MessageAttachment>>(v, JsonOptions));
builder.Property<bool>("_isFromBot")
.HasColumnName("is_from_bot")
.IsRequired();
builder.Property<DateTime>("_sentAt")
.HasColumnName("sent_at")
.IsRequired();
builder.Property<DateTime>("_createdAt")
.HasColumnName("created_at")
.IsRequired();
// EN: Indexes
builder.HasIndex(m => m.ConversationId)
.HasDatabaseName("ix_messages_conversation_id");
builder.HasIndex("_twitterMessageId")
.HasDatabaseName("ix_messages_twitter_id");
builder.HasIndex("_sentAt")
.HasDatabaseName("ix_messages_sent_at");
}
}

View File

@@ -0,0 +1,96 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using MktXService.Domain.AggregatesModel.SegmentAggregate;
namespace MktXService.Infrastructure.EntityConfigurations;
/// <summary>
/// EN: EF Core configuration for Segment entity.
/// VI: Cấu hình EF Core cho entity Segment.
/// </summary>
public class SegmentEntityTypeConfiguration : IEntityTypeConfiguration<Segment>
{
public void Configure(EntityTypeBuilder<Segment> builder)
{
builder.ToTable("segments");
builder.HasKey(s => s.Id);
builder.Ignore(s => s.DomainEvents);
builder.Property(s => s.Id)
.HasColumnName("id")
.IsRequired();
builder.Property<Guid>("_merchantId")
.HasColumnName("merchant_id")
.IsRequired();
builder.Property<string>("_name")
.HasColumnName("name")
.HasMaxLength(200)
.IsRequired();
builder.Property<int>("_contactCount")
.HasColumnName("contact_count")
.HasDefaultValue(0);
builder.Property<DateTime>("_createdAt")
.HasColumnName("created_at")
.IsRequired();
builder.Property<DateTime?>("_updatedAt")
.HasColumnName("updated_at");
// EN: One-to-many with SegmentConditions
builder.HasMany(s => s.Conditions)
.WithOne()
.HasForeignKey(c => c.SegmentId)
.OnDelete(DeleteBehavior.Cascade);
// EN: Indexes
builder.HasIndex("_merchantId")
.HasDatabaseName("ix_segments_merchant_id");
builder.HasIndex("_name")
.HasDatabaseName("ix_segments_name");
}
}
/// <summary>
/// EN: EF Core configuration for SegmentCondition entity.
/// VI: Cấu hình EF Core cho entity SegmentCondition.
/// </summary>
public class SegmentConditionEntityTypeConfiguration : IEntityTypeConfiguration<SegmentCondition>
{
public void Configure(EntityTypeBuilder<SegmentCondition> builder)
{
builder.ToTable("segment_conditions");
builder.HasKey(c => c.Id);
builder.Property(c => c.Id)
.HasColumnName("id")
.IsRequired();
builder.Property(c => c.SegmentId)
.HasColumnName("segment_id")
.IsRequired();
builder.Property<string>("_field")
.HasColumnName("field")
.HasMaxLength(100)
.IsRequired();
builder.Property<string>("_operator")
.HasColumnName("operator")
.HasMaxLength(50)
.IsRequired();
builder.Property<string>("_value")
.HasColumnName("value")
.HasMaxLength(500);
builder.HasIndex(c => c.SegmentId)
.HasDatabaseName("ix_segment_conditions_segment_id");
}
}

View File

@@ -0,0 +1,79 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using MktXService.Domain.AggregatesModel.TemplateAggregate;
namespace MktXService.Infrastructure.EntityConfigurations;
/// <summary>
/// EN: EF Core configuration for Template entity.
/// VI: Cấu hình EF Core cho entity Template.
/// </summary>
public class TemplateEntityTypeConfiguration : IEntityTypeConfiguration<Template>
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
public void Configure(EntityTypeBuilder<Template> builder)
{
builder.ToTable("templates");
builder.HasKey(t => t.Id);
builder.Ignore(t => t.DomainEvents);
builder.Property(t => t.Id)
.HasColumnName("id")
.IsRequired();
builder.Property<Guid>("_merchantId")
.HasColumnName("merchant_id")
.IsRequired();
builder.Property<string>("_name")
.HasColumnName("name")
.HasMaxLength(200)
.IsRequired();
builder.Property<string>("_type")
.HasColumnName("type")
.HasMaxLength(50)
.IsRequired();
builder.Property<string>("_content")
.HasColumnName("content")
.IsRequired();
// EN: Variables list as JSONB
builder.Property<List<string>>("_variables")
.HasColumnName("variables")
.HasColumnType("jsonb")
.HasConversion(
v => JsonSerializer.Serialize(v, JsonOptions),
v => JsonSerializer.Deserialize<List<string>>(v, JsonOptions) ?? new List<string>(),
new ValueComparer<List<string>>(
(c1, c2) => c1!.SequenceEqual(c2!),
c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())),
c => c.ToList()));
builder.Property<DateTime>("_createdAt")
.HasColumnName("created_at")
.IsRequired();
builder.Property<DateTime?>("_updatedAt")
.HasColumnName("updated_at");
// EN: Indexes
builder.HasIndex("_merchantId")
.HasDatabaseName("ix_templates_merchant_id");
builder.HasIndex("_type")
.HasDatabaseName("ix_templates_type");
builder.HasIndex("_name")
.HasDatabaseName("ix_templates_name");
}
}

View File

@@ -0,0 +1,160 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using MktXService.Domain.AggregatesModel.TwitterAccountAggregate;
namespace MktXService.Infrastructure.EntityConfigurations;
/// <summary>
/// EN: EF Core configuration for TwitterAccount entity.
/// VI: Cấu hình EF Core cho entity TwitterAccount.
/// </summary>
/// <remarks>
/// <para>
/// EN: SECURITY NOTE: OAuth tokens (_oauthToken, _oauthTokenSecret) are stored as-is in this configuration.
/// In production, these should be encrypted using IDataProtector before storage.
/// Consider implementing a ValueConverter that uses DataProtectionProvider for encryption/decryption.
/// </para>
/// <para>
/// VI: LƯU Ý BẢO MẬT: OAuth tokens (_oauthToken, _oauthTokenSecret) được lưu nguyên trong cấu hình này.
/// Trong production, nên mã hóa bằng IDataProtector trước khi lưu.
/// Xem xét triển khai ValueConverter sử dụng DataProtectionProvider để mã hóa/giải mã.
/// </para>
/// </remarks>
public class TwitterAccountEntityTypeConfiguration : IEntityTypeConfiguration<TwitterAccount>
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
public void Configure(EntityTypeBuilder<TwitterAccount> builder)
{
builder.ToTable("twitter_accounts");
builder.HasKey(a => a.Id);
builder.Ignore(a => a.DomainEvents);
builder.Property(a => a.Id)
.HasColumnName("id")
.IsRequired();
builder.Property<Guid>("_merchantId")
.HasColumnName("merchant_id")
.IsRequired();
builder.Property<string>("_twitterUserId")
.HasColumnName("twitter_user_id")
.HasMaxLength(100)
.IsRequired();
builder.Property<string>("_username")
.HasColumnName("username")
.HasMaxLength(100)
.IsRequired();
builder.Property<string?>("_displayName")
.HasColumnName("display_name")
.HasMaxLength(200);
builder.Property<string?>("_profileImageUrl")
.HasColumnName("profile_image_url")
.HasMaxLength(500);
builder.Property(a => a.StatusId)
.HasColumnName("status_id")
.IsRequired();
// EN: OAuth tokens - SHOULD BE ENCRYPTED IN PRODUCTION
// VI: OAuth tokens - NÊN ĐƯỢC MÃ HÓA TRONG PRODUCTION
builder.Property<string>("_oauthToken")
.HasColumnName("oauth_token")
.IsRequired();
builder.Property<string>("_oauthTokenSecret")
.HasColumnName("oauth_token_secret")
.IsRequired();
builder.Property<string?>("_webhookId")
.HasColumnName("webhook_id")
.HasMaxLength(100);
// EN: Settings as JSONB
// VI: Settings dạng JSONB
builder.Property<Dictionary<string, object>>("_settings")
.HasColumnName("settings")
.HasColumnType("jsonb")
.HasConversion(
v => JsonSerializer.Serialize(v, JsonOptions),
v => JsonSerializer.Deserialize<Dictionary<string, object>>(v, JsonOptions)
?? new Dictionary<string, object>(),
new ValueComparer<Dictionary<string, object>>(
(c1, c2) => c1!.SequenceEqual(c2!),
c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())),
c => new Dictionary<string, object>(c)));
builder.Property<DateTime>("_connectedAt")
.HasColumnName("connected_at")
.IsRequired();
builder.Property<DateTime>("_createdAt")
.HasColumnName("created_at")
.IsRequired();
builder.Property<DateTime?>("_updatedAt")
.HasColumnName("updated_at");
// EN: Relationships
builder.HasOne(a => a.Status)
.WithMany()
.HasForeignKey(a => a.StatusId)
.OnDelete(DeleteBehavior.Restrict);
// EN: Unique constraint on twitter_user_id
// VI: Ràng buộc unique trên twitter_user_id
builder.HasIndex("_twitterUserId")
.IsUnique()
.HasDatabaseName("ix_twitter_accounts_twitter_user_id");
builder.HasIndex("_merchantId")
.HasDatabaseName("ix_twitter_accounts_merchant_id");
builder.HasIndex(a => a.StatusId)
.HasDatabaseName("ix_twitter_accounts_status_id");
}
}
/// <summary>
/// EN: EF Core configuration for TwitterAccountStatus enumeration.
/// VI: Cấu hình EF Core cho enumeration TwitterAccountStatus.
/// </summary>
public class TwitterAccountStatusEntityTypeConfiguration : IEntityTypeConfiguration<TwitterAccountStatus>
{
public void Configure(EntityTypeBuilder<TwitterAccountStatus> builder)
{
builder.ToTable("twitter_account_statuses");
builder.HasKey(s => s.Id);
builder.Property(s => s.Id)
.HasColumnName("id")
.ValueGeneratedNever()
.IsRequired();
builder.Property(s => s.Name)
.HasColumnName("name")
.HasMaxLength(50)
.IsRequired();
builder.HasData(
TwitterAccountStatus.Pending,
TwitterAccountStatus.Active,
TwitterAccountStatus.Inactive,
TwitterAccountStatus.Error,
TwitterAccountStatus.Disconnected
);
}
}

View File

@@ -0,0 +1,175 @@
namespace MktXService.Infrastructure.ExternalServices.OpenAI;
/// <summary>
/// EN: Interface for AI/LLM service client operations.
/// VI: Interface cho các thao tác client dịch vụ AI/LLM.
/// </summary>
/// <remarks>
/// EN: Abstracts OpenAI API for chat completions and embeddings.
/// VI: Trừu tượng hóa OpenAI API cho chat completions và embeddings.
/// </remarks>
public interface IAIServiceClient
{
#region Chat Completions
/// <summary>
/// EN: Generate a chat completion response.
/// VI: Tạo phản hồi chat completion.
/// </summary>
Task<ChatCompletionResult> GenerateChatResponseAsync(
IEnumerable<ChatMessage> messages,
ChatCompletionOptions? options = null,
CancellationToken cancellationToken = default);
/// <summary>
/// EN: Generate a chat completion with function calling.
/// VI: Tạo chat completion với function calling.
/// </summary>
Task<ChatCompletionResult> GenerateChatWithFunctionsAsync(
IEnumerable<ChatMessage> messages,
IEnumerable<FunctionDefinition> functions,
ChatCompletionOptions? options = null,
CancellationToken cancellationToken = default);
/// <summary>
/// EN: Stream chat completion response.
/// VI: Stream phản hồi chat completion.
/// </summary>
IAsyncEnumerable<string> StreamChatResponseAsync(
IEnumerable<ChatMessage> messages,
ChatCompletionOptions? options = null,
CancellationToken cancellationToken = default);
#endregion
#region Intent Detection
/// <summary>
/// EN: Detect intent from user message.
/// VI: Phát hiện ý định từ tin nhắn người dùng.
/// </summary>
Task<IntentDetectionResult> DetectIntentAsync(
string message,
IEnumerable<string> possibleIntents,
CancellationToken cancellationToken = default);
/// <summary>
/// EN: Extract entities/slots from user message.
/// VI: Trích xuất entities/slots từ tin nhắn người dùng.
/// </summary>
Task<Dictionary<string, object>> ExtractEntitiesAsync(
string message,
IEnumerable<EntityDefinition> entityDefinitions,
CancellationToken cancellationToken = default);
#endregion
#region Embeddings
/// <summary>
/// EN: Generate embeddings for text.
/// VI: Tạo embeddings cho văn bản.
/// </summary>
Task<float[]> GenerateEmbeddingAsync(
string text,
CancellationToken cancellationToken = default);
/// <summary>
/// EN: Generate embeddings for multiple texts.
/// VI: Tạo embeddings cho nhiều văn bản.
/// </summary>
Task<IEnumerable<float[]>> GenerateEmbeddingsAsync(
IEnumerable<string> texts,
CancellationToken cancellationToken = default);
#endregion
#region Moderation
/// <summary>
/// EN: Check if content violates usage policies.
/// VI: Kiểm tra xem nội dung có vi phạm chính sách sử dụng không.
/// </summary>
Task<ModerationResult> ModerateContentAsync(
string content,
CancellationToken cancellationToken = default);
#endregion
}
#region DTOs
/// <summary>
/// EN: Chat message for conversation context.
/// VI: Tin nhắn chat cho ngữ cảnh hội thoại.
/// </summary>
public record ChatMessage(
string Role, // "system", "user", "assistant", "function"
string Content,
string? Name = null,
string? FunctionCall = null);
/// <summary>
/// EN: Options for chat completion.
/// VI: Tùy chọn cho chat completion.
/// </summary>
public record ChatCompletionOptions(
string Model = "gpt-4o-mini",
double Temperature = 0.7,
int MaxTokens = 1000,
double TopP = 1.0,
double FrequencyPenalty = 0,
double PresencePenalty = 0,
string? SystemPrompt = null);
/// <summary>
/// EN: Result of chat completion.
/// VI: Kết quả của chat completion.
/// </summary>
public record ChatCompletionResult(
bool Success,
string? Content,
string? FunctionName,
string? FunctionArguments,
string? Error,
int TokensUsed,
string? FinishReason);
/// <summary>
/// EN: Function definition for function calling.
/// VI: Định nghĩa function cho function calling.
/// </summary>
public record FunctionDefinition(
string Name,
string Description,
Dictionary<string, object> Parameters);
/// <summary>
/// EN: Entity definition for extraction.
/// VI: Định nghĩa entity để trích xuất.
/// </summary>
public record EntityDefinition(
string Name,
string Type,
string Description,
bool Required = false);
/// <summary>
/// EN: Result of intent detection.
/// VI: Kết quả phát hiện ý định.
/// </summary>
public record IntentDetectionResult(
string Intent,
double Confidence,
Dictionary<string, object>? ExtractedEntities = null);
/// <summary>
/// EN: Result of content moderation.
/// VI: Kết quả kiểm duyệt nội dung.
/// </summary>
public record ModerationResult(
bool Flagged,
Dictionary<string, bool> Categories,
Dictionary<string, double> CategoryScores);
#endregion

View File

@@ -0,0 +1,399 @@
using System.Net.Http.Headers;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Polly;
using Polly.CircuitBreaker;
namespace MktXService.Infrastructure.ExternalServices.OpenAI;
/// <summary>
/// EN: OpenAI API client implementation with Polly resilience.
/// VI: Triển khai OpenAI API client với Polly resilience.
/// </summary>
public class OpenAIServiceClient : IAIServiceClient
{
private readonly HttpClient _httpClient;
private readonly OpenAIOptions _options;
private readonly ILogger<OpenAIServiceClient> _logger;
private readonly AsyncCircuitBreakerPolicy<HttpResponseMessage> _circuitBreakerPolicy;
private const string BaseUrl = "https://api.openai.com/v1";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
WriteIndented = false
};
public OpenAIServiceClient(
HttpClient httpClient,
IOptions<OpenAIOptions> options,
ILogger<OpenAIServiceClient> logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", _options.ApiKey);
_circuitBreakerPolicy = Policy<HttpResponseMessage>
.Handle<HttpRequestException>()
.OrResult(r => (int)r.StatusCode >= 500)
.CircuitBreakerAsync(
5,
TimeSpan.FromMinutes(1),
(outcome, breakDuration) =>
{
_logger.LogError("OpenAI circuit breaker opened for {Duration}s", breakDuration.TotalSeconds);
},
() => _logger.LogInformation("OpenAI circuit breaker reset"));
}
#region Chat Completions
public async Task<ChatCompletionResult> GenerateChatResponseAsync(
IEnumerable<ChatMessage> messages,
ChatCompletionOptions? options = null,
CancellationToken cancellationToken = default)
{
options ??= new ChatCompletionOptions();
var payload = new
{
model = options.Model,
messages = messages.Select(m => new { role = m.Role, content = m.Content, name = m.Name }).ToList(),
temperature = options.Temperature,
max_tokens = options.MaxTokens,
top_p = options.TopP,
frequency_penalty = options.FrequencyPenalty,
presence_penalty = options.PresencePenalty
};
try
{
var response = await SendRequestAsync($"{BaseUrl}/chat/completions", payload, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var error = await response.Content.ReadAsStringAsync(cancellationToken);
_logger.LogError("OpenAI API error: {Error}", error);
return new ChatCompletionResult(false, null, null, null, error, 0, null);
}
var content = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonSerializer.Deserialize<JsonElement>(content);
var choice = result.GetProperty("choices")[0];
var message = choice.GetProperty("message");
var usage = result.GetProperty("usage");
return new ChatCompletionResult(
true,
message.TryGetProperty("content", out var c) ? c.GetString() : null,
null,
null,
null,
usage.GetProperty("total_tokens").GetInt32(),
choice.GetProperty("finish_reason").GetString());
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to generate chat response");
return new ChatCompletionResult(false, null, null, null, ex.Message, 0, null);
}
}
public async Task<ChatCompletionResult> GenerateChatWithFunctionsAsync(
IEnumerable<ChatMessage> messages,
IEnumerable<FunctionDefinition> functions,
ChatCompletionOptions? options = null,
CancellationToken cancellationToken = default)
{
options ??= new ChatCompletionOptions();
var payload = new
{
model = options.Model,
messages = messages.Select(m => new { role = m.Role, content = m.Content }).ToList(),
functions = functions.Select(f => new
{
name = f.Name,
description = f.Description,
parameters = f.Parameters
}).ToList(),
function_call = "auto",
temperature = options.Temperature,
max_tokens = options.MaxTokens
};
try
{
var response = await SendRequestAsync($"{BaseUrl}/chat/completions", payload, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var error = await response.Content.ReadAsStringAsync(cancellationToken);
return new ChatCompletionResult(false, null, null, null, error, 0, null);
}
var content = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonSerializer.Deserialize<JsonElement>(content);
var choice = result.GetProperty("choices")[0];
var message = choice.GetProperty("message");
var usage = result.GetProperty("usage");
string? functionName = null;
string? functionArgs = null;
if (message.TryGetProperty("function_call", out var fc))
{
functionName = fc.GetProperty("name").GetString();
functionArgs = fc.GetProperty("arguments").GetString();
}
return new ChatCompletionResult(
true,
message.TryGetProperty("content", out var c) ? c.GetString() : null,
functionName,
functionArgs,
null,
usage.GetProperty("total_tokens").GetInt32(),
choice.GetProperty("finish_reason").GetString());
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to generate chat with functions");
return new ChatCompletionResult(false, null, null, null, ex.Message, 0, null);
}
}
public async IAsyncEnumerable<string> StreamChatResponseAsync(
IEnumerable<ChatMessage> messages,
ChatCompletionOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
options ??= new ChatCompletionOptions();
var payload = new
{
model = options.Model,
messages = messages.Select(m => new { role = m.Role, content = m.Content }).ToList(),
temperature = options.Temperature,
max_tokens = options.MaxTokens,
stream = true
};
var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/chat/completions")
{
Content = new StringContent(JsonSerializer.Serialize(payload, JsonOptions), Encoding.UTF8, "application/json")
};
var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var reader = new StreamReader(stream);
string? line;
while ((line = await reader.ReadLineAsync(cancellationToken)) != null && !cancellationToken.IsCancellationRequested)
{
if (string.IsNullOrEmpty(line) || !line.StartsWith("data: "))
continue;
var data = line[6..];
if (data == "[DONE]")
break;
var json = JsonSerializer.Deserialize<JsonElement>(data);
if (json.TryGetProperty("choices", out var choices) &&
choices.GetArrayLength() > 0 &&
choices[0].TryGetProperty("delta", out var delta) &&
delta.TryGetProperty("content", out var content))
{
var text = content.GetString();
if (!string.IsNullOrEmpty(text))
yield return text;
}
}
}
#endregion
#region Intent Detection
public async Task<IntentDetectionResult> DetectIntentAsync(
string message,
IEnumerable<string> possibleIntents,
CancellationToken cancellationToken = default)
{
var systemPrompt = $@"You are an intent classifier. Classify the user message into one of these intents: {string.Join(", ", possibleIntents)}.
Respond ONLY with a JSON object: {{""intent"": ""<intent>"", ""confidence"": <0.0-1.0>}}";
var messages = new[]
{
new ChatMessage("system", systemPrompt),
new ChatMessage("user", message)
};
var result = await GenerateChatResponseAsync(messages, new ChatCompletionOptions(Temperature: 0.1), cancellationToken);
if (!result.Success || string.IsNullOrEmpty(result.Content))
return new IntentDetectionResult("unknown", 0);
try
{
var json = JsonSerializer.Deserialize<JsonElement>(result.Content);
return new IntentDetectionResult(
json.GetProperty("intent").GetString() ?? "unknown",
json.GetProperty("confidence").GetDouble());
}
catch
{
return new IntentDetectionResult("unknown", 0);
}
}
public async Task<Dictionary<string, object>> ExtractEntitiesAsync(
string message,
IEnumerable<EntityDefinition> entityDefinitions,
CancellationToken cancellationToken = default)
{
var entityDesc = string.Join("\n", entityDefinitions.Select(e => $"- {e.Name} ({e.Type}): {e.Description}"));
var systemPrompt = $@"Extract the following entities from the user message:
{entityDesc}
Respond ONLY with a JSON object containing the extracted values.";
var messages = new[]
{
new ChatMessage("system", systemPrompt),
new ChatMessage("user", message)
};
var result = await GenerateChatResponseAsync(messages, new ChatCompletionOptions(Temperature: 0.1), cancellationToken);
if (!result.Success || string.IsNullOrEmpty(result.Content))
return new Dictionary<string, object>();
try
{
return JsonSerializer.Deserialize<Dictionary<string, object>>(result.Content)
?? new Dictionary<string, object>();
}
catch
{
return new Dictionary<string, object>();
}
}
#endregion
#region Embeddings
public async Task<float[]> GenerateEmbeddingAsync(string text, CancellationToken cancellationToken = default)
{
var payload = new
{
model = _options.EmbeddingModel,
input = text
};
var response = await SendRequestAsync($"{BaseUrl}/embeddings", payload, cancellationToken);
if (!response.IsSuccessStatusCode)
return Array.Empty<float>();
var content = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonSerializer.Deserialize<JsonElement>(content);
var embedding = result.GetProperty("data")[0].GetProperty("embedding");
return embedding.EnumerateArray().Select(e => e.GetSingle()).ToArray();
}
public async Task<IEnumerable<float[]>> GenerateEmbeddingsAsync(
IEnumerable<string> texts,
CancellationToken cancellationToken = default)
{
var payload = new
{
model = _options.EmbeddingModel,
input = texts.ToList()
};
var response = await SendRequestAsync($"{BaseUrl}/embeddings", payload, cancellationToken);
if (!response.IsSuccessStatusCode)
return Enumerable.Empty<float[]>();
var content = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonSerializer.Deserialize<JsonElement>(content);
return result.GetProperty("data")
.EnumerateArray()
.Select(d => d.GetProperty("embedding").EnumerateArray().Select(e => e.GetSingle()).ToArray())
.ToList();
}
#endregion
#region Moderation
public async Task<ModerationResult> ModerateContentAsync(string content, CancellationToken cancellationToken = default)
{
var payload = new { input = content };
var response = await SendRequestAsync($"{BaseUrl}/moderations", payload, cancellationToken);
if (!response.IsSuccessStatusCode)
return new ModerationResult(false, new Dictionary<string, bool>(), new Dictionary<string, double>());
var responseContent = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonSerializer.Deserialize<JsonElement>(responseContent);
var moderationResult = result.GetProperty("results")[0];
var categories = moderationResult.GetProperty("categories");
var categoryScores = moderationResult.GetProperty("category_scores");
return new ModerationResult(
moderationResult.GetProperty("flagged").GetBoolean(),
categories.EnumerateObject().ToDictionary(p => p.Name, p => p.Value.GetBoolean()),
categoryScores.EnumerateObject().ToDictionary(p => p.Name, p => p.Value.GetDouble()));
}
#endregion
#region Private Helpers
private async Task<HttpResponseMessage> SendRequestAsync(string url, object payload, CancellationToken cancellationToken)
{
return await _circuitBreakerPolicy.ExecuteAsync(async () =>
{
var request = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new StringContent(JsonSerializer.Serialize(payload, JsonOptions), Encoding.UTF8, "application/json")
};
return await _httpClient.SendAsync(request, cancellationToken);
});
}
#endregion
}
/// <summary>
/// EN: OpenAI API configuration options.
/// VI: Các tùy chọn cấu hình OpenAI API.
/// </summary>
public class OpenAIOptions
{
public string ApiKey { get; set; } = string.Empty;
public string DefaultModel { get; set; } = "gpt-4o-mini";
public string EmbeddingModel { get; set; } = "text-embedding-3-small";
public int MaxTokens { get; set; } = 1000;
public double DefaultTemperature { get; set; } = 0.7;
}

View File

@@ -0,0 +1,183 @@
namespace MktXService.Infrastructure.ExternalServices.Twitter;
/// <summary>
/// EN: Interface for Twitter API v2 client operations.
/// VI: Interface cho các thao tác client Twitter API v2.
/// </summary>
/// <remarks>
/// EN: Implements Direct Message and Account Activity API operations.
/// VI: Triển khai các thao tác Direct Message và Account Activity API.
/// </remarks>
public interface ITwitterApiClient
{
#region Direct Messages
/// <summary>
/// EN: Send a direct message to a user.
/// VI: Gửi tin nhắn trực tiếp đến người dùng.
/// </summary>
Task<TwitterMessageResult> SendDirectMessageAsync(
string recipientId,
string text,
CancellationToken cancellationToken = default);
/// <summary>
/// EN: Send a direct message with media attachment.
/// VI: Gửi tin nhắn trực tiếp với tệp đính kèm.
/// </summary>
Task<TwitterMessageResult> SendDirectMessageWithMediaAsync(
string recipientId,
string text,
string mediaId,
CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get conversation history with a participant.
/// VI: Lấy lịch sử hội thoại với một người tham gia.
/// </summary>
Task<IEnumerable<TwitterDirectMessage>> GetConversationAsync(
string participantId,
int maxResults = 50,
string? paginationToken = null,
CancellationToken cancellationToken = default);
#endregion
#region User Operations
/// <summary>
/// EN: Get user information by user ID.
/// VI: Lấy thông tin người dùng theo user ID.
/// </summary>
Task<TwitterUser?> GetUserByIdAsync(
string userId,
CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get user information by username.
/// VI: Lấy thông tin người dùng theo username.
/// </summary>
Task<TwitterUser?> GetUserByUsernameAsync(
string username,
CancellationToken cancellationToken = default);
/// <summary>
/// EN: Verify OAuth credentials and get authenticated user.
/// VI: Xác thực OAuth credentials và lấy thông tin người dùng đã xác thực.
/// </summary>
Task<TwitterUser?> VerifyCredentialsAsync(CancellationToken cancellationToken = default);
#endregion
#region Webhooks (Account Activity API)
/// <summary>
/// EN: Register a webhook URL for receiving events.
/// VI: Đăng ký webhook URL để nhận events.
/// </summary>
Task<string> RegisterWebhookAsync(
string webhookUrl,
CancellationToken cancellationToken = default);
/// <summary>
/// EN: Delete a registered webhook.
/// VI: Xóa webhook đã đăng ký.
/// </summary>
Task DeleteWebhookAsync(
string webhookId,
CancellationToken cancellationToken = default);
/// <summary>
/// EN: Subscribe to webhook events for the authenticated user.
/// VI: Đăng ký nhận webhook events cho người dùng đã xác thực.
/// </summary>
Task SubscribeToWebhookAsync(CancellationToken cancellationToken = default);
/// <summary>
/// EN: Validate CRC token for webhook verification.
/// VI: Xác thực CRC token cho webhook verification.
/// </summary>
string GenerateCrcResponse(string crcToken);
#endregion
#region Media
/// <summary>
/// EN: Upload media for use in messages.
/// VI: Tải lên media để sử dụng trong tin nhắn.
/// </summary>
Task<string> UploadMediaAsync(
byte[] mediaData,
string mediaType,
CancellationToken cancellationToken = default);
#endregion
#region Rate Limiting
/// <summary>
/// EN: Get remaining rate limit for an endpoint.
/// VI: Lấy rate limit còn lại cho một endpoint.
/// </summary>
Task<RateLimitInfo> GetRateLimitInfoAsync(string endpoint);
#endregion
}
#region DTOs
/// <summary>
/// EN: Result of sending a direct message.
/// VI: Kết quả gửi tin nhắn trực tiếp.
/// </summary>
public record TwitterMessageResult(
bool Success,
string? MessageId,
string? Error);
/// <summary>
/// EN: Twitter direct message data.
/// VI: Dữ liệu tin nhắn trực tiếp Twitter.
/// </summary>
public record TwitterDirectMessage(
string Id,
string SenderId,
string RecipientId,
string Text,
DateTime CreatedAt,
List<TwitterMessageAttachment>? Attachments);
/// <summary>
/// EN: Message attachment data.
/// VI: Dữ liệu tệp đính kèm tin nhắn.
/// </summary>
public record TwitterMessageAttachment(
string Type,
string Url,
string? MediaId);
/// <summary>
/// EN: Twitter user information.
/// VI: Thông tin người dùng Twitter.
/// </summary>
public record TwitterUser(
string Id,
string Username,
string Name,
string? ProfileImageUrl,
string? Description,
bool Verified,
int FollowersCount,
int FollowingCount);
/// <summary>
/// EN: Rate limit information.
/// VI: Thông tin giới hạn tốc độ.
/// </summary>
public record RateLimitInfo(
int Limit,
int Remaining,
DateTime ResetAt);
#endregion

View File

@@ -0,0 +1,415 @@
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Polly;
using Polly.CircuitBreaker;
using Polly.Retry;
namespace MktXService.Infrastructure.ExternalServices.Twitter;
/// <summary>
/// EN: Twitter API v2 client implementation with Polly resilience.
/// VI: Triển khai Twitter API v2 client với Polly resilience.
/// </summary>
/// <remarks>
/// EN: Uses OAuth 1.0a authentication and implements rate limiting handling.
/// VI: Sử dụng xác thực OAuth 1.0a và xử lý rate limiting.
/// </remarks>
public class TwitterApiClient : ITwitterApiClient
{
private readonly HttpClient _httpClient;
private readonly TwitterApiOptions _options;
private readonly ILogger<TwitterApiClient> _logger;
private readonly AsyncRetryPolicy<HttpResponseMessage> _retryPolicy;
private readonly AsyncCircuitBreakerPolicy<HttpResponseMessage> _circuitBreakerPolicy;
private const string BaseUrl = "https://api.twitter.com/2";
private const string UploadUrl = "https://upload.twitter.com/1.1/media/upload.json";
public TwitterApiClient(
HttpClient httpClient,
IOptions<TwitterApiOptions> options,
ILogger<TwitterApiClient> logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
// EN: Configure resilience policies
// VI: Cấu hình các policy resilience
_retryPolicy = Policy<HttpResponseMessage>
.Handle<HttpRequestException>()
.OrResult(r => r.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
.WaitAndRetryAsync(
3,
retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)),
(outcome, timespan, retryCount, context) =>
{
_logger.LogWarning("Twitter API retry {RetryCount} after {Delay}s", retryCount, timespan.TotalSeconds);
});
_circuitBreakerPolicy = Policy<HttpResponseMessage>
.Handle<HttpRequestException>()
.OrResult(r => (int)r.StatusCode >= 500)
.CircuitBreakerAsync(
5,
TimeSpan.FromMinutes(1),
(outcome, breakDuration) =>
{
_logger.LogError("Twitter API circuit breaker opened for {Duration}s", breakDuration.TotalSeconds);
},
() => _logger.LogInformation("Twitter API circuit breaker reset"));
}
#region Direct Messages
public async Task<TwitterMessageResult> SendDirectMessageAsync(
string recipientId,
string text,
CancellationToken cancellationToken = default)
{
try
{
var payload = new
{
text,
participant_id = recipientId
};
var response = await ExecuteWithPoliciesAsync(
() => CreateAuthenticatedRequest(HttpMethod.Post, $"{BaseUrl}/dm_conversations/with/{recipientId}/messages", payload),
cancellationToken);
if (response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonSerializer.Deserialize<JsonElement>(content);
var messageId = result.GetProperty("data").GetProperty("dm_event_id").GetString();
return new TwitterMessageResult(true, messageId, null);
}
var error = await response.Content.ReadAsStringAsync(cancellationToken);
_logger.LogError("Twitter API error: {Error}", error);
return new TwitterMessageResult(false, null, error);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send direct message to {RecipientId}", recipientId);
return new TwitterMessageResult(false, null, ex.Message);
}
}
public async Task<TwitterMessageResult> SendDirectMessageWithMediaAsync(
string recipientId,
string text,
string mediaId,
CancellationToken cancellationToken = default)
{
try
{
var payload = new
{
text,
attachments = new[] { new { media_id = mediaId } }
};
var response = await ExecuteWithPoliciesAsync(
() => CreateAuthenticatedRequest(HttpMethod.Post, $"{BaseUrl}/dm_conversations/with/{recipientId}/messages", payload),
cancellationToken);
if (response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonSerializer.Deserialize<JsonElement>(content);
var messageId = result.GetProperty("data").GetProperty("dm_event_id").GetString();
return new TwitterMessageResult(true, messageId, null);
}
var error = await response.Content.ReadAsStringAsync(cancellationToken);
return new TwitterMessageResult(false, null, error);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send direct message with media to {RecipientId}", recipientId);
return new TwitterMessageResult(false, null, ex.Message);
}
}
public async Task<IEnumerable<TwitterDirectMessage>> GetConversationAsync(
string participantId,
int maxResults = 50,
string? paginationToken = null,
CancellationToken cancellationToken = default)
{
var url = $"{BaseUrl}/dm_conversations/with/{participantId}/dm_events?max_results={maxResults}";
if (!string.IsNullOrEmpty(paginationToken))
url += $"&pagination_token={paginationToken}";
var response = await ExecuteWithPoliciesAsync(
() => CreateAuthenticatedRequest(HttpMethod.Get, url),
cancellationToken);
if (!response.IsSuccessStatusCode)
return Enumerable.Empty<TwitterDirectMessage>();
var content = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonSerializer.Deserialize<JsonElement>(content);
var messages = new List<TwitterDirectMessage>();
if (result.TryGetProperty("data", out var data))
{
foreach (var item in data.EnumerateArray())
{
messages.Add(new TwitterDirectMessage(
item.GetProperty("id").GetString()!,
item.GetProperty("sender_id").GetString()!,
participantId,
item.TryGetProperty("text", out var text) ? text.GetString()! : "",
DateTime.Parse(item.GetProperty("created_at").GetString()!),
null
));
}
}
return messages;
}
#endregion
#region User Operations
public async Task<TwitterUser?> GetUserByIdAsync(string userId, CancellationToken cancellationToken = default)
{
var url = $"{BaseUrl}/users/{userId}?user.fields=profile_image_url,description,verified,public_metrics";
return await GetUserAsync(url, cancellationToken);
}
public async Task<TwitterUser?> GetUserByUsernameAsync(string username, CancellationToken cancellationToken = default)
{
var url = $"{BaseUrl}/users/by/username/{username}?user.fields=profile_image_url,description,verified,public_metrics";
return await GetUserAsync(url, cancellationToken);
}
public async Task<TwitterUser?> VerifyCredentialsAsync(CancellationToken cancellationToken = default)
{
var url = $"{BaseUrl}/users/me?user.fields=profile_image_url,description,verified,public_metrics";
return await GetUserAsync(url, cancellationToken);
}
private async Task<TwitterUser?> GetUserAsync(string url, CancellationToken cancellationToken)
{
var response = await ExecuteWithPoliciesAsync(
() => CreateAuthenticatedRequest(HttpMethod.Get, url),
cancellationToken);
if (!response.IsSuccessStatusCode)
return null;
var content = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonSerializer.Deserialize<JsonElement>(content);
if (!result.TryGetProperty("data", out var data))
return null;
var metrics = data.TryGetProperty("public_metrics", out var pm) ? pm : default;
return new TwitterUser(
data.GetProperty("id").GetString()!,
data.GetProperty("username").GetString()!,
data.GetProperty("name").GetString()!,
data.TryGetProperty("profile_image_url", out var img) ? img.GetString() : null,
data.TryGetProperty("description", out var desc) ? desc.GetString() : null,
data.TryGetProperty("verified", out var ver) && ver.GetBoolean(),
metrics.ValueKind != JsonValueKind.Undefined && metrics.TryGetProperty("followers_count", out var fc) ? fc.GetInt32() : 0,
metrics.ValueKind != JsonValueKind.Undefined && metrics.TryGetProperty("following_count", out var foc) ? foc.GetInt32() : 0
);
}
#endregion
#region Webhooks
public async Task<string> RegisterWebhookAsync(string webhookUrl, CancellationToken cancellationToken = default)
{
var url = $"https://api.twitter.com/1.1/account_activity/all/{_options.EnvironmentName}/webhooks.json?url={Uri.EscapeDataString(webhookUrl)}";
var response = await ExecuteWithPoliciesAsync(
() => CreateAuthenticatedRequest(HttpMethod.Post, url),
cancellationToken);
if (!response.IsSuccessStatusCode)
throw new TwitterApiException($"Failed to register webhook: {await response.Content.ReadAsStringAsync(cancellationToken)}");
var content = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonSerializer.Deserialize<JsonElement>(content);
return result.GetProperty("id").GetString()!;
}
public async Task DeleteWebhookAsync(string webhookId, CancellationToken cancellationToken = default)
{
var url = $"https://api.twitter.com/1.1/account_activity/all/{_options.EnvironmentName}/webhooks/{webhookId}.json";
var response = await ExecuteWithPoliciesAsync(
() => CreateAuthenticatedRequest(HttpMethod.Delete, url),
cancellationToken);
if (!response.IsSuccessStatusCode)
throw new TwitterApiException($"Failed to delete webhook: {await response.Content.ReadAsStringAsync(cancellationToken)}");
}
public async Task SubscribeToWebhookAsync(CancellationToken cancellationToken = default)
{
var url = $"https://api.twitter.com/1.1/account_activity/all/{_options.EnvironmentName}/subscriptions.json";
var response = await ExecuteWithPoliciesAsync(
() => CreateAuthenticatedRequest(HttpMethod.Post, url),
cancellationToken);
if (!response.IsSuccessStatusCode)
throw new TwitterApiException($"Failed to subscribe to webhook: {await response.Content.ReadAsStringAsync(cancellationToken)}");
}
public string GenerateCrcResponse(string crcToken)
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(_options.ConsumerSecret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(crcToken));
return $"sha256={Convert.ToBase64String(hash)}";
}
#endregion
#region Media
public async Task<string> UploadMediaAsync(byte[] mediaData, string mediaType, CancellationToken cancellationToken = default)
{
using var content = new MultipartFormDataContent();
content.Add(new ByteArrayContent(mediaData), "media", "media");
content.Add(new StringContent(mediaType), "media_category");
var request = new HttpRequestMessage(HttpMethod.Post, UploadUrl) { Content = content };
AddOAuthHeader(request);
var response = await _httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
throw new TwitterApiException($"Failed to upload media: {await response.Content.ReadAsStringAsync(cancellationToken)}");
var result = await response.Content.ReadAsStringAsync(cancellationToken);
var json = JsonSerializer.Deserialize<JsonElement>(result);
return json.GetProperty("media_id_string").GetString()!;
}
#endregion
#region Rate Limiting
public Task<RateLimitInfo> GetRateLimitInfoAsync(string endpoint)
{
// EN: Rate limit info is typically extracted from response headers
// VI: Thông tin rate limit thường được trích xuất từ response headers
// For now, return default values
return Task.FromResult(new RateLimitInfo(15, 15, DateTime.UtcNow.AddMinutes(15)));
}
#endregion
#region Private Helpers
private async Task<HttpResponseMessage> ExecuteWithPoliciesAsync(
Func<HttpRequestMessage> requestFactory,
CancellationToken cancellationToken)
{
return await _retryPolicy.ExecuteAsync(async () =>
{
return await _circuitBreakerPolicy.ExecuteAsync(async () =>
{
var request = requestFactory();
AddOAuthHeader(request);
return await _httpClient.SendAsync(request, cancellationToken);
});
});
}
private HttpRequestMessage CreateAuthenticatedRequest(HttpMethod method, string url, object? payload = null)
{
var request = new HttpRequestMessage(method, url);
if (payload != null)
{
request.Content = new StringContent(
JsonSerializer.Serialize(payload),
Encoding.UTF8,
"application/json");
}
return request;
}
private void AddOAuthHeader(HttpRequestMessage request)
{
// EN: OAuth 1.0a signature generation
// VI: Tạo chữ ký OAuth 1.0a
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
var nonce = Guid.NewGuid().ToString("N");
var parameters = new SortedDictionary<string, string>
{
["oauth_consumer_key"] = _options.ConsumerKey,
["oauth_nonce"] = nonce,
["oauth_signature_method"] = "HMAC-SHA1",
["oauth_timestamp"] = timestamp,
["oauth_token"] = _options.AccessToken,
["oauth_version"] = "1.0"
};
// EN: Create signature base string
var baseString = CreateSignatureBaseString(request.Method.Method, request.RequestUri!.ToString(), parameters);
var signingKey = $"{Uri.EscapeDataString(_options.ConsumerSecret)}&{Uri.EscapeDataString(_options.AccessTokenSecret)}";
using var hmac = new HMACSHA1(Encoding.UTF8.GetBytes(signingKey));
var signature = Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(baseString)));
parameters["oauth_signature"] = signature;
var authHeader = "OAuth " + string.Join(", ", parameters.Select(p => $"{p.Key}=\"{Uri.EscapeDataString(p.Value)}\""));
request.Headers.Authorization = new AuthenticationHeaderValue("OAuth", authHeader[6..]);
}
private static string CreateSignatureBaseString(string method, string url, SortedDictionary<string, string> parameters)
{
var uri = new Uri(url);
var baseUrl = $"{uri.Scheme}://{uri.Host}{uri.AbsolutePath}";
var paramString = string.Join("&", parameters.Select(p => $"{Uri.EscapeDataString(p.Key)}={Uri.EscapeDataString(p.Value)}"));
return $"{method.ToUpper()}&{Uri.EscapeDataString(baseUrl)}&{Uri.EscapeDataString(paramString)}";
}
#endregion
}
/// <summary>
/// EN: Twitter API configuration options.
/// VI: Các tùy chọn cấu hình Twitter API.
/// </summary>
public class TwitterApiOptions
{
public string ConsumerKey { get; set; } = string.Empty;
public string ConsumerSecret { get; set; } = string.Empty;
public string AccessToken { get; set; } = string.Empty;
public string AccessTokenSecret { get; set; } = string.Empty;
public string EnvironmentName { get; set; } = "production";
}
/// <summary>
/// EN: Exception for Twitter API errors.
/// VI: Exception cho lỗi Twitter API.
/// </summary>
public class TwitterApiException : Exception
{
public TwitterApiException(string message) : base(message) { }
public TwitterApiException(string message, Exception inner) : base(message, inner) { }
}

View File

@@ -1,26 +1,81 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using MktXService.Domain.AggregatesModel.AIConversationSessionAggregate;
using MktXService.Domain.AggregatesModel.AutomationFlowAggregate;
using MktXService.Domain.AggregatesModel.CampaignAggregate;
using MktXService.Domain.AggregatesModel.ContactAggregate;
using MktXService.Domain.AggregatesModel.ConversationAggregate;
using MktXService.Domain.AggregatesModel.SampleAggregate;
using MktXService.Domain.AggregatesModel.SegmentAggregate;
using MktXService.Domain.AggregatesModel.TemplateAggregate;
using MktXService.Domain.AggregatesModel.TwitterAccountAggregate;
using MktXService.Domain.SeedWork;
using MktXService.Infrastructure.EntityConfigurations;
namespace MktXService.Infrastructure;
/// <summary>
/// EN: EF Core DbContext for MktXService.
/// VI: EF Core DbContext cho MktXService.
/// EN: EF Core DbContext for MktXService with PostgreSQL and JSONB support.
/// VI: EF Core DbContext cho MktXService với PostgreSQL và hỗ trợ JSONB.
/// </summary>
/// <remarks>
/// EN: Uses snake_case naming convention for PostgreSQL compatibility.
/// VI: Sử dụng quy ước đặt tên snake_case cho tương thích PostgreSQL.
/// </remarks>
public class MktXServiceContext : DbContext, IUnitOfWork
{
private readonly IMediator _mediator;
private IDbContextTransaction? _currentTransaction;
/// <summary>
/// EN: Samples table.
/// VI: Bảng Samples.
/// </summary>
#region DbSets
// EN: Sample aggregate (template reference)
// VI: Sample aggregate (tham chiếu template)
public DbSet<Sample> Samples => Set<Sample>();
public DbSet<SampleStatus> SampleStatuses => Set<SampleStatus>();
// EN: Twitter Account aggregate
// VI: Aggregate tài khoản Twitter
public DbSet<TwitterAccount> TwitterAccounts => Set<TwitterAccount>();
public DbSet<TwitterAccountStatus> TwitterAccountStatuses => Set<TwitterAccountStatus>();
// EN: Contact aggregate
// VI: Aggregate liên hệ
public DbSet<Contact> Contacts => Set<Contact>();
public DbSet<ContactTag> ContactTags => Set<ContactTag>();
// EN: Conversation aggregate
// VI: Aggregate hội thoại
public DbSet<Conversation> Conversations => Set<Conversation>();
public DbSet<ConversationStatus> ConversationStatuses => Set<ConversationStatus>();
public DbSet<Message> Messages => Set<Message>();
// EN: Template aggregate
// VI: Aggregate mẫu tin
public DbSet<Template> Templates => Set<Template>();
// EN: Campaign aggregate
// VI: Aggregate chiến dịch
public DbSet<Campaign> Campaigns => Set<Campaign>();
public DbSet<CampaignStatus> CampaignStatuses => Set<CampaignStatus>();
// EN: Segment aggregate
// VI: Aggregate phân khúc
public DbSet<Segment> Segments => Set<Segment>();
public DbSet<SegmentCondition> SegmentConditions => Set<SegmentCondition>();
// EN: Automation Flow aggregate
// VI: Aggregate luồng tự động
public DbSet<AutomationFlow> AutomationFlows => Set<AutomationFlow>();
public DbSet<FlowStatus> FlowStatuses => Set<FlowStatus>();
public DbSet<FlowNode> FlowNodes => Set<FlowNode>();
public DbSet<FlowConnection> FlowConnections => Set<FlowConnection>();
// EN: AI Conversation Session aggregate
// VI: Aggregate phiên hội thoại AI
public DbSet<AIConversationSession> AIConversationSessions => Set<AIConversationSession>();
#endregion
/// <summary>
/// EN: Read-only access to current transaction.
@@ -48,10 +103,14 @@ public class MktXServiceContext : DbContext, IUnitOfWork
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// EN: Apply entity configurations
// VI: Áp dụng các cấu hình entity
modelBuilder.ApplyConfiguration(new SampleEntityTypeConfiguration());
modelBuilder.ApplyConfiguration(new SampleStatusEntityTypeConfiguration());
// EN: Apply all entity configurations from this assembly
// VI: Áp dụng tất cả entity configurations từ assembly này
modelBuilder.ApplyConfigurationsFromAssembly(typeof(MktXServiceContext).Assembly);
// EN: Configure snake_case naming convention for PostgreSQL
// VI: Cấu hình quy ước đặt tên snake_case cho PostgreSQL
// Note: Using Npgsql's built-in SnakeCaseNamingConvention when available
// For now, we handle it in individual configurations
}
/// <summary>

View File

@@ -0,0 +1,50 @@
using Microsoft.EntityFrameworkCore;
using MktXService.Domain.AggregatesModel.AIConversationSessionAggregate;
using MktXService.Domain.SeedWork;
namespace MktXService.Infrastructure.Repositories;
/// <summary>
/// EN: Repository implementation for AIConversationSession aggregate.
/// VI: Triển khai repository cho AIConversationSession aggregate.
/// </summary>
public class AIConversationSessionRepository : IAIConversationSessionRepository
{
private readonly MktXServiceContext _context;
public IUnitOfWork UnitOfWork => _context;
public AIConversationSessionRepository(MktXServiceContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
public AIConversationSession Add(AIConversationSession session)
{
return _context.AIConversationSessions.Add(session).Entity;
}
public void Update(AIConversationSession session)
{
_context.Entry(session).State = EntityState.Modified;
}
public async Task<AIConversationSession?> GetByIdAsync(Guid id)
{
return await _context.AIConversationSessions.FirstOrDefaultAsync(s => s.Id == id);
}
public async Task<AIConversationSession?> GetByConversationIdAsync(Guid conversationId)
{
return await _context.AIConversationSessions
.FirstOrDefaultAsync(s => EF.Property<Guid>(s, "_conversationId") == conversationId);
}
public async Task<AIConversationSession?> GetActiveByConversationIdAsync(Guid conversationId)
{
return await _context.AIConversationSessions
.FirstOrDefaultAsync(s =>
EF.Property<Guid>(s, "_conversationId") == conversationId &&
EF.Property<bool>(s, "_isActive") == true);
}
}

Some files were not shown because too many files have changed in this diff Show More