feat: Introduce MktXService with API controllers for campaigns, contacts, conversations, templates, and webhooks, and add chatbot rule deletion and status toggling to MktZaloService.

This commit is contained in:
Ho Ngoc Hai
2026-01-19 01:24:57 +07:00
parent 37db21fbc0
commit 8ace5025d9
7 changed files with 657 additions and 3 deletions

View File

@@ -0,0 +1,170 @@
using Asp.Versioning;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using MktXService.API.Application.Commands;
using MktXService.API.Application.Queries;
using MktXService.Domain.AggregatesModel.CampaignAggregate;
namespace MktXService.API.Controllers;
/// <summary>
/// EN: Controller for Campaign management.
/// VI: Controller quản lý chiến dịch.
/// </summary>
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/[controller]")]
[Produces("application/json")]
public class CampaignsController : ControllerBase
{
private readonly IMediator _mediator;
private readonly ILogger<CampaignsController> _logger;
public CampaignsController(IMediator mediator, ILogger<CampaignsController> logger)
{
_mediator = mediator;
_logger = logger;
}
/// <summary>
/// EN: Get all campaigns for a merchant.
/// VI: Lấy tất cả chiến dịch của merchant.
/// </summary>
[HttpGet]
[ProducesResponseType(typeof(IEnumerable<CampaignDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetCampaigns(
[FromQuery] Guid merchantId,
[FromQuery] string? status = null,
[FromQuery] int skip = 0,
[FromQuery] int take = 20)
{
var campaigns = await _mediator.Send(new GetCampaignsQuery(merchantId, status, skip, take));
return Ok(new { success = true, data = campaigns });
}
/// <summary>
/// EN: Get a campaign by ID.
/// VI: Lấy chiến dịch theo ID.
/// </summary>
[HttpGet("{id:guid}")]
[ProducesResponseType(typeof(CampaignDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetCampaign(Guid id)
{
var campaign = await _mediator.Send(new GetCampaignQuery(id));
if (campaign == null)
return NotFound(new { success = false, error = new { code = "CAMPAIGN_NOT_FOUND", message = $"Campaign {id} not found" } });
return Ok(new { success = true, data = campaign });
}
/// <summary>
/// EN: Create a new campaign.
/// VI: Tạo chiến dịch mới.
/// </summary>
[HttpPost]
[ProducesResponseType(typeof(CreateCampaignResult), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> CreateCampaign([FromBody] CreateCampaignRequest request)
{
CampaignSchedule? schedule = null;
if (request.Schedule != null)
schedule = new CampaignSchedule(request.Schedule.StartAt, request.Schedule.EndAt, request.Schedule.Recurrence);
var command = new CreateCampaignCommand(
request.MerchantId,
request.Name,
request.Type,
request.TemplateId,
request.SegmentIds,
schedule);
var result = await _mediator.Send(command);
if (!result.Success)
return BadRequest(new { success = false, error = new { code = "CREATE_FAILED", message = result.Error } });
return CreatedAtAction(nameof(GetCampaign), new { id = result.CampaignId },
new { success = true, data = result });
}
/// <summary>
/// EN: Start a campaign.
/// VI: Bắt đầu chiến dịch.
/// </summary>
[HttpPost("{id:guid}/start")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> StartCampaign(Guid id)
{
var result = await _mediator.Send(new StartCampaignCommand(id));
if (!result)
return BadRequest(new { success = false, error = new { code = "START_FAILED", message = "Failed to start campaign" } });
return Ok(new { success = true, message = "Campaign started successfully" });
}
/// <summary>
/// EN: Pause a running campaign.
/// VI: Tạm dừng chiến dịch đang chạy.
/// </summary>
[HttpPost("{id:guid}/pause")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> PauseCampaign(Guid id)
{
var result = await _mediator.Send(new PauseCampaignCommand(id));
if (!result)
return BadRequest(new { success = false, error = new { code = "PAUSE_FAILED", message = "Failed to pause campaign" } });
return Ok(new { success = true, message = "Campaign paused successfully" });
}
/// <summary>
/// EN: Resume a paused campaign.
/// VI: Tiếp tục chiến dịch đã tạm dừng.
/// </summary>
[HttpPost("{id:guid}/resume")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> ResumeCampaign(Guid id)
{
var result = await _mediator.Send(new ResumeCampaignCommand(id));
if (!result)
return BadRequest(new { success = false, error = new { code = "RESUME_FAILED", message = "Failed to resume campaign" } });
return Ok(new { success = true, message = "Campaign resumed successfully" });
}
/// <summary>
/// EN: Cancel a campaign.
/// VI: Hủy chiến dịch.
/// </summary>
[HttpPost("{id:guid}/cancel")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> CancelCampaign(Guid id, [FromBody] CancelCampaignRequest? request = null)
{
var result = await _mediator.Send(new CancelCampaignCommand(id, request?.Reason));
if (!result)
return BadRequest(new { success = false, error = new { code = "CANCEL_FAILED", message = "Failed to cancel campaign" } });
return Ok(new { success = true, message = "Campaign cancelled successfully" });
}
}
#region Request Models
public record CreateCampaignRequest(
Guid MerchantId,
string Name,
string Type,
Guid? TemplateId,
List<Guid> SegmentIds,
CampaignScheduleRequest? Schedule = null);
public record CampaignScheduleRequest(DateTime? StartAt, DateTime? EndAt, string? Recurrence);
public record CancelCampaignRequest(string? Reason);
#endregion

View File

@@ -0,0 +1,59 @@
using Asp.Versioning;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using MktXService.API.Application.Queries;
namespace MktXService.API.Controllers;
/// <summary>
/// EN: Controller for Contact management.
/// VI: Controller quản lý liên hệ.
/// </summary>
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/[controller]")]
[Produces("application/json")]
public class ContactsController : ControllerBase
{
private readonly IMediator _mediator;
private readonly ILogger<ContactsController> _logger;
public ContactsController(IMediator mediator, ILogger<ContactsController> logger)
{
_mediator = mediator;
_logger = logger;
}
/// <summary>
/// EN: Get all contacts for an account.
/// VI: Lấy tất cả liên hệ của tài khoản.
/// </summary>
[HttpGet]
[ProducesResponseType(typeof(IEnumerable<ContactDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetContacts(
[FromQuery] Guid accountId,
[FromQuery] string? search = null,
[FromQuery] List<string>? tags = null,
[FromQuery] int skip = 0,
[FromQuery] int take = 20)
{
var contacts = await _mediator.Send(new GetContactsQuery(accountId, search, tags, skip, take));
return Ok(new { success = true, data = contacts });
}
/// <summary>
/// EN: Get a contact by ID.
/// VI: Lấy liên hệ theo ID.
/// </summary>
[HttpGet("{id:guid}")]
[ProducesResponseType(typeof(ContactDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetContact(Guid id)
{
var contact = await _mediator.Send(new GetContactQuery(id));
if (contact == null)
return NotFound(new { success = false, error = new { code = "CONTACT_NOT_FOUND", message = $"Contact {id} not found" } });
return Ok(new { success = true, data = contact });
}
}

View File

@@ -0,0 +1,152 @@
using Asp.Versioning;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using MktXService.API.Application.Commands;
using MktXService.API.Application.Queries;
namespace MktXService.API.Controllers;
/// <summary>
/// EN: Controller for Conversation management.
/// VI: Controller quản lý hội thoại.
/// </summary>
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/[controller]")]
[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;
_logger = logger;
}
/// <summary>
/// EN: Get all conversations for an account.
/// VI: Lấy tất cả hội thoại của tài khoản.
/// </summary>
[HttpGet]
[ProducesResponseType(typeof(IEnumerable<ConversationDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetConversations(
[FromQuery] Guid accountId,
[FromQuery] string? status = null,
[FromQuery] Guid? assignedToUserId = null,
[FromQuery] int skip = 0,
[FromQuery] int take = 20)
{
var conversations = await _mediator.Send(
new GetConversationsQuery(accountId, status, assignedToUserId, skip, take));
return Ok(new { success = true, data = conversations });
}
/// <summary>
/// EN: Get a conversation by ID with messages.
/// VI: Lấy hội thoại theo ID với tin nhắn.
/// </summary>
[HttpGet("{id:guid}")]
[ProducesResponseType(typeof(ConversationDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetConversation(Guid id, [FromQuery] bool includeMessages = true)
{
var conversation = await _mediator.Send(new GetConversationQuery(id, includeMessages));
if (conversation == null)
return NotFound(new { success = false, error = new { code = "CONVERSATION_NOT_FOUND", message = $"Conversation {id} not found" } });
return Ok(new { success = true, data = conversation });
}
/// <summary>
/// EN: Send a message in a conversation.
/// VI: Gửi tin nhắn trong hội thoại.
/// </summary>
[HttpPost("{id:guid}/messages")]
[ProducesResponseType(typeof(SendMessageResult), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> SendMessage(Guid id, [FromBody] SendMessageRequest request)
{
var command = new SendMessageCommand(id, request.Content, request.IsFromBot, request.MediaUrls);
var result = await _mediator.Send(command);
if (!result.Success)
return BadRequest(new { success = false, error = new { code = "SEND_FAILED", message = result.Error } });
return Created($"/api/v1/conversations/{id}/messages/{result.MessageId}",
new { success = true, data = result });
}
/// <summary>
/// EN: Close a conversation.
/// VI: Đóng hội thoại.
/// </summary>
[HttpPost("{id:guid}/close")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> CloseConversation(Guid id, [FromBody] CloseConversationRequest? request = null)
{
var result = await _mediator.Send(new CloseConversationCommand(id, request?.Reason));
if (!result)
return NotFound(new { success = false, error = new { code = "CONVERSATION_NOT_FOUND", message = $"Conversation {id} not found" } });
return Ok(new { success = true, message = "Conversation closed successfully" });
}
/// <summary>
/// EN: Reopen a closed conversation.
/// VI: Mở lại hội thoại đã đóng.
/// </summary>
[HttpPost("{id:guid}/reopen")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> ReopenConversation(Guid id)
{
var result = await _mediator.Send(new ReopenConversationCommand(id));
if (!result)
return BadRequest(new { success = false, error = new { code = "REOPEN_FAILED", message = "Failed to reopen conversation" } });
return Ok(new { success = true, message = "Conversation reopened successfully" });
}
/// <summary>
/// EN: Assign conversation to an agent.
/// VI: Gán hội thoại cho agent.
/// </summary>
[HttpPost("{id:guid}/assign")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> AssignConversation(Guid id, [FromBody] AssignConversationRequest request)
{
var result = await _mediator.Send(new AssignConversationCommand(id, request.UserId));
if (!result)
return NotFound(new { success = false, error = new { code = "CONVERSATION_NOT_FOUND", message = $"Conversation {id} not found" } });
return Ok(new { success = true, message = "Conversation assigned successfully" });
}
/// <summary>
/// EN: Mark conversation as pending.
/// VI: Đánh dấu hội thoại đang chờ.
/// </summary>
[HttpPost("{id:guid}/pending")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> MarkAsPending(Guid id)
{
var result = await _mediator.Send(new MarkConversationPendingCommand(id));
if (!result)
return BadRequest(new { success = false, error = new { code = "MARK_PENDING_FAILED", message = "Failed to mark conversation as pending" } });
return Ok(new { success = true, message = "Conversation marked as pending" });
}
}
#region Request Models
public record SendMessageRequest(string Content, bool IsFromBot = false, List<string>? MediaUrls = null);
public record CloseConversationRequest(string? Reason);
public record AssignConversationRequest(Guid UserId);
#endregion

View File

@@ -0,0 +1,56 @@
using Asp.Versioning;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using MktXService.API.Application.Queries;
namespace MktXService.API.Controllers;
/// <summary>
/// EN: Controller for Template management.
/// VI: Controller quản lý template.
/// </summary>
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/[controller]")]
[Produces("application/json")]
public class TemplatesController : ControllerBase
{
private readonly IMediator _mediator;
private readonly ILogger<TemplatesController> _logger;
public TemplatesController(IMediator mediator, ILogger<TemplatesController> logger)
{
_mediator = mediator;
_logger = logger;
}
/// <summary>
/// EN: Get all templates for a merchant.
/// VI: Lấy tất cả template của merchant.
/// </summary>
[HttpGet]
[ProducesResponseType(typeof(IEnumerable<TemplateDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetTemplates(
[FromQuery] Guid merchantId,
[FromQuery] string? type = null)
{
var templates = await _mediator.Send(new GetTemplatesQuery(merchantId, type));
return Ok(new { success = true, data = templates });
}
/// <summary>
/// EN: Get a template by ID.
/// VI: Lấy template theo ID.
/// </summary>
[HttpGet("{id:guid}")]
[ProducesResponseType(typeof(TemplateDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetTemplate(Guid id)
{
var template = await _mediator.Send(new GetTemplateQuery(id));
if (template == null)
return NotFound(new { success = false, error = new { code = "TEMPLATE_NOT_FOUND", message = $"Template {id} not found" } });
return Ok(new { success = true, data = template });
}
}

View File

@@ -0,0 +1,178 @@
using System.Security.Cryptography;
using System.Text;
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using MktXService.Infrastructure.ExternalServices.Twitter;
namespace MktXService.API.Controllers;
/// <summary>
/// EN: Controller for Twitter Webhook handling.
/// VI: Controller xử lý Twitter Webhook.
/// </summary>
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/webhooks/twitter")]
[Produces("application/json")]
public class WebhooksController : ControllerBase
{
private readonly ITwitterApiClient _twitterClient;
private readonly TwitterApiOptions _options;
private readonly ILogger<WebhooksController> _logger;
public WebhooksController(
ITwitterApiClient twitterClient,
IOptions<TwitterApiOptions> options,
ILogger<WebhooksController> logger)
{
_twitterClient = twitterClient;
_options = options.Value;
_logger = logger;
}
/// <summary>
/// EN: CRC token verification for Twitter webhook.
/// VI: Xác thực CRC token cho Twitter webhook.
/// </summary>
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
public IActionResult VerifyCrcToken([FromQuery(Name = "crc_token")] string crcToken)
{
_logger.LogInformation("Received CRC challenge: {Token}", crcToken);
var response = _twitterClient.GenerateCrcResponse(crcToken);
return Ok(new { response_token = response });
}
/// <summary>
/// EN: Receive Twitter webhook events.
/// VI: Nhận events từ Twitter webhook.
/// </summary>
[HttpPost]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<IActionResult> ReceiveWebhookEvent([FromBody] TwitterWebhookPayload payload)
{
_logger.LogInformation("Received webhook event for user: {UserId}", payload.ForUserId);
// EN: Process Direct Message events
// VI: Xử lý events Direct Message
if (payload.DirectMessageEvents?.Any() == true)
{
foreach (var dmEvent in payload.DirectMessageEvents)
{
_logger.LogInformation("Received DM from {SenderId}: {MessageId}",
dmEvent.MessageCreate?.SenderId, dmEvent.Id);
// EN: In production, queue for async processing
// VI: Trong production, đưa vào hàng đợi để xử lý bất đồng bộ
}
}
// EN: Process follow events
// VI: Xử lý events follow
if (payload.FollowEvents?.Any() == true)
{
foreach (var followEvent in payload.FollowEvents)
{
_logger.LogInformation("New follower: {FollowerId}",
followEvent.Source?.Id);
}
}
return Ok();
}
/// <summary>
/// EN: Register webhook for an account.
/// VI: Đăng ký webhook cho tài khoản.
/// </summary>
[HttpPost("register")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> RegisterWebhook([FromBody] RegisterWebhookRequest request)
{
try
{
var webhookId = await _twitterClient.RegisterWebhookAsync(request.WebhookUrl);
return Ok(new { success = true, webhookId });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to register webhook");
return BadRequest(new { success = false, error = ex.Message });
}
}
/// <summary>
/// EN: Subscribe to webhook events.
/// VI: Đăng ký nhận webhook events.
/// </summary>
[HttpPost("subscribe")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> SubscribeToWebhook()
{
try
{
await _twitterClient.SubscribeToWebhookAsync();
return Ok(new { success = true, message = "Subscribed to webhook events" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to subscribe to webhook");
return BadRequest(new { success = false, error = ex.Message });
}
}
}
#region Webhook DTOs
public class TwitterWebhookPayload
{
public string? ForUserId { get; set; }
public List<DirectMessageEvent>? DirectMessageEvents { get; set; }
public List<FollowEvent>? FollowEvents { get; set; }
public Dictionary<string, TwitterUserData>? Users { get; set; }
}
public class DirectMessageEvent
{
public string? Id { get; set; }
public string? Type { get; set; }
public string? CreatedTimestamp { get; set; }
public MessageCreate? MessageCreate { get; set; }
}
public class MessageCreate
{
public string? SenderId { get; set; }
public string? Target { get; set; }
public MessageData? MessageData { get; set; }
}
public class MessageData
{
public string? Text { get; set; }
public List<object>? Entities { get; set; }
}
public class FollowEvent
{
public string? Type { get; set; }
public string? CreatedTimestamp { get; set; }
public TwitterUserData? Source { get; set; }
public TwitterUserData? Target { get; set; }
}
public class TwitterUserData
{
public string? Id { get; set; }
public string? Name { get; set; }
public string? ScreenName { get; set; }
}
public record RegisterWebhookRequest(string WebhookUrl);
#endregion

View File

@@ -40,13 +40,11 @@ public class UpdateCustomerCommandHandler : IRequestHandler<UpdateCustomerComman
!string.IsNullOrEmpty(request.PhoneNumber) ||
!string.IsNullOrEmpty(request.Email))
{
var newProfile = new CustomerProfile(
customer.UpdateProfile(
request.DisplayName ?? customer.Profile.DisplayName,
request.AvatarUrl ?? customer.Profile.AvatarUrl,
request.PhoneNumber ?? customer.Profile.PhoneNumber,
request.Email ?? customer.Profile.Email);
customer.UpdateProfile(newProfile);
}
// EN: Add tags / VI: Thêm tags

View File

@@ -72,6 +72,47 @@ public class ChatbotRulesController : ControllerBase
return CreatedAtAction(nameof(GetRules), new { id = result.RuleId }, result);
}
/// <summary>
/// EN: Delete a chatbot rule.
/// VI: Xóa quy tắc chatbot.
/// </summary>
[HttpDelete("{id:guid}")]
public async Task<IActionResult> DeleteRule(
Guid id,
CancellationToken ct = default)
{
var command = new DeleteChatbotRuleCommand(id);
var result = await _mediator.Send(command, ct);
if (!result.Success)
{
return NotFound(new { error = result.Error });
}
return NoContent();
}
/// <summary>
/// EN: Toggle chatbot rule active status.
/// VI: Bật/tắt trạng thái hoạt động quy tắc chatbot.
/// </summary>
[HttpPatch("{id:guid}/toggle")]
public async Task<IActionResult> ToggleRule(
Guid id,
[FromQuery] bool activate,
CancellationToken ct = default)
{
var command = new ToggleChatbotRuleCommand(id, activate);
var result = await _mediator.Send(command, ct);
if (!result.Success)
{
return BadRequest(new { error = result.Error });
}
return NoContent();
}
}
/// <summary>