From d285f1f9ebeb42970be051bfd7e96f345bfc434a Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Mon, 19 Jan 2026 01:19:00 +0700 Subject: [PATCH] feat: Add new WebClientBase Blazor application and MktZaloService API, and update WhatsAppService and MktXService infrastructure. --- apps/web-client-base-net/WebClientBase.slnx | 7 + .../src/WebClientBase.Client/App.razor | 6 + .../Layout/MainLayout.razor | 16 ++ .../Layout/MainLayout.razor.css | 77 ++++++ .../WebClientBase.Client/Layout/NavMenu.razor | 39 +++ .../Layout/NavMenu.razor.css | 83 ++++++ .../WebClientBase.Client/Pages/Counter.razor | 18 ++ .../src/WebClientBase.Client/Pages/Home.razor | 7 + .../WebClientBase.Client/Pages/NotFound.razor | 5 + .../WebClientBase.Client/Pages/Weather.razor | 57 ++++ .../src/WebClientBase.Client/Program.cs | 11 + .../Properties/launchSettings.json | 25 ++ .../WebClientBase.Client.csproj | 19 ++ .../src/WebClientBase.Client/_Imports.razor | 10 + .../WebClientBase.Client/wwwroot/css/app.css | 115 ++++++++ .../WebClientBase.Client/wwwroot/favicon.png | Bin 0 -> 1148 bytes .../WebClientBase.Client/wwwroot/icon-192.png | Bin 0 -> 2626 bytes .../WebClientBase.Client/wwwroot/index.html | 34 +++ .../wwwroot/sample-data/weather.json | 27 ++ .../Controllers/AuthController.cs | 86 ++++++ .../Controllers/ProductsController.cs | 95 +++++++ .../src/WebClientBase.Server/Program.cs | 73 +++++ .../Properties/launchSettings.json | 23 ++ .../WebClientBase.Server.csproj | 20 ++ .../WebClientBase.Server.http | 6 + .../appsettings.Development.json | 8 + .../src/WebClientBase.Server/appsettings.json | 9 + .../src/WebClientBase.Shared/ApiResponse.cs | 58 ++++ .../WebClientBase.Shared/DTOs/ProductDto.cs | 72 +++++ .../src/WebClientBase.Shared/DTOs/UserDto.cs | 85 ++++++ .../WebClientBase.Shared.csproj | 9 + .../Controllers/CustomersController.cs | 6 +- .../DependencyInjection.cs | 37 +++ .../WhatsAppService.Infrastructure.csproj | 2 +- .../BackgroundJobs/CampaignSchedulerJob.cs | 120 ++++++++ .../BackgroundJobs/MessageProcessorJob.cs | 210 ++++++++++++++ .../BackgroundJobs/QuartzServiceExtensions.cs | 69 +++++ .../WebhookEventProcessorJob.cs | 257 ++++++++++++++++++ .../MktXService.Infrastructure.csproj | 4 + .../Commands/Messages/SendMessageCommand.cs | 24 ++ .../Messages/SendMessageCommandHandler.cs | 92 +++++++ .../Commands/Webhook/ProcessWebhookCommand.cs | 24 ++ .../Webhook/ProcessWebhookCommandHandler.cs | 202 ++++++++++++++ .../MessageReceivedDomainEventHandler.cs | 121 +++++++++ .../GetConversationHistoryQuery.cs | 43 +++ .../GetConversationHistoryQueryHandler.cs | 55 ++++ .../Services/ChatbotRulesService.cs | 99 +++++++ .../Controllers/ConversationsController.cs | 75 +++++ .../Controllers/WebhooksController.cs | 110 ++++++++ 49 files changed, 2646 insertions(+), 4 deletions(-) create mode 100644 apps/web-client-base-net/WebClientBase.slnx create mode 100644 apps/web-client-base-net/src/WebClientBase.Client/App.razor create mode 100644 apps/web-client-base-net/src/WebClientBase.Client/Layout/MainLayout.razor create mode 100644 apps/web-client-base-net/src/WebClientBase.Client/Layout/MainLayout.razor.css create mode 100644 apps/web-client-base-net/src/WebClientBase.Client/Layout/NavMenu.razor create mode 100644 apps/web-client-base-net/src/WebClientBase.Client/Layout/NavMenu.razor.css create mode 100644 apps/web-client-base-net/src/WebClientBase.Client/Pages/Counter.razor create mode 100644 apps/web-client-base-net/src/WebClientBase.Client/Pages/Home.razor create mode 100644 apps/web-client-base-net/src/WebClientBase.Client/Pages/NotFound.razor create mode 100644 apps/web-client-base-net/src/WebClientBase.Client/Pages/Weather.razor create mode 100644 apps/web-client-base-net/src/WebClientBase.Client/Program.cs create mode 100644 apps/web-client-base-net/src/WebClientBase.Client/Properties/launchSettings.json create mode 100644 apps/web-client-base-net/src/WebClientBase.Client/WebClientBase.Client.csproj create mode 100644 apps/web-client-base-net/src/WebClientBase.Client/_Imports.razor create mode 100644 apps/web-client-base-net/src/WebClientBase.Client/wwwroot/css/app.css create mode 100644 apps/web-client-base-net/src/WebClientBase.Client/wwwroot/favicon.png create mode 100644 apps/web-client-base-net/src/WebClientBase.Client/wwwroot/icon-192.png create mode 100644 apps/web-client-base-net/src/WebClientBase.Client/wwwroot/index.html create mode 100644 apps/web-client-base-net/src/WebClientBase.Client/wwwroot/sample-data/weather.json create mode 100644 apps/web-client-base-net/src/WebClientBase.Server/Controllers/AuthController.cs create mode 100644 apps/web-client-base-net/src/WebClientBase.Server/Controllers/ProductsController.cs create mode 100644 apps/web-client-base-net/src/WebClientBase.Server/Program.cs create mode 100644 apps/web-client-base-net/src/WebClientBase.Server/Properties/launchSettings.json create mode 100644 apps/web-client-base-net/src/WebClientBase.Server/WebClientBase.Server.csproj create mode 100644 apps/web-client-base-net/src/WebClientBase.Server/WebClientBase.Server.http create mode 100644 apps/web-client-base-net/src/WebClientBase.Server/appsettings.Development.json create mode 100644 apps/web-client-base-net/src/WebClientBase.Server/appsettings.json create mode 100644 apps/web-client-base-net/src/WebClientBase.Shared/ApiResponse.cs create mode 100644 apps/web-client-base-net/src/WebClientBase.Shared/DTOs/ProductDto.cs create mode 100644 apps/web-client-base-net/src/WebClientBase.Shared/DTOs/UserDto.cs create mode 100644 apps/web-client-base-net/src/WebClientBase.Shared/WebClientBase.Shared.csproj create mode 100644 services/mkt-x-service-net/src/MktXService.Infrastructure/BackgroundJobs/CampaignSchedulerJob.cs create mode 100644 services/mkt-x-service-net/src/MktXService.Infrastructure/BackgroundJobs/MessageProcessorJob.cs create mode 100644 services/mkt-x-service-net/src/MktXService.Infrastructure/BackgroundJobs/QuartzServiceExtensions.cs create mode 100644 services/mkt-x-service-net/src/MktXService.Infrastructure/BackgroundJobs/WebhookEventProcessorJob.cs create mode 100644 services/mkt-zalo-service-net/src/MktZaloService.API/Application/Commands/Messages/SendMessageCommand.cs create mode 100644 services/mkt-zalo-service-net/src/MktZaloService.API/Application/Commands/Messages/SendMessageCommandHandler.cs create mode 100644 services/mkt-zalo-service-net/src/MktZaloService.API/Application/Commands/Webhook/ProcessWebhookCommand.cs create mode 100644 services/mkt-zalo-service-net/src/MktZaloService.API/Application/Commands/Webhook/ProcessWebhookCommandHandler.cs create mode 100644 services/mkt-zalo-service-net/src/MktZaloService.API/Application/DomainEventHandlers/MessageReceivedDomainEventHandler.cs create mode 100644 services/mkt-zalo-service-net/src/MktZaloService.API/Application/Queries/Conversations/GetConversationHistoryQuery.cs create mode 100644 services/mkt-zalo-service-net/src/MktZaloService.API/Application/Queries/Conversations/GetConversationHistoryQueryHandler.cs create mode 100644 services/mkt-zalo-service-net/src/MktZaloService.API/Application/Services/ChatbotRulesService.cs create mode 100644 services/mkt-zalo-service-net/src/MktZaloService.API/Controllers/ConversationsController.cs create mode 100644 services/mkt-zalo-service-net/src/MktZaloService.API/Controllers/WebhooksController.cs diff --git a/apps/web-client-base-net/WebClientBase.slnx b/apps/web-client-base-net/WebClientBase.slnx new file mode 100644 index 00000000..81c2e7c6 --- /dev/null +++ b/apps/web-client-base-net/WebClientBase.slnx @@ -0,0 +1,7 @@ + + + + + + + diff --git a/apps/web-client-base-net/src/WebClientBase.Client/App.razor b/apps/web-client-base-net/src/WebClientBase.Client/App.razor new file mode 100644 index 00000000..a8a79e51 --- /dev/null +++ b/apps/web-client-base-net/src/WebClientBase.Client/App.razor @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/web-client-base-net/src/WebClientBase.Client/Layout/MainLayout.razor b/apps/web-client-base-net/src/WebClientBase.Client/Layout/MainLayout.razor new file mode 100644 index 00000000..76eb7252 --- /dev/null +++ b/apps/web-client-base-net/src/WebClientBase.Client/Layout/MainLayout.razor @@ -0,0 +1,16 @@ +@inherits LayoutComponentBase +
+ + +
+
+ About +
+ +
+ @Body +
+
+
diff --git a/apps/web-client-base-net/src/WebClientBase.Client/Layout/MainLayout.razor.css b/apps/web-client-base-net/src/WebClientBase.Client/Layout/MainLayout.razor.css new file mode 100644 index 00000000..ecf25e5b --- /dev/null +++ b/apps/web-client-base-net/src/WebClientBase.Client/Layout/MainLayout.razor.css @@ -0,0 +1,77 @@ +.page { + position: relative; + display: flex; + flex-direction: column; +} + +main { + flex: 1; +} + +.sidebar { + background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%); +} + +.top-row { + background-color: #f7f7f7; + border-bottom: 1px solid #d6d5d5; + justify-content: flex-end; + height: 3.5rem; + display: flex; + align-items: center; +} + + .top-row ::deep a, .top-row ::deep .btn-link { + white-space: nowrap; + margin-left: 1.5rem; + text-decoration: none; + } + + .top-row ::deep a:hover, .top-row ::deep .btn-link:hover { + text-decoration: underline; + } + + .top-row ::deep a:first-child { + overflow: hidden; + text-overflow: ellipsis; + } + +@media (max-width: 640.98px) { + .top-row { + justify-content: space-between; + } + + .top-row ::deep a, .top-row ::deep .btn-link { + margin-left: 0; + } +} + +@media (min-width: 641px) { + .page { + flex-direction: row; + } + + .sidebar { + width: 250px; + height: 100vh; + position: sticky; + top: 0; + } + + .top-row { + position: sticky; + top: 0; + z-index: 1; + } + + .top-row.auth ::deep a:first-child { + flex: 1; + text-align: right; + width: 0; + } + + .top-row, article { + padding-left: 2rem !important; + padding-right: 1.5rem !important; + } +} diff --git a/apps/web-client-base-net/src/WebClientBase.Client/Layout/NavMenu.razor b/apps/web-client-base-net/src/WebClientBase.Client/Layout/NavMenu.razor new file mode 100644 index 00000000..fb102416 --- /dev/null +++ b/apps/web-client-base-net/src/WebClientBase.Client/Layout/NavMenu.razor @@ -0,0 +1,39 @@ + + + + +@code { + private bool collapseNavMenu = true; + + private string? NavMenuCssClass => collapseNavMenu ? "collapse" : null; + + private void ToggleNavMenu() + { + collapseNavMenu = !collapseNavMenu; + } +} diff --git a/apps/web-client-base-net/src/WebClientBase.Client/Layout/NavMenu.razor.css b/apps/web-client-base-net/src/WebClientBase.Client/Layout/NavMenu.razor.css new file mode 100644 index 00000000..617b89cc --- /dev/null +++ b/apps/web-client-base-net/src/WebClientBase.Client/Layout/NavMenu.razor.css @@ -0,0 +1,83 @@ +.navbar-toggler { + background-color: rgba(255, 255, 255, 0.1); +} + +.top-row { + min-height: 3.5rem; + background-color: rgba(0,0,0,0.4); +} + +.navbar-brand { + font-size: 1.1rem; +} + +.bi { + display: inline-block; + position: relative; + width: 1.25rem; + height: 1.25rem; + margin-right: 0.75rem; + top: -1px; + background-size: cover; +} + +.bi-house-door-fill-nav-menu { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E"); +} + +.bi-plus-square-fill-nav-menu { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E"); +} + +.bi-list-nested-nav-menu { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E"); +} + +.nav-item { + font-size: 0.9rem; + padding-bottom: 0.5rem; +} + + .nav-item:first-of-type { + padding-top: 1rem; + } + + .nav-item:last-of-type { + padding-bottom: 1rem; + } + + .nav-item ::deep a { + color: #d7d7d7; + border-radius: 4px; + height: 3rem; + display: flex; + align-items: center; + line-height: 3rem; + } + +.nav-item ::deep a.active { + background-color: rgba(255,255,255,0.37); + color: white; +} + +.nav-item ::deep a:hover { + background-color: rgba(255,255,255,0.1); + color: white; +} + +@media (min-width: 641px) { + .navbar-toggler { + display: none; + } + + .collapse { + /* Never collapse the sidebar for wide screens */ + display: block; + } + + .nav-scrollable { + /* Allow sidebar to scroll for tall menus */ + height: calc(100vh - 3.5rem); + overflow-y: auto; + } +} diff --git a/apps/web-client-base-net/src/WebClientBase.Client/Pages/Counter.razor b/apps/web-client-base-net/src/WebClientBase.Client/Pages/Counter.razor new file mode 100644 index 00000000..ef23cb31 --- /dev/null +++ b/apps/web-client-base-net/src/WebClientBase.Client/Pages/Counter.razor @@ -0,0 +1,18 @@ +@page "/counter" + +Counter + +

Counter

+ +

Current count: @currentCount

+ + + +@code { + private int currentCount = 0; + + private void IncrementCount() + { + currentCount++; + } +} diff --git a/apps/web-client-base-net/src/WebClientBase.Client/Pages/Home.razor b/apps/web-client-base-net/src/WebClientBase.Client/Pages/Home.razor new file mode 100644 index 00000000..9001e0bd --- /dev/null +++ b/apps/web-client-base-net/src/WebClientBase.Client/Pages/Home.razor @@ -0,0 +1,7 @@ +@page "/" + +Home + +

Hello, world!

+ +Welcome to your new app. diff --git a/apps/web-client-base-net/src/WebClientBase.Client/Pages/NotFound.razor b/apps/web-client-base-net/src/WebClientBase.Client/Pages/NotFound.razor new file mode 100644 index 00000000..917ada1d --- /dev/null +++ b/apps/web-client-base-net/src/WebClientBase.Client/Pages/NotFound.razor @@ -0,0 +1,5 @@ +@page "/not-found" +@layout MainLayout + +

Not Found

+

Sorry, the content you are looking for does not exist.

\ No newline at end of file diff --git a/apps/web-client-base-net/src/WebClientBase.Client/Pages/Weather.razor b/apps/web-client-base-net/src/WebClientBase.Client/Pages/Weather.razor new file mode 100644 index 00000000..3ea2b1cc --- /dev/null +++ b/apps/web-client-base-net/src/WebClientBase.Client/Pages/Weather.razor @@ -0,0 +1,57 @@ +@page "/weather" +@inject HttpClient Http + +Weather + +

Weather

+ +

This component demonstrates fetching data from the server.

+ +@if (forecasts == null) +{ +

Loading...

+} +else +{ + + + + + + + + + + + @foreach (var forecast in forecasts) + { + + + + + + + } + +
DateTemp. (C)Temp. (F)Summary
@forecast.Date.ToShortDateString()@forecast.TemperatureC@forecast.TemperatureF@forecast.Summary
+} + +@code { + private WeatherForecast[]? forecasts; + + protected override async Task OnInitializedAsync() + { + forecasts = await Http.GetFromJsonAsync("sample-data/weather.json"); + } + + public class WeatherForecast + { + public DateOnly Date { get; set; } + + public int TemperatureC { get; set; } + + public string? Summary { get; set; } + + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); + } +} diff --git a/apps/web-client-base-net/src/WebClientBase.Client/Program.cs b/apps/web-client-base-net/src/WebClientBase.Client/Program.cs new file mode 100644 index 00000000..5fa2c89f --- /dev/null +++ b/apps/web-client-base-net/src/WebClientBase.Client/Program.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using WebClientBase.Client; + +var builder = WebAssemblyHostBuilder.CreateDefault(args); +builder.RootComponents.Add("#app"); +builder.RootComponents.Add("head::after"); + +builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); + +await builder.Build().RunAsync(); diff --git a/apps/web-client-base-net/src/WebClientBase.Client/Properties/launchSettings.json b/apps/web-client-base-net/src/WebClientBase.Client/Properties/launchSettings.json new file mode 100644 index 00000000..cbfca635 --- /dev/null +++ b/apps/web-client-base-net/src/WebClientBase.Client/Properties/launchSettings.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "http://localhost:5043", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "https://localhost:7002;http://localhost:5043", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/apps/web-client-base-net/src/WebClientBase.Client/WebClientBase.Client.csproj b/apps/web-client-base-net/src/WebClientBase.Client/WebClientBase.Client.csproj new file mode 100644 index 00000000..1738905a --- /dev/null +++ b/apps/web-client-base-net/src/WebClientBase.Client/WebClientBase.Client.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + + + + diff --git a/apps/web-client-base-net/src/WebClientBase.Client/_Imports.razor b/apps/web-client-base-net/src/WebClientBase.Client/_Imports.razor new file mode 100644 index 00000000..47f34606 --- /dev/null +++ b/apps/web-client-base-net/src/WebClientBase.Client/_Imports.razor @@ -0,0 +1,10 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.AspNetCore.Components.WebAssembly.Http +@using Microsoft.JSInterop +@using WebClientBase.Client +@using WebClientBase.Client.Layout diff --git a/apps/web-client-base-net/src/WebClientBase.Client/wwwroot/css/app.css b/apps/web-client-base-net/src/WebClientBase.Client/wwwroot/css/app.css new file mode 100644 index 00000000..fe44b1d2 --- /dev/null +++ b/apps/web-client-base-net/src/WebClientBase.Client/wwwroot/css/app.css @@ -0,0 +1,115 @@ +html, body { + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; +} + +h1:focus { + outline: none; +} + +a, .btn-link { + color: #0071c1; +} + +.btn-primary { + color: #fff; + background-color: #1b6ec2; + border-color: #1861ac; +} + +.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus { + box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb; +} + +.content { + padding-top: 1.1rem; +} + +.valid.modified:not([type=checkbox]) { + outline: 1px solid #26b050; +} + +.invalid { + outline: 1px solid red; +} + +.validation-message { + color: red; +} + +#blazor-error-ui { + color-scheme: light only; + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + box-sizing: border-box; + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + + #blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; + } + +.blazor-error-boundary { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + + .blazor-error-boundary::after { + content: "An error has occurred." + } + +.loading-progress { + position: absolute; + display: block; + width: 8rem; + height: 8rem; + inset: 20vh 0 auto 0; + margin: 0 auto 0 auto; +} + + .loading-progress circle { + fill: none; + stroke: #e0e0e0; + stroke-width: 0.6rem; + transform-origin: 50% 50%; + transform: rotate(-90deg); + } + + .loading-progress circle:last-child { + stroke: #1b6ec2; + stroke-dasharray: calc(3.141 * var(--blazor-load-percentage, 0%) * 0.8), 500%; + transition: stroke-dasharray 0.05s ease-in-out; + } + +.loading-progress-text { + position: absolute; + text-align: center; + font-weight: bold; + inset: calc(20vh + 3.25rem) 0 auto 0.2rem; +} + + .loading-progress-text:after { + content: var(--blazor-load-percentage-text, "Loading"); + } + +code { + color: #c02d76; +} + +.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder { + color: var(--bs-secondary-color); + text-align: end; +} + +.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder { + text-align: start; +} \ No newline at end of file diff --git a/apps/web-client-base-net/src/WebClientBase.Client/wwwroot/favicon.png b/apps/web-client-base-net/src/WebClientBase.Client/wwwroot/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..8422b59695935d180d11d5dbe99653e711097819 GIT binary patch literal 1148 zcmV-?1cUpDP)9h26h2-Cs%i*@Moc3?#6qJID|D#|3|2Hn7gTIYEkr|%Xjp);YgvFmB&0#2E2b=| zkVr)lMv9=KqwN&%obTp-$<51T%rx*NCwceh-E+=&e(oLO`@Z~7gybJ#U|^tB2Pai} zRN@5%1qsZ1e@R(XC8n~)nU1S0QdzEYlWPdUpH{wJ2Pd4V8kI3BM=)sG^IkUXF2-j{ zrPTYA6sxpQ`Q1c6mtar~gG~#;lt=s^6_OccmRd>o{*=>)KS=lM zZ!)iG|8G0-9s3VLm`bsa6e ze*TlRxAjXtm^F8V`M1%s5d@tYS>&+_ga#xKGb|!oUBx3uc@mj1%=MaH4GR0tPBG_& z9OZE;->dO@`Q)nr<%dHAsEZRKl zedN6+3+uGHejJp;Q==pskSAcRcyh@6mjm2z-uG;s%dM-u0*u##7OxI7wwyCGpS?4U zBFAr(%GBv5j$jS@@t@iI8?ZqE36I^4t+P^J9D^ELbS5KMtZ z{Qn#JnSd$15nJ$ggkF%I4yUQC+BjDF^}AtB7w348EL>7#sAsLWs}ndp8^DsAcOIL9 zTOO!!0!k2`9BLk25)NeZp7ev>I1Mn={cWI3Yhx2Q#DnAo4IphoV~R^c0x&nw*MoIV zPthX?{6{u}sMS(MxD*dmd5rU(YazQE59b|TsB5Tm)I4a!VaN@HYOR)DwH1U5y(E)z zQqQU*B%MwtRQ$%x&;1p%ANmc|PkoFJZ%<-uq%PX&C!c-7ypis=eP+FCeuv+B@h#{4 zGx1m0PjS~FJt}3mdt4c!lel`1;4W|03kcZRG+DzkTy|7-F~eDsV2Tx!73dM0H0CTh zl)F-YUkE1zEzEW(;JXc|KR5{ox%YTh{$%F$a36JP6Nb<0%#NbSh$dMYF-{ z1_x(Vx)}fs?5_|!5xBTWiiIQHG<%)*e=45Fhjw_tlnmlixq;mUdC$R8v#j( zhQ$9YR-o%i5Uc`S?6EC51!bTRK=Xkyb<18FkCKnS2;o*qlij1YA@-nRpq#OMTX&RbL<^2q@0qja!uIvI;j$6>~k@IMwD42=8$$!+R^@5o6HX(*n~v0A9xRwxP|bki~~&uFk>U z#P+PQh zyZ;-jwXKqnKbb6)@RaxQz@vm={%t~VbaZrdbaZrdbaeEeXj>~BG?&`J0XrqR#sSlO zg~N5iUk*15JibvlR1f^^1czzNKWvoJtc!Sj*G37QXbZ8LeD{Fzxgdv#Q{x}ytfZ5q z+^k#NaEp>zX_8~aSaZ`O%B9C&YLHb(mNtgGD&Kezd5S@&C=n~Uy1NWHM`t07VQP^MopUXki{2^#ryd94>UJMYW|(#4qV`kb7eD)Q=~NN zaVIRi@|TJ!Rni8J=5DOutQ#bEyMVr8*;HU|)MEKmVC+IOiDi9y)vz=rdtAUHW$yjt zrj3B7v(>exU=IrzC<+?AE=2vI;%fafM}#ShGDZx=0Nus5QHKdyb9pw&4>4XCpa-o?P(Gnco1CGX|U> z$f+_tA3+V~<{MU^A%eP!8R*-sD9y<>Jc7A(;aC5hVbs;kX9&Sa$JMG!W_BLFQa*hM zri__C@0i0U1X#?)Y=)>JpvTnY6^s;fu#I}K9u>OldV}m!Ch`d1Vs@v9 zb}w(!TvOmSzmMBa9gYvD4xocL2r0ds6%Hs>Z& z#7#o9PGHDmfG%JQq`O5~dt|MAQN@2wyJw_@``7Giyy(yyk(m8U*kk5$X1^;3$a3}N^Lp6hE5!#8l z#~NYHmKAs6IAe&A;bvM8OochRmXN>`D`{N$%#dZCRxp4-dJ?*3P}}T`tYa3?zz5BA zTu7uE#GsDpZ$~j9q=Zq!LYjLbZPXFILZK4?S)C-zE1(dC2d<7nO4-nSCbV#9E|E1MM|V<9>i4h?WX*r*ul1 z5#k6;po8z=fdMiVVz*h+iaTlz#WOYmU^SX5#97H~B32s-#4wk<1NTN#g?LrYieCu> zF7pbOLR;q2D#Q`^t%QcY06*X-jM+ei7%ZuanUTH#9Y%FBi*Z#22({_}3^=BboIsbg zR0#jJ>9QR8SnmtSS6x($?$}6$x+q)697#m${Z@G6Ujf=6iO^S}7P`q8DkH!IHd4lB zDzwxt3BHsPAcXFFY^Fj}(073>NL_$A%v2sUW(CRutd%{G`5ow?L`XYSO*Qu?x+Gzv zBtR}Y6`XF4xX7)Z04D+fH;TMapdQFFameUuHL34NN)r@aF4RO%x&NApeWGtr#mG~M z6sEIZS;Uj1HB1*0hh=O@0q1=Ia@L>-tETu-3n(op+97E z#&~2xggrl(LA|giII;RwBlX2^Q`B{_t}gxNL;iB11gEPC>v` zb4SJ;;BFOB!{chn>?cCeGDKuqI0+!skyWTn*k!WiPNBf=8rn;@y%( znhq%8fj2eAe?`A5mP;TE&iLEmQ^xV%-kmC-8mWao&EUK_^=GW-Y3z ksi~={si~={skwfB0gq6itke#r1ONa407*qoM6N<$g11Kq@c;k- literal 0 HcmV?d00001 diff --git a/apps/web-client-base-net/src/WebClientBase.Client/wwwroot/index.html b/apps/web-client-base-net/src/WebClientBase.Client/wwwroot/index.html new file mode 100644 index 00000000..90f7165a --- /dev/null +++ b/apps/web-client-base-net/src/WebClientBase.Client/wwwroot/index.html @@ -0,0 +1,34 @@ + + + + + + + WebClientBase.Client + + + + + + + + + + +
+ + + + +
+
+ +
+ An unhandled error has occurred. + Reload + 🗙 +
+ + + + diff --git a/apps/web-client-base-net/src/WebClientBase.Client/wwwroot/sample-data/weather.json b/apps/web-client-base-net/src/WebClientBase.Client/wwwroot/sample-data/weather.json new file mode 100644 index 00000000..b7459733 --- /dev/null +++ b/apps/web-client-base-net/src/WebClientBase.Client/wwwroot/sample-data/weather.json @@ -0,0 +1,27 @@ +[ + { + "date": "2022-01-06", + "temperatureC": 1, + "summary": "Freezing" + }, + { + "date": "2022-01-07", + "temperatureC": 14, + "summary": "Bracing" + }, + { + "date": "2022-01-08", + "temperatureC": -13, + "summary": "Freezing" + }, + { + "date": "2022-01-09", + "temperatureC": -16, + "summary": "Balmy" + }, + { + "date": "2022-01-10", + "temperatureC": -2, + "summary": "Chilly" + } +] diff --git a/apps/web-client-base-net/src/WebClientBase.Server/Controllers/AuthController.cs b/apps/web-client-base-net/src/WebClientBase.Server/Controllers/AuthController.cs new file mode 100644 index 00000000..5e49b515 --- /dev/null +++ b/apps/web-client-base-net/src/WebClientBase.Server/Controllers/AuthController.cs @@ -0,0 +1,86 @@ +using Microsoft.AspNetCore.Mvc; +using WebClientBase.Shared; +using WebClientBase.Shared.DTOs; + +namespace WebClientBase.Server.Controllers; + +/// +/// EN: Authentication API controller. +/// VI: Auth API controller. +/// +[ApiController] +[Route("api/[controller]")] +public class AuthController : ControllerBase +{ + /// + /// EN: Register a new user. + /// VI: Đăng ký user mới. + /// + /// Registration data with validation + [HttpPost("register")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)] + public ActionResult> Register([FromBody] RegisterDto request) + { + // EN: [ApiController] validates before this code runs + // VI: [ApiController] validate trước khi code này chạy + + // EN: Demo - create user profile + // VI: Demo - tạo user profile + var profile = new UserProfileDto + { + Id = Guid.NewGuid(), + Email = request.Email, + DisplayName = request.DisplayName, + CreatedAt = DateTime.UtcNow + }; + + return CreatedAtAction(nameof(GetProfile), new { id = profile.Id }, ApiResponse.Ok(profile)); + } + + /// + /// EN: Login with email and password. + /// VI: Đăng nhập với email và mật khẩu. + /// + [HttpPost("login")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status401Unauthorized)] + public ActionResult> Login([FromBody] LoginDto request) + { + // EN: Demo - always succeed with mock profile + // VI: Demo - luôn thành công với profile giả + var profile = new UserProfileDto + { + Id = Guid.NewGuid(), + Email = request.Email, + DisplayName = "Demo User", + CreatedAt = DateTime.UtcNow.AddDays(-30) + }; + + return Ok(ApiResponse.Ok(profile)); + } + + /// + /// EN: Get user profile by ID. + /// VI: Lấy user profile theo ID. + /// + [HttpGet("profile/{id:guid}")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public ActionResult> GetProfile(Guid id) + { + // EN: Demo - return mock profile + // VI: Demo - trả về profile giả + var profile = new UserProfileDto + { + Id = id, + Email = "demo@example.com", + DisplayName = "Demo User", + AvatarUrl = "https://example.com/avatar.jpg", + CreatedAt = DateTime.UtcNow.AddDays(-30) + }; + + return Ok(ApiResponse.Ok(profile)); + } +} diff --git a/apps/web-client-base-net/src/WebClientBase.Server/Controllers/ProductsController.cs b/apps/web-client-base-net/src/WebClientBase.Server/Controllers/ProductsController.cs new file mode 100644 index 00000000..66b9df06 --- /dev/null +++ b/apps/web-client-base-net/src/WebClientBase.Server/Controllers/ProductsController.cs @@ -0,0 +1,95 @@ +using Microsoft.AspNetCore.Mvc; +using WebClientBase.Shared; +using WebClientBase.Shared.DTOs; + +namespace WebClientBase.Server.Controllers; + +/// +/// EN: Products API controller with automatic ModelState validation. +/// VI: Products API controller với validation ModelState tự động. +/// +/// +/// EN: The [ApiController] attribute automatically validates ModelState before action executes. +/// VI: Attribute [ApiController] tự động validate ModelState trước khi action được thực thi. +/// +[ApiController] +[Route("api/[controller]")] +public class ProductsController : ControllerBase +{ + // EN: In-memory storage for demo purposes + // VI: Lưu trữ trong bộ nhớ cho mục đích demo + private static readonly List _products = new() + { + new ProductDto { Name = "MacBook Pro", Description = "Laptop cao cấp", Price = 2499.99m, Quantity = 10 }, + new ProductDto { Name = "iPhone 15 Pro", Description = "Điện thoại thông minh", Price = 1199.99m, Quantity = 50 } + }; + + /// + /// EN: Get all products. + /// VI: Lấy tất cả sản phẩm. + /// + [HttpGet] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public ActionResult>> GetAll() + { + return Ok(ApiResponse>.Ok(_products)); + } + + /// + /// EN: Create a new product. + /// VI: Tạo sản phẩm mới. + /// + /// Product data with validation + /// + /// EN: [ApiController] automatically returns 400 if ModelState is invalid. + /// VI: [ApiController] tự động trả về 400 nếu ModelState không hợp lệ. + /// No need for: if (!ModelState.IsValid) return BadRequest(ModelState); + /// + [HttpPost] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)] + public ActionResult> Create([FromBody] CreateProductDto request) + { + // EN: Validation is automatically done by [ApiController] before reaching here + // VI: Validation được tự động thực hiện bởi [ApiController] trước khi đến đây + + var product = new ProductDto + { + Name = request.Name, + Description = request.Description, + Price = request.Price, + Quantity = request.Quantity + }; + + _products.Add(product); + + return CreatedAtAction( + nameof(GetAll), + ApiResponse.Ok(product) + ); + } + + /// + /// EN: Update an existing product. + /// VI: Cập nhật sản phẩm đã có. + /// + [HttpPut] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)] + public ActionResult> Update([FromBody] UpdateProductDto request) + { + // EN: Find and update (simplified for demo) + // VI: Tìm và cập nhật (đơn giản hóa cho demo) + var existing = _products.FirstOrDefault(p => p.Name == request.Name); + if (existing == null) + { + return NotFound(ApiResponse.Fail("Sản phẩm không tồn tại / Product not found")); + } + + existing.Description = request.Description; + existing.Price = request.Price; + existing.Quantity = request.Quantity; + + return Ok(ApiResponse.Ok(existing)); + } +} diff --git a/apps/web-client-base-net/src/WebClientBase.Server/Program.cs b/apps/web-client-base-net/src/WebClientBase.Server/Program.cs new file mode 100644 index 00000000..f9b87419 --- /dev/null +++ b/apps/web-client-base-net/src/WebClientBase.Server/Program.cs @@ -0,0 +1,73 @@ +/// +/// EN: ASP.NET Core Server with Blazor WebAssembly hosting. +/// VI: ASP.NET Core Server với Blazor WebAssembly hosting. +/// + +var builder = WebApplication.CreateBuilder(args); + +// ═══════════════════════════════════════════════════════════════════════════════ +// EN: Add services to the container +// VI: Thêm các services vào container +// ═══════════════════════════════════════════════════════════════════════════════ + +// EN: Add controllers with automatic ModelState validation via [ApiController] +// VI: Thêm controllers với validation ModelState tự động qua [ApiController] +builder.Services.AddControllers(); + +// EN: Add OpenAPI/Swagger support +// VI: Thêm hỗ trợ OpenAPI/Swagger +builder.Services.AddOpenApi(); + +// EN: Add CORS for Blazor WebAssembly client +// VI: Thêm CORS cho Blazor WebAssembly client +builder.Services.AddCors(options => +{ + options.AddPolicy("BlazorClient", policy => + { + policy.WithOrigins("https://localhost:5001", "http://localhost:5000") + .AllowAnyMethod() + .AllowAnyHeader(); + }); +}); + +// EN: Add health checks +// VI: Thêm health checks +builder.Services.AddHealthChecks(); + +var app = builder.Build(); + +// ═══════════════════════════════════════════════════════════════════════════════ +// EN: Configure the HTTP request pipeline +// VI: Cấu hình HTTP request pipeline +// ═══════════════════════════════════════════════════════════════════════════════ + +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); + app.UseDeveloperExceptionPage(); +} + +app.UseHttpsRedirection(); + +// EN: Enable CORS +// VI: Kích hoạt CORS +app.UseCors("BlazorClient"); + +// EN: Map health check endpoint +// VI: Map endpoint health check +app.MapHealthChecks("/health"); + +// EN: Map API controllers +// VI: Map các API controllers +app.MapControllers(); + +// EN: Serve Blazor WebAssembly static files (for hosted mode) +// VI: Phục vụ static files của Blazor WebAssembly (cho hosted mode) +app.UseBlazorFrameworkFiles(); +app.UseStaticFiles(); + +// EN: Fallback to index.html for SPA routing +// VI: Fallback đến index.html cho SPA routing +app.MapFallbackToFile("index.html"); + +app.Run(); diff --git a/apps/web-client-base-net/src/WebClientBase.Server/Properties/launchSettings.json b/apps/web-client-base-net/src/WebClientBase.Server/Properties/launchSettings.json new file mode 100644 index 00000000..fe335d30 --- /dev/null +++ b/apps/web-client-base-net/src/WebClientBase.Server/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5091", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7228;http://localhost:5091", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/apps/web-client-base-net/src/WebClientBase.Server/WebClientBase.Server.csproj b/apps/web-client-base-net/src/WebClientBase.Server/WebClientBase.Server.csproj new file mode 100644 index 00000000..e5e20fcb --- /dev/null +++ b/apps/web-client-base-net/src/WebClientBase.Server/WebClientBase.Server.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + diff --git a/apps/web-client-base-net/src/WebClientBase.Server/WebClientBase.Server.http b/apps/web-client-base-net/src/WebClientBase.Server/WebClientBase.Server.http new file mode 100644 index 00000000..1a44879e --- /dev/null +++ b/apps/web-client-base-net/src/WebClientBase.Server/WebClientBase.Server.http @@ -0,0 +1,6 @@ +@WebClientBase.Server_HostAddress = http://localhost:5091 + +GET {{WebClientBase.Server_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/apps/web-client-base-net/src/WebClientBase.Server/appsettings.Development.json b/apps/web-client-base-net/src/WebClientBase.Server/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/apps/web-client-base-net/src/WebClientBase.Server/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/apps/web-client-base-net/src/WebClientBase.Server/appsettings.json b/apps/web-client-base-net/src/WebClientBase.Server/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/apps/web-client-base-net/src/WebClientBase.Server/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/apps/web-client-base-net/src/WebClientBase.Shared/ApiResponse.cs b/apps/web-client-base-net/src/WebClientBase.Shared/ApiResponse.cs new file mode 100644 index 00000000..81e70639 --- /dev/null +++ b/apps/web-client-base-net/src/WebClientBase.Shared/ApiResponse.cs @@ -0,0 +1,58 @@ +namespace WebClientBase.Shared; + +/// +/// EN: Standard API response wrapper for consistent response format. +/// VI: Wrapper response API chuẩn cho định dạng response nhất quán. +/// +/// The type of data in the response. +public class ApiResponse +{ + /// + /// EN: Indicates if the request was successful. + /// VI: Cho biết request có thành công không. + /// + public bool Success { get; set; } + + /// + /// EN: The response data. + /// VI: Dữ liệu response. + /// + public T? Data { get; set; } + + /// + /// EN: Error message if request failed. + /// VI: Thông báo lỗi nếu request thất bại. + /// + public string? Error { get; set; } + + /// + /// EN: Creates a successful response with data. + /// VI: Tạo response thành công với dữ liệu. + /// + public static ApiResponse Ok(T data) => new() { Success = true, Data = data }; + + /// + /// EN: Creates a failed response with error message. + /// VI: Tạo response thất bại với thông báo lỗi. + /// + public static ApiResponse Fail(string error) => new() { Success = false, Error = error }; +} + +/// +/// EN: Non-generic API response for operations without return data. +/// VI: API response không generic cho các operation không có dữ liệu trả về. +/// +public class ApiResponse : ApiResponse +{ + /// + /// EN: Creates a successful response. + /// VI: Tạo response thành công. + /// + public static new ApiResponse Ok() => new() { Success = true }; + + /// + /// EN: Creates a failed response with error message. + /// VI: Tạo response thất bại với thông báo lỗi. + /// + public static new ApiResponse Fail(string error) => new() { Success = false, Error = error }; +} diff --git a/apps/web-client-base-net/src/WebClientBase.Shared/DTOs/ProductDto.cs b/apps/web-client-base-net/src/WebClientBase.Shared/DTOs/ProductDto.cs new file mode 100644 index 00000000..b11c89ee --- /dev/null +++ b/apps/web-client-base-net/src/WebClientBase.Shared/DTOs/ProductDto.cs @@ -0,0 +1,72 @@ +using System.ComponentModel.DataAnnotations; + +namespace WebClientBase.Shared.DTOs; + +/// +/// EN: Product data transfer object with validation. +/// VI: DTO sản phẩm với validation. +/// +/// +/// EN: This DTO is shared between Client and Server for consistent validation. +/// VI: DTO này được chia sẻ giữa Client và Server để validation nhất quán. +/// +public class ProductDto +{ + /// + /// EN: Product name, required with length constraints. + /// VI: Tên sản phẩm, bắt buộc với ràng buộc độ dài. + /// + [Required(ErrorMessage = "Tên sản phẩm là bắt buộc / Name is required")] + [StringLength(100, MinimumLength = 3, ErrorMessage = "Tên phải từ 3-100 ký tự / Name must be 3-100 chars")] + public string Name { get; set; } = string.Empty; + + /// + /// EN: Product description. + /// VI: Mô tả sản phẩm. + /// + [StringLength(500, ErrorMessage = "Mô tả tối đa 500 ký tự / Max 500 characters")] + public string? Description { get; set; } + + /// + /// EN: Product price, must be positive. + /// VI: Giá sản phẩm, phải dương. + /// + [Required(ErrorMessage = "Giá là bắt buộc / Price is required")] + [Range(0.01, 1_000_000_000, ErrorMessage = "Giá phải từ 0.01 đến 1 tỷ / Price must be 0.01 to 1B")] + public decimal Price { get; set; } + + /// + /// EN: Stock quantity. + /// VI: Số lượng tồn kho. + /// + [Range(0, int.MaxValue, ErrorMessage = "Số lượng không âm / Quantity must be non-negative")] + public int Quantity { get; set; } +} + +/// +/// EN: Create product request DTO. +/// VI: DTO request tạo sản phẩm. +/// +public class CreateProductDto : ProductDto +{ + /// + /// EN: Product category ID. + /// VI: ID danh mục sản phẩm. + /// + [Required(ErrorMessage = "Danh mục là bắt buộc / Category is required")] + public Guid CategoryId { get; set; } +} + +/// +/// EN: Update product request DTO. +/// VI: DTO request cập nhật sản phẩm. +/// +public class UpdateProductDto : ProductDto +{ + /// + /// EN: Product ID. + /// VI: ID sản phẩm. + /// + [Required] + public Guid Id { get; set; } +} diff --git a/apps/web-client-base-net/src/WebClientBase.Shared/DTOs/UserDto.cs b/apps/web-client-base-net/src/WebClientBase.Shared/DTOs/UserDto.cs new file mode 100644 index 00000000..43b99a11 --- /dev/null +++ b/apps/web-client-base-net/src/WebClientBase.Shared/DTOs/UserDto.cs @@ -0,0 +1,85 @@ +using System.ComponentModel.DataAnnotations; + +namespace WebClientBase.Shared.DTOs; + +/// +/// EN: User registration DTO with validation. +/// VI: DTO đăng ký user với validation. +/// +public class RegisterDto +{ + /// + /// EN: User email address. + /// VI: Địa chỉ email user. + /// + [Required(ErrorMessage = "Email là bắt buộc / Email is required")] + [EmailAddress(ErrorMessage = "Email không hợp lệ / Invalid email format")] + public string Email { get; set; } = string.Empty; + + /// + /// EN: User password with strength requirements. + /// VI: Mật khẩu user với yêu cầu độ mạnh. + /// + [Required(ErrorMessage = "Mật khẩu là bắt buộc / Password is required")] + [StringLength(100, MinimumLength = 8, ErrorMessage = "Mật khẩu phải từ 8-100 ký tự / Password must be 8-100 chars")] + [RegularExpression(@"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$", + ErrorMessage = "Mật khẩu phải có chữ hoa, chữ thường và số / Password must have upper, lower and digit")] + public string Password { get; set; } = string.Empty; + + /// + /// EN: Password confirmation must match. + /// VI: Xác nhận mật khẩu phải khớp. + /// + [Required(ErrorMessage = "Xác nhận mật khẩu là bắt buộc / Confirm password is required")] + [Compare(nameof(Password), ErrorMessage = "Mật khẩu không khớp / Passwords do not match")] + public string ConfirmPassword { get; set; } = string.Empty; + + /// + /// EN: User display name. + /// VI: Tên hiển thị của user. + /// + [Required(ErrorMessage = "Tên là bắt buộc / Name is required")] + [StringLength(50, MinimumLength = 2, ErrorMessage = "Tên phải từ 2-50 ký tự / Name must be 2-50 chars")] + public string DisplayName { get; set; } = string.Empty; +} + +/// +/// EN: User login DTO. +/// VI: DTO đăng nhập user. +/// +public class LoginDto +{ + /// + /// EN: User email address. + /// VI: Địa chỉ email user. + /// + [Required(ErrorMessage = "Email là bắt buộc / Email is required")] + [EmailAddress(ErrorMessage = "Email không hợp lệ / Invalid email format")] + public string Email { get; set; } = string.Empty; + + /// + /// EN: User password. + /// VI: Mật khẩu user. + /// + [Required(ErrorMessage = "Mật khẩu là bắt buộc / Password is required")] + public string Password { get; set; } = string.Empty; + + /// + /// EN: Remember me option. + /// VI: Tùy chọn ghi nhớ đăng nhập. + /// + public bool RememberMe { get; set; } +} + +/// +/// EN: User profile DTO. +/// VI: DTO hồ sơ user. +/// +public class UserProfileDto +{ + public Guid Id { get; set; } + public string Email { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + public string? AvatarUrl { get; set; } + public DateTime CreatedAt { get; set; } +} diff --git a/apps/web-client-base-net/src/WebClientBase.Shared/WebClientBase.Shared.csproj b/apps/web-client-base-net/src/WebClientBase.Shared/WebClientBase.Shared.csproj new file mode 100644 index 00000000..b7601447 --- /dev/null +++ b/apps/web-client-base-net/src/WebClientBase.Shared/WebClientBase.Shared.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/services/mkt-whatsapp-service-net/src/WhatsAppService.API/Controllers/CustomersController.cs b/services/mkt-whatsapp-service-net/src/WhatsAppService.API/Controllers/CustomersController.cs index 4b1b8183..c8b0741f 100644 --- a/services/mkt-whatsapp-service-net/src/WhatsAppService.API/Controllers/CustomersController.cs +++ b/services/mkt-whatsapp-service-net/src/WhatsAppService.API/Controllers/CustomersController.cs @@ -126,7 +126,7 @@ public class CustomersController : ControllerBase { foreach (var field in request.CustomFields) { - customer.SetCustomField(field.Key, field.Value); + customer.UpdateCustomField(field.Key, field.Value); } } @@ -151,11 +151,11 @@ public class CustomersController : ControllerBase if (request.OptIn) { - customer.OptIn(request.Source ?? "api"); + customer.UpdateOptIn("opted_in", request.Source ?? "api"); } else { - customer.OptOut(request.Source ?? "api"); + customer.UpdateOptIn("opted_out", request.Source ?? "api"); } await _repository.UnitOfWork.SaveEntitiesAsync(); diff --git a/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/DependencyInjection.cs b/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/DependencyInjection.cs index c52f8d15..efd16d74 100644 --- a/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/DependencyInjection.cs +++ b/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/DependencyInjection.cs @@ -1,7 +1,15 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Polly; +using Polly.Extensions.Http; +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.Infrastructure.ExternalServices; using WhatsAppService.Infrastructure.Idempotency; using WhatsAppService.Infrastructure.Repositories; @@ -48,10 +56,39 @@ public static class DependencyInjection // EN: Register repositories / VI: Đăng ký repositories services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // EN: Register external services / VI: Đăng ký external services + services.AddHttpClient() + .AddPolicyHandler(GetRetryPolicy()) + .AddPolicyHandler(GetCircuitBreakerPolicy()); + + // EN: Register LLM service / VI: Đăng ký LLM service + services.Configure(configuration.GetSection("OpenAI")); + services.AddScoped(); // EN: Register idempotency services / VI: Đăng ký idempotency services services.AddScoped(); return services; } + + private static IAsyncPolicy GetRetryPolicy() + { + return HttpPolicyExtensions + .HandleTransientHttpError() + .WaitAndRetryAsync(3, retryAttempt => + TimeSpan.FromSeconds(Math.Pow(2, retryAttempt))); + } + + private static IAsyncPolicy GetCircuitBreakerPolicy() + { + return HttpPolicyExtensions + .HandleTransientHttpError() + .CircuitBreakerAsync(5, TimeSpan.FromSeconds(30)); + } } diff --git a/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/WhatsAppService.Infrastructure.csproj b/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/WhatsAppService.Infrastructure.csproj index a29ca9ed..c4d20341 100644 --- a/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/WhatsAppService.Infrastructure.csproj +++ b/services/mkt-whatsapp-service-net/src/WhatsAppService.Infrastructure/WhatsAppService.Infrastructure.csproj @@ -22,7 +22,7 @@ - + diff --git a/services/mkt-x-service-net/src/MktXService.Infrastructure/BackgroundJobs/CampaignSchedulerJob.cs b/services/mkt-x-service-net/src/MktXService.Infrastructure/BackgroundJobs/CampaignSchedulerJob.cs new file mode 100644 index 00000000..a9a4fa98 --- /dev/null +++ b/services/mkt-x-service-net/src/MktXService.Infrastructure/BackgroundJobs/CampaignSchedulerJob.cs @@ -0,0 +1,120 @@ +using Microsoft.Extensions.Logging; +using MktXService.Domain.AggregatesModel.CampaignAggregate; +using MktXService.Domain.AggregatesModel.SegmentAggregate; +using MktXService.Domain.AggregatesModel.ContactAggregate; +using MktXService.Infrastructure.ExternalServices.Twitter; +using Quartz; + +namespace MktXService.Infrastructure.BackgroundJobs; + +/// +/// EN: Background job for processing scheduled campaigns. +/// VI: Background job để xử lý các chiến dịch đã lên lịch. +/// +/// +/// EN: Runs periodically to check for campaigns that need to start based on schedule. +/// VI: Chạy định kỳ để kiểm tra các chiến dịch cần bắt đầu dựa trên lịch trình. +/// +[DisallowConcurrentExecution] +public class CampaignSchedulerJob : IJob +{ + private readonly ICampaignRepository _campaignRepository; + private readonly ISegmentRepository _segmentRepository; + private readonly IContactRepository _contactRepository; + private readonly ILogger _logger; + + public CampaignSchedulerJob( + ICampaignRepository campaignRepository, + ISegmentRepository segmentRepository, + IContactRepository contactRepository, + ILogger logger) + { + _campaignRepository = campaignRepository; + _segmentRepository = segmentRepository; + _contactRepository = contactRepository; + _logger = logger; + } + + public async Task Execute(IJobExecutionContext context) + { + _logger.LogInformation("CampaignSchedulerJob started at {Time}", DateTimeOffset.UtcNow); + + try + { + // EN: Get all scheduled campaigns that should start now + // VI: Lấy tất cả chiến dịch đã lên lịch cần bắt đầu + var campaignsToStart = await _campaignRepository.GetScheduledCampaignsAsync(DateTime.UtcNow); + + foreach (var campaign in campaignsToStart) + { + try + { + await ProcessCampaignAsync(campaign, context.CancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to start campaign {CampaignId}", campaign.Id); + } + } + + _logger.LogInformation("CampaignSchedulerJob completed. Processed {Count} campaigns", + campaignsToStart.Count()); + } + catch (Exception ex) + { + _logger.LogError(ex, "CampaignSchedulerJob failed"); + throw new JobExecutionException(ex, refireImmediately: false); + } + } + + private async Task ProcessCampaignAsync(Campaign campaign, CancellationToken cancellationToken) + { + _logger.LogInformation("Starting campaign {CampaignId}: {Name}", campaign.Id, campaign.Name); + + // EN: Start the campaign + // VI: Bắt đầu chiến dịch + campaign.Start(); + + // EN: Get all segments for this campaign + // VI: Lấy tất cả phân khúc cho chiến dịch này + var segments = await _segmentRepository.GetByIdsAsync(campaign.SegmentIds); + + // EN: Calculate total contacts + // VI: Tính tổng số liên hệ + var totalContacts = 0; + foreach (var segment in segments) + { + // EN: In production, apply segment conditions to query contacts + // VI: Trong production, áp dụng điều kiện phân khúc để truy vấn liên hệ + totalContacts += segment.ContactCount; + } + + _logger.LogInformation("Campaign {CampaignId} targeting {ContactCount} contacts from {SegmentCount} segments", + campaign.Id, totalContacts, segments.Count()); + + // EN: Queue messages for processing (will be handled by MessageProcessorJob) + // VI: Đưa tin nhắn vào hàng đợi để xử lý (sẽ được MessageProcessorJob xử lý) + // In production, this would publish to a message queue like RabbitMQ + + await _campaignRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + } +} + +/// +/// EN: Schedule configuration for CampaignSchedulerJob. +/// VI: Cấu hình lịch trình cho CampaignSchedulerJob. +/// +public static class CampaignSchedulerJobSetup +{ + public static void ConfigureJob(IServiceCollectionQuartzConfigurator configurator) + { + var jobKey = new JobKey(nameof(CampaignSchedulerJob)); + + configurator.AddJob(opts => opts.WithIdentity(jobKey)); + + configurator.AddTrigger(opts => opts + .ForJob(jobKey) + .WithIdentity($"{nameof(CampaignSchedulerJob)}-trigger") + .WithCronSchedule("0 * * ? * *")); // EN: Every minute / VI: Mỗi phút + } +} diff --git a/services/mkt-x-service-net/src/MktXService.Infrastructure/BackgroundJobs/MessageProcessorJob.cs b/services/mkt-x-service-net/src/MktXService.Infrastructure/BackgroundJobs/MessageProcessorJob.cs new file mode 100644 index 00000000..f895f724 --- /dev/null +++ b/services/mkt-x-service-net/src/MktXService.Infrastructure/BackgroundJobs/MessageProcessorJob.cs @@ -0,0 +1,210 @@ +using Microsoft.Extensions.Logging; +using MktXService.Domain.AggregatesModel.CampaignAggregate; +using MktXService.Domain.AggregatesModel.ContactAggregate; +using MktXService.Domain.AggregatesModel.TemplateAggregate; +using MktXService.Infrastructure.ExternalServices.Twitter; +using Quartz; + +namespace MktXService.Infrastructure.BackgroundJobs; + +/// +/// EN: Background job for processing message queue and sending to Twitter. +/// VI: Background job để xử lý hàng đợi tin nhắn và gửi đến Twitter. +/// +/// +/// EN: Handles rate limiting and batch processing for campaign messages. +/// VI: Xử lý rate limiting và xử lý hàng loạt cho tin nhắn chiến dịch. +/// +[DisallowConcurrentExecution] +public class MessageProcessorJob : IJob +{ + private readonly ITwitterApiClient _twitterClient; + private readonly ITemplateRepository _templateRepository; + private readonly ICampaignRepository _campaignRepository; + private readonly IContactRepository _contactRepository; + private readonly ILogger _logger; + + // EN: Twitter DM rate limit: 500 per 24 hours per user + // VI: Giới hạn DM Twitter: 500 mỗi 24 giờ cho mỗi người dùng + private const int MaxMessagesPerBatch = 50; + private const int DelayBetweenMessagesMs = 1000; + + public MessageProcessorJob( + ITwitterApiClient twitterClient, + ITemplateRepository templateRepository, + ICampaignRepository campaignRepository, + IContactRepository contactRepository, + ILogger logger) + { + _twitterClient = twitterClient; + _templateRepository = templateRepository; + _campaignRepository = campaignRepository; + _contactRepository = contactRepository; + _logger = logger; + } + + public async Task Execute(IJobExecutionContext context) + { + _logger.LogInformation("MessageProcessorJob started at {Time}", DateTimeOffset.UtcNow); + + try + { + // EN: Get all running campaigns + // VI: Lấy tất cả chiến dịch đang chạy + var runningCampaigns = await GetRunningCampaignsAsync(); + + foreach (var campaign in runningCampaigns) + { + if (context.CancellationToken.IsCancellationRequested) + break; + + await ProcessCampaignMessagesAsync(campaign, context.CancellationToken); + } + + _logger.LogInformation("MessageProcessorJob completed"); + } + catch (Exception ex) + { + _logger.LogError(ex, "MessageProcessorJob failed"); + throw new JobExecutionException(ex, refireImmediately: false); + } + } + + private async Task> GetRunningCampaignsAsync() + { + // EN: In production, this would query campaigns with status = Running + // VI: Trong production, sẽ truy vấn các chiến dịch có status = Running + // For now, return empty - the actual implementation depends on campaign state tracking + return await Task.FromResult(Enumerable.Empty()); + } + + private async Task ProcessCampaignMessagesAsync(Campaign campaign, CancellationToken cancellationToken) + { + _logger.LogInformation("Processing messages for campaign {CampaignId}", campaign.Id); + + if (!campaign.TemplateId.HasValue) + { + _logger.LogWarning("Campaign {CampaignId} has no template", campaign.Id); + return; + } + + // EN: Get the template + // VI: Lấy template + var template = await _templateRepository.GetByIdAsync(campaign.TemplateId.Value); + if (template == null) + { + _logger.LogWarning("Template {TemplateId} not found for campaign {CampaignId}", + campaign.TemplateId, campaign.Id); + return; + } + + // EN: Get pending contacts from campaign queue (in production, from message queue) + // VI: Lấy các liên hệ đang chờ từ hàng đợi chiến dịch (trong production, từ message queue) + var pendingContacts = await GetPendingContactsAsync(campaign, MaxMessagesPerBatch); + + var sentCount = 0; + var failedCount = 0; + + foreach (var contact in pendingContacts) + { + if (cancellationToken.IsCancellationRequested) + break; + + try + { + // EN: Render the template with contact data + // VI: Render template với dữ liệu liên hệ + var variables = new Dictionary + { + ["username"] = contact.Username, + ["displayName"] = contact.DisplayName ?? contact.Username, + ["firstName"] = ExtractFirstName(contact.DisplayName) + }; + + var messageContent = template.Render(variables); + + // EN: Send the message + // VI: Gửi tin nhắn + var result = await _twitterClient.SendDirectMessageAsync( + contact.TwitterUserId, + messageContent, + cancellationToken); + + if (result.Success) + { + sentCount++; + _logger.LogDebug("Sent message to {Username}", contact.Username); + } + else + { + failedCount++; + _logger.LogWarning("Failed to send message to {Username}: {Error}", + contact.Username, result.Error); + } + + // EN: Respect rate limiting + // VI: Tuân thủ rate limiting + await Task.Delay(DelayBetweenMessagesMs, cancellationToken); + } + catch (Exception ex) + { + failedCount++; + _logger.LogError(ex, "Error sending message to contact {ContactId}", contact.Id); + } + } + + // EN: Update campaign metrics + // VI: Cập nhật metrics chiến dịch + var currentMetrics = campaign.Metrics; + var newMetrics = new CampaignMetrics( + currentMetrics.TotalSent + sentCount, + currentMetrics.Delivered + sentCount, + currentMetrics.Opened, + currentMetrics.Clicked, + currentMetrics.Replied, + currentMetrics.Failed + failedCount); + + campaign.UpdateMetrics(newMetrics); + await _campaignRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation("Campaign {CampaignId}: Sent {Sent}, Failed {Failed}", + campaign.Id, sentCount, failedCount); + } + + private async Task> GetPendingContactsAsync(Campaign campaign, int limit) + { + // EN: In production, this would query from a message queue or campaign state table + // VI: Trong production, sẽ truy vấn từ message queue hoặc bảng trạng thái chiến dịch + return await Task.FromResult(Enumerable.Empty()); + } + + private static string ExtractFirstName(string? displayName) + { + if (string.IsNullOrWhiteSpace(displayName)) + return ""; + + var parts = displayName.Split(' ', StringSplitOptions.RemoveEmptyEntries); + return parts.Length > 0 ? parts[0] : ""; + } +} + +/// +/// EN: Schedule configuration for MessageProcessorJob. +/// VI: Cấu hình lịch trình cho MessageProcessorJob. +/// +public static class MessageProcessorJobSetup +{ + public static void ConfigureJob(IServiceCollectionQuartzConfigurator configurator) + { + var jobKey = new JobKey(nameof(MessageProcessorJob)); + + configurator.AddJob(opts => opts.WithIdentity(jobKey)); + + configurator.AddTrigger(opts => opts + .ForJob(jobKey) + .WithIdentity($"{nameof(MessageProcessorJob)}-trigger") + .WithSimpleSchedule(x => x + .WithIntervalInSeconds(30) + .RepeatForever())); + } +} diff --git a/services/mkt-x-service-net/src/MktXService.Infrastructure/BackgroundJobs/QuartzServiceExtensions.cs b/services/mkt-x-service-net/src/MktXService.Infrastructure/BackgroundJobs/QuartzServiceExtensions.cs new file mode 100644 index 00000000..18fe455a --- /dev/null +++ b/services/mkt-x-service-net/src/MktXService.Infrastructure/BackgroundJobs/QuartzServiceExtensions.cs @@ -0,0 +1,69 @@ +using Microsoft.Extensions.DependencyInjection; +using Quartz; + +namespace MktXService.Infrastructure.BackgroundJobs; + +/// +/// EN: Extension methods for configuring Quartz.NET background jobs. +/// VI: Các phương thức mở rộng để cấu hình Quartz.NET background jobs. +/// +public static class QuartzServiceExtensions +{ + /// + /// EN: Add all background jobs with Quartz.NET. + /// VI: Thêm tất cả background jobs với Quartz.NET. + /// + public static IServiceCollection AddMktXBackgroundJobs( + this IServiceCollection services, + BackgroundJobOptions? options = null) + { + options ??= new BackgroundJobOptions(); + + services.AddQuartz(q => + { + if (options.EnableCampaignScheduler) + { + CampaignSchedulerJobSetup.ConfigureJob(q); + } + + if (options.EnableMessageProcessor) + { + MessageProcessorJobSetup.ConfigureJob(q); + } + + if (options.EnableWebhookProcessor) + { + WebhookEventProcessorJobSetup.ConfigureJob(q); + } + }); + + services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true); + + return services; + } +} + +/// +/// EN: Options for configuring background jobs. +/// VI: Tùy chọn để cấu hình background jobs. +/// +public class BackgroundJobOptions +{ + /// + /// EN: Enable campaign scheduler job (default: true). + /// VI: Bật job lập lịch chiến dịch (mặc định: true). + /// + public bool EnableCampaignScheduler { get; set; } = true; + + /// + /// EN: Enable message processor job (default: true). + /// VI: Bật job xử lý tin nhắn (mặc định: true). + /// + public bool EnableMessageProcessor { get; set; } = true; + + /// + /// EN: Enable webhook event processor job (default: true). + /// VI: Bật job xử lý webhook events (mặc định: true). + /// + public bool EnableWebhookProcessor { get; set; } = true; +} diff --git a/services/mkt-x-service-net/src/MktXService.Infrastructure/BackgroundJobs/WebhookEventProcessorJob.cs b/services/mkt-x-service-net/src/MktXService.Infrastructure/BackgroundJobs/WebhookEventProcessorJob.cs new file mode 100644 index 00000000..f8beaa06 --- /dev/null +++ b/services/mkt-x-service-net/src/MktXService.Infrastructure/BackgroundJobs/WebhookEventProcessorJob.cs @@ -0,0 +1,257 @@ +using Microsoft.Extensions.Logging; +using MediatR; +using MktXService.Domain.AggregatesModel.ContactAggregate; +using MktXService.Domain.AggregatesModel.ConversationAggregate; +using MktXService.Domain.AggregatesModel.TwitterAccountAggregate; +using MktXService.Domain.Events; +using MktXService.Infrastructure.ExternalServices.OpenAI; +using Quartz; + +namespace MktXService.Infrastructure.BackgroundJobs; + +/// +/// EN: Background job for processing Twitter webhook events. +/// VI: Background job để xử lý các webhook events từ Twitter. +/// +/// +/// EN: Processes incoming DMs, follows, and other Twitter events asynchronously. +/// VI: Xử lý các DM đến, follows, và các events Twitter khác một cách bất đồng bộ. +/// +[DisallowConcurrentExecution] +public class WebhookEventProcessorJob : IJob +{ + private readonly ITwitterAccountRepository _accountRepository; + private readonly IContactRepository _contactRepository; + private readonly IConversationRepository _conversationRepository; + private readonly IAIServiceClient _aiClient; + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public WebhookEventProcessorJob( + ITwitterAccountRepository accountRepository, + IContactRepository contactRepository, + IConversationRepository conversationRepository, + IAIServiceClient aiClient, + IMediator mediator, + ILogger logger) + { + _accountRepository = accountRepository; + _contactRepository = contactRepository; + _conversationRepository = conversationRepository; + _aiClient = aiClient; + _mediator = mediator; + _logger = logger; + } + + public async Task Execute(IJobExecutionContext context) + { + _logger.LogDebug("WebhookEventProcessorJob started at {Time}", DateTimeOffset.UtcNow); + + // EN: In production, this would poll from a message queue (RabbitMQ/Redis) + // VI: Trong production, sẽ poll từ message queue (RabbitMQ/Redis) + var events = await GetPendingWebhookEventsAsync(); + + foreach (var evt in events) + { + if (context.CancellationToken.IsCancellationRequested) + break; + + try + { + await ProcessEventAsync(evt, context.CancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to process webhook event {EventType}", evt.EventType); + } + } + } + + private async Task> GetPendingWebhookEventsAsync() + { + // EN: Placeholder - In production, dequeue from message broker + // VI: Placeholder - Trong production, dequeue từ message broker + return await Task.FromResult(Enumerable.Empty()); + } + + private async Task ProcessEventAsync(WebhookEvent evt, CancellationToken cancellationToken) + { + switch (evt.EventType) + { + case WebhookEventType.DirectMessageCreate: + await ProcessDirectMessageAsync(evt, cancellationToken); + break; + case WebhookEventType.Follow: + await ProcessFollowAsync(evt, cancellationToken); + break; + case WebhookEventType.Unfollow: + await ProcessUnfollowAsync(evt, cancellationToken); + break; + default: + _logger.LogDebug("Unhandled event type: {EventType}", evt.EventType); + break; + } + } + + private async Task ProcessDirectMessageAsync(WebhookEvent evt, CancellationToken cancellationToken) + { + var data = evt.Data as DirectMessageEventData; + if (data == null) return; + + _logger.LogInformation("Processing DM from {SenderId}", data.SenderId); + + // EN: Find or create contact + // VI: Tìm hoặc tạo liên hệ + var contact = await _contactRepository.GetByTwitterUserIdAsync(evt.AccountId, data.SenderId); + if (contact == null) + { + contact = new Contact( + evt.AccountId, + data.SenderId, + data.SenderUsername ?? "unknown", + "dm", // source + data.SenderName, + null); + _contactRepository.Add(contact); + } + + // EN: Find or create conversation + // VI: Tìm hoặc tạo hội thoại + var conversation = await _conversationRepository.GetOpenByContactIdAsync(contact.Id); + if (conversation == null) + { + conversation = new Conversation(contact.Id, evt.AccountId, "dm"); + _conversationRepository.Add(conversation); + } + + // EN: Add the message + // VI: Thêm tin nhắn + var message = conversation.AddMessage( + "inbound", + "text", + data.Text ?? "", + false, + data.MessageId); + + // EN: Detect intent if AI is enabled + // VI: Phát hiện ý định nếu AI được bật + if (!string.IsNullOrEmpty(data.Text)) + { + var intentResult = await _aiClient.DetectIntentAsync( + data.Text, + new[] { "greeting", "question", "support", "order", "feedback", "other" }, + cancellationToken); + + _logger.LogInformation("Detected intent: {Intent} (confidence: {Confidence})", + intentResult.Intent, intentResult.Confidence); + + // EN: Publish domain event for further processing + // VI: Publish domain event để xử lý tiếp + await _mediator.Publish(new MessageReceivedDomainEvent(message), cancellationToken); + } + + await _conversationRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + } + + private async Task ProcessFollowAsync(WebhookEvent evt, CancellationToken cancellationToken) + { + var data = evt.Data as FollowEventData; + if (data == null) return; + + _logger.LogInformation("New follower: {FollowerId}", data.FollowerId); + + // EN: Create contact for new follower + // VI: Tạo liên hệ cho follower mới + var existingContact = await _contactRepository.GetByTwitterUserIdAsync(evt.AccountId, data.FollowerId); + if (existingContact == null) + { + var contact = new Contact( + evt.AccountId, + data.FollowerId, + data.FollowerUsername ?? "unknown", + "follow", // source + data.FollowerName, + null); + contact.AddTag("follower"); + _contactRepository.Add(contact); + + await _contactRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + } + } + + private async Task ProcessUnfollowAsync(WebhookEvent evt, CancellationToken cancellationToken) + { + var data = evt.Data as FollowEventData; + if (data == null) return; + + _logger.LogInformation("Lost follower: {FollowerId}", data.FollowerId); + + var contact = await _contactRepository.GetByTwitterUserIdAsync(evt.AccountId, data.FollowerId); + if (contact != null) + { + contact.RemoveTag("follower"); + contact.AddTag("unfollowed"); + await _contactRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + } + } +} + +#region Event DTOs + +public enum WebhookEventType +{ + DirectMessageCreate, + DirectMessageRead, + Follow, + Unfollow, + Mention, + Like +} + +public class WebhookEvent +{ + public Guid AccountId { get; set; } + public WebhookEventType EventType { get; set; } + public object? Data { get; set; } + public DateTime ReceivedAt { get; set; } +} + +public class DirectMessageEventData +{ + public string MessageId { get; set; } = string.Empty; + public string SenderId { get; set; } = string.Empty; + public string? SenderUsername { get; set; } + public string? SenderName { get; set; } + public string? Text { get; set; } + public List? MediaUrls { get; set; } +} + +public class FollowEventData +{ + public string FollowerId { get; set; } = string.Empty; + public string? FollowerUsername { get; set; } + public string? FollowerName { get; set; } +} + +#endregion + +/// +/// EN: Schedule configuration for WebhookEventProcessorJob. +/// VI: Cấu hình lịch trình cho WebhookEventProcessorJob. +/// +public static class WebhookEventProcessorJobSetup +{ + public static void ConfigureJob(IServiceCollectionQuartzConfigurator configurator) + { + var jobKey = new JobKey(nameof(WebhookEventProcessorJob)); + + configurator.AddJob(opts => opts.WithIdentity(jobKey)); + + configurator.AddTrigger(opts => opts + .ForJob(jobKey) + .WithIdentity($"{nameof(WebhookEventProcessorJob)}-trigger") + .WithSimpleSchedule(x => x + .WithIntervalInSeconds(5) + .RepeatForever())); + } +} diff --git a/services/mkt-x-service-net/src/MktXService.Infrastructure/MktXService.Infrastructure.csproj b/services/mkt-x-service-net/src/MktXService.Infrastructure/MktXService.Infrastructure.csproj index 4bc735f7..91686e75 100644 --- a/services/mkt-x-service-net/src/MktXService.Infrastructure/MktXService.Infrastructure.csproj +++ b/services/mkt-x-service-net/src/MktXService.Infrastructure/MktXService.Infrastructure.csproj @@ -27,6 +27,10 @@ + + + + diff --git a/services/mkt-zalo-service-net/src/MktZaloService.API/Application/Commands/Messages/SendMessageCommand.cs b/services/mkt-zalo-service-net/src/MktZaloService.API/Application/Commands/Messages/SendMessageCommand.cs new file mode 100644 index 00000000..3e4496fe --- /dev/null +++ b/services/mkt-zalo-service-net/src/MktZaloService.API/Application/Commands/Messages/SendMessageCommand.cs @@ -0,0 +1,24 @@ +using MediatR; + +namespace MktZaloService.API.Application.Commands.Messages; + +/// +/// EN: Command to send a message to a Zalo user. +/// VI: Command để gửi tin nhắn đến người dùng Zalo. +/// +public record SendMessageCommand( + Guid ConversationId, + string Text, + bool IsFromBot = true +) : IRequest; + +/// +/// EN: Result of sending a message. +/// VI: Kết quả gửi tin nhắn. +/// +public record SendMessageResult( + bool Success, + Guid? MessageId = null, + string? ZaloMessageId = null, + string? Error = null +); diff --git a/services/mkt-zalo-service-net/src/MktZaloService.API/Application/Commands/Messages/SendMessageCommandHandler.cs b/services/mkt-zalo-service-net/src/MktZaloService.API/Application/Commands/Messages/SendMessageCommandHandler.cs new file mode 100644 index 00000000..a936e1e8 --- /dev/null +++ b/services/mkt-zalo-service-net/src/MktZaloService.API/Application/Commands/Messages/SendMessageCommandHandler.cs @@ -0,0 +1,92 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using MktZaloService.Domain.AggregatesModel.ConversationAggregate; +using MktZaloService.Domain.Enums; +using MktZaloService.Domain.Exceptions; +using MktZaloService.Infrastructure.Zalo; + +namespace MktZaloService.API.Application.Commands.Messages; + +/// +/// EN: Handler for sending messages to Zalo users. +/// VI: Handler để gửi tin nhắn đến người dùng Zalo. +/// +public class SendMessageCommandHandler : IRequestHandler +{ + private readonly IConversationRepository _conversationRepository; + private readonly IZaloOfficialAccountClient _zaloClient; + private readonly ILogger _logger; + + public SendMessageCommandHandler( + IConversationRepository conversationRepository, + IZaloOfficialAccountClient zaloClient, + ILogger logger) + { + _conversationRepository = conversationRepository; + _zaloClient = zaloClient; + _logger = logger; + } + + public async Task Handle( + SendMessageCommand request, + CancellationToken cancellationToken) + { + // EN: Get conversation / VI: Lấy cuộc hội thoại + var conversation = await _conversationRepository.GetByIdAsync(request.ConversationId, cancellationToken); + + if (conversation == null) + { + return new SendMessageResult(false, Error: "Conversation not found"); + } + + try + { + // EN: Send message via Zalo API / VI: Gửi tin nhắn qua Zalo API + var response = await _zaloClient.SendTextMessageAsync( + conversation.ZaloUserId, + request.Text, + cancellationToken); + + if (response.Error != 0) + { + _logger.LogWarning( + "EN: Zalo API error {Error}: {Message}. VI: Lỗi Zalo API {Error}: {Message}", + response.Error, response.Message); + return new SendMessageResult(false, Error: $"Zalo error: {response.Message}"); + } + + // EN: Add message to conversation / VI: Thêm tin nhắn vào cuộc hội thoại + var message = conversation.AddMessage( + MessageType.Text, + request.Text, + MessageDirection.Outgoing, + isFromBot: request.IsFromBot, + zaloMessageId: response.Data?.MessageId); + + _conversationRepository.Update(conversation); + await _conversationRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "EN: Message {MessageId} sent to {ZaloUserId}. VI: Tin nhắn {MessageId} đã gửi đến {ZaloUserId}", + message.Id, conversation.ZaloUserId); + + return new SendMessageResult( + true, + MessageId: message.Id, + ZaloMessageId: response.Data?.MessageId); + } + catch (ConversationClosedException ex) + { + _logger.LogWarning(ex, + "EN: Cannot send to closed conversation. VI: Không thể gửi đến cuộc hội thoại đã đóng"); + return new SendMessageResult(false, Error: ex.Message); + } + catch (Exception ex) + { + _logger.LogError(ex, + "EN: Error sending message to {ZaloUserId}. VI: Lỗi gửi tin nhắn đến {ZaloUserId}", + conversation.ZaloUserId); + return new SendMessageResult(false, Error: ex.Message); + } + } +} diff --git a/services/mkt-zalo-service-net/src/MktZaloService.API/Application/Commands/Webhook/ProcessWebhookCommand.cs b/services/mkt-zalo-service-net/src/MktZaloService.API/Application/Commands/Webhook/ProcessWebhookCommand.cs new file mode 100644 index 00000000..6b208908 --- /dev/null +++ b/services/mkt-zalo-service-net/src/MktZaloService.API/Application/Commands/Webhook/ProcessWebhookCommand.cs @@ -0,0 +1,24 @@ +using MediatR; +using MktZaloService.Infrastructure.Zalo.Models; + +namespace MktZaloService.API.Application.Commands.Webhook; + +/// +/// EN: Command to process incoming Zalo webhook event. +/// VI: Command để xử lý sự kiện webhook đến từ Zalo. +/// +public record ProcessWebhookCommand( + ZaloWebhookEvent WebhookEvent, + string RawBody, + string Signature +) : IRequest; + +/// +/// EN: Result of webhook processing. +/// VI: Kết quả xử lý webhook. +/// +public record ProcessWebhookResult( + bool Success, + string? MessageId = null, + string? Error = null +); diff --git a/services/mkt-zalo-service-net/src/MktZaloService.API/Application/Commands/Webhook/ProcessWebhookCommandHandler.cs b/services/mkt-zalo-service-net/src/MktZaloService.API/Application/Commands/Webhook/ProcessWebhookCommandHandler.cs new file mode 100644 index 00000000..8718cea1 --- /dev/null +++ b/services/mkt-zalo-service-net/src/MktZaloService.API/Application/Commands/Webhook/ProcessWebhookCommandHandler.cs @@ -0,0 +1,202 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using MktZaloService.Domain.AggregatesModel.ConversationAggregate; +using MktZaloService.Domain.AggregatesModel.CustomerAggregate; +using MktZaloService.Domain.Enums; +using MktZaloService.Infrastructure.Zalo; + +namespace MktZaloService.API.Application.Commands.Webhook; + +/// +/// EN: Handler for processing incoming Zalo webhook events. +/// VI: Handler để xử lý các sự kiện webhook đến từ Zalo. +/// +public class ProcessWebhookCommandHandler : IRequestHandler +{ + private readonly IZaloWebhookVerifier _webhookVerifier; + private readonly ICustomerRepository _customerRepository; + private readonly IConversationRepository _conversationRepository; + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public ProcessWebhookCommandHandler( + IZaloWebhookVerifier webhookVerifier, + ICustomerRepository customerRepository, + IConversationRepository conversationRepository, + IMediator mediator, + ILogger logger) + { + _webhookVerifier = webhookVerifier; + _customerRepository = customerRepository; + _conversationRepository = conversationRepository; + _mediator = mediator; + _logger = logger; + } + + public async Task Handle( + ProcessWebhookCommand request, + CancellationToken cancellationToken) + { + // EN: Verify webhook signature / VI: Xác thực chữ ký webhook + if (!_webhookVerifier.VerifySignature(request.RawBody, request.Signature)) + { + _logger.LogWarning("EN: Invalid webhook signature. VI: Chữ ký webhook không hợp lệ"); + return new ProcessWebhookResult(false, Error: "Invalid signature"); + } + + var eventName = request.WebhookEvent.EventName; + _logger.LogInformation( + "EN: Processing webhook event {EventName}. VI: Đang xử lý sự kiện webhook {EventName}", + eventName); + + try + { + return eventName switch + { + "user_send_text" => await HandleUserMessageAsync(request.WebhookEvent, cancellationToken), + "user_send_image" => await HandleUserMessageAsync(request.WebhookEvent, cancellationToken), + "user_send_file" => await HandleUserMessageAsync(request.WebhookEvent, cancellationToken), + "user_send_sticker" => await HandleUserMessageAsync(request.WebhookEvent, cancellationToken), + "follow" => await HandleFollowEventAsync(request.WebhookEvent, cancellationToken), + "unfollow" => await HandleUnfollowEventAsync(request.WebhookEvent, cancellationToken), + _ => new ProcessWebhookResult(true) // Acknowledge unknown events + }; + } + catch (Exception ex) + { + _logger.LogError(ex, + "EN: Error processing webhook {EventName}. VI: Lỗi xử lý webhook {EventName}", + eventName); + return new ProcessWebhookResult(false, Error: ex.Message); + } + } + + /// + /// EN: Handle incoming user message. + /// VI: Xử lý tin nhắn từ người dùng. + /// + private async Task HandleUserMessageAsync( + Infrastructure.Zalo.Models.ZaloWebhookEvent webhookEvent, + CancellationToken ct) + { + var senderId = webhookEvent.Sender?.Id; + var messageText = webhookEvent.Message?.Text ?? string.Empty; + var messageId = webhookEvent.Message?.MessageId; + + if (string.IsNullOrEmpty(senderId)) + { + return new ProcessWebhookResult(false, Error: "Missing sender ID"); + } + + // EN: Get or create customer / VI: Lấy hoặc tạo khách hàng + var customer = await _customerRepository.GetOrCreateAsync( + senderId, + displayName: $"Zalo User {senderId[..Math.Min(6, senderId.Length)]}", + ct: ct); + + customer.RecordInteraction(); + customer.IncrementMessageCount(); + + // EN: Get or create active conversation / VI: Lấy hoặc tạo cuộc hội thoại đang hoạt động + var conversation = await _conversationRepository.FindActiveByZaloUserIdAsync(senderId, ct); + + if (conversation == null) + { + conversation = new Conversation(senderId, customer.Id); + _conversationRepository.Add(conversation); + customer.IncrementConversationCount(); + } + + // EN: Add incoming message / VI: Thêm tin nhắn đến + var messageType = DetermineMessageType(webhookEvent.EventName); + var message = conversation.AddMessage( + messageType, + messageText, + MessageDirection.Incoming, + isFromBot: false, + zaloMessageId: messageId); + + // EN: Save changes / VI: Lưu thay đổi + await _customerRepository.UnitOfWork.SaveEntitiesAsync(ct); + + // EN: Trigger chatbot rule matching (fire and forget via domain events) + // VI: Kích hoạt khớp quy tắc chatbot (fire and forget qua domain events) + _logger.LogInformation( + "EN: Message {MessageId} added to conversation {ConversationId}. VI: Tin nhắn {MessageId} đã thêm vào cuộc hội thoại {ConversationId}", + message.Id, conversation.Id); + + return new ProcessWebhookResult(true, MessageId: message.Id.ToString()); + } + + /// + /// EN: Handle follow event (new follower). + /// VI: Xử lý sự kiện theo dõi (người theo dõi mới). + /// + private async Task HandleFollowEventAsync( + Infrastructure.Zalo.Models.ZaloWebhookEvent webhookEvent, + CancellationToken ct) + { + var followerId = webhookEvent.Follower?.Id ?? webhookEvent.Sender?.Id; + + if (string.IsNullOrEmpty(followerId)) + { + return new ProcessWebhookResult(false, Error: "Missing follower ID"); + } + + _logger.LogInformation( + "EN: New follower {FollowerId}. VI: Người theo dõi mới {FollowerId}", + followerId); + + // EN: Create customer record / VI: Tạo bản ghi khách hàng + var customer = await _customerRepository.GetOrCreateAsync( + followerId, + displayName: $"Zalo User {followerId[..Math.Min(6, followerId.Length)]}", + ct: ct); + + customer.MarkActive(); + await _customerRepository.UnitOfWork.SaveEntitiesAsync(ct); + + return new ProcessWebhookResult(true); + } + + /// + /// EN: Handle unfollow event. + /// VI: Xử lý sự kiện hủy theo dõi. + /// + private async Task HandleUnfollowEventAsync( + Infrastructure.Zalo.Models.ZaloWebhookEvent webhookEvent, + CancellationToken ct) + { + var followerId = webhookEvent.Follower?.Id ?? webhookEvent.Sender?.Id; + + if (string.IsNullOrEmpty(followerId)) + { + return new ProcessWebhookResult(false, Error: "Missing follower ID"); + } + + _logger.LogInformation( + "EN: User unfollowed {FollowerId}. VI: Người dùng hủy theo dõi {FollowerId}", + followerId); + + var customer = await _customerRepository.FindByZaloUserIdAsync(followerId, ct); + if (customer != null) + { + customer.MarkInactive(); + await _customerRepository.UnitOfWork.SaveEntitiesAsync(ct); + } + + return new ProcessWebhookResult(true); + } + + private static MessageType DetermineMessageType(string? eventName) + { + return eventName switch + { + "user_send_text" => MessageType.Text, + "user_send_image" => MessageType.Image, + "user_send_sticker" => MessageType.Sticker, + "user_send_file" => MessageType.Link, + _ => MessageType.Text + }; + } +} diff --git a/services/mkt-zalo-service-net/src/MktZaloService.API/Application/DomainEventHandlers/MessageReceivedDomainEventHandler.cs b/services/mkt-zalo-service-net/src/MktZaloService.API/Application/DomainEventHandlers/MessageReceivedDomainEventHandler.cs new file mode 100644 index 00000000..e14a9e2b --- /dev/null +++ b/services/mkt-zalo-service-net/src/MktZaloService.API/Application/DomainEventHandlers/MessageReceivedDomainEventHandler.cs @@ -0,0 +1,121 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using MktZaloService.API.Application.Commands.Messages; +using MktZaloService.API.Application.Services; +using MktZaloService.Domain.AggregatesModel.ChatbotRuleAggregate; +using MktZaloService.Domain.Enums; +using MktZaloService.Domain.Events; + +namespace MktZaloService.API.Application.DomainEventHandlers; + +/// +/// EN: Handler for MessageReceivedDomainEvent - triggers chatbot auto-response. +/// VI: Handler cho MessageReceivedDomainEvent - kích hoạt phản hồi tự động chatbot. +/// +public class MessageReceivedDomainEventHandler : INotificationHandler +{ + private readonly IChatbotRulesService _rulesService; + private readonly IChatbotRuleRepository _ruleRepository; + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public MessageReceivedDomainEventHandler( + IChatbotRulesService rulesService, + IChatbotRuleRepository ruleRepository, + IMediator mediator, + ILogger logger) + { + _rulesService = rulesService; + _ruleRepository = ruleRepository; + _mediator = mediator; + _logger = logger; + } + + public async Task Handle( + MessageReceivedDomainEvent notification, + CancellationToken cancellationToken) + { + // EN: Skip if message is from bot (avoid infinite loops) + // VI: Bỏ qua nếu tin nhắn từ bot (tránh vòng lặp vô hạn) + if (notification.IsFromBot) + { + return; + } + + _logger.LogDebug( + "EN: Processing message for chatbot rules. VI: Đang xử lý tin nhắn cho quy tắc chatbot"); + + // EN: Find matching rule / VI: Tìm quy tắc khớp + var matchedRule = await _rulesService.FindMatchingRuleAsync( + notification.Content, + cancellationToken); + + if (matchedRule == null) + { + _logger.LogDebug("EN: No rule matched. VI: Không có quy tắc nào khớp"); + return; + } + + // EN: Record the match / VI: Ghi nhận kết quả khớp + matchedRule.RecordMatch(notification.ConversationId, notification.Content); + _ruleRepository.Update(matchedRule); + await _ruleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + // EN: Handle action based on type / VI: Xử lý hành động dựa trên loại + switch (matchedRule.Action.ActionType) + { + case ActionType.SendText: + await HandleSendTextAsync(notification.ConversationId, matchedRule, cancellationToken); + break; + + case ActionType.SendTemplate: + // EN: Template sending requires additional implementation + // VI: Gửi mẫu cần triển khai bổ sung + _logger.LogInformation( + "EN: Template {TemplateId} would be sent. VI: Mẫu {TemplateId} sẽ được gửi", + matchedRule.Action.TemplateId); + break; + + case ActionType.ForwardToHuman: + // EN: Mark for human attention (could notify staff) + // VI: Đánh dấu để nhân viên chú ý (có thể thông báo cho staff) + _logger.LogInformation( + "EN: Conversation {ConversationId} forwarded to human. VI: Cuộc hội thoại {ConversationId} chuyển đến nhân viên", + notification.ConversationId); + break; + } + } + + private async Task HandleSendTextAsync( + Guid conversationId, + ChatbotRule rule, + CancellationToken ct) + { + var responseText = _rulesService.GetResponseText(rule); + + if (string.IsNullOrEmpty(responseText)) + { + return; + } + + var sendCommand = new SendMessageCommand( + conversationId, + responseText, + IsFromBot: true); + + var result = await _mediator.Send(sendCommand, ct); + + if (result.Success) + { + _logger.LogInformation( + "EN: Auto-response sent for rule '{RuleName}'. VI: Phản hồi tự động đã gửi cho quy tắc '{RuleName}'", + rule.Name); + } + else + { + _logger.LogWarning( + "EN: Failed to send auto-response: {Error}. VI: Không thể gửi phản hồi tự động: {Error}", + result.Error); + } + } +} diff --git a/services/mkt-zalo-service-net/src/MktZaloService.API/Application/Queries/Conversations/GetConversationHistoryQuery.cs b/services/mkt-zalo-service-net/src/MktZaloService.API/Application/Queries/Conversations/GetConversationHistoryQuery.cs new file mode 100644 index 00000000..f1bfaee9 --- /dev/null +++ b/services/mkt-zalo-service-net/src/MktZaloService.API/Application/Queries/Conversations/GetConversationHistoryQuery.cs @@ -0,0 +1,43 @@ +using MediatR; +using MktZaloService.Domain.Enums; + +namespace MktZaloService.API.Application.Queries.Conversations; + +/// +/// EN: Query to get conversation history with messages. +/// VI: Query để lấy lịch sử cuộc hội thoại với tin nhắn. +/// +public record GetConversationHistoryQuery( + Guid ConversationId, + int Skip = 0, + int Take = 50 +) : IRequest; + +/// +/// EN: DTO for conversation history. +/// VI: DTO cho lịch sử cuộc hội thoại. +/// +public record ConversationHistoryDto( + Guid Id, + string ZaloUserId, + Guid CustomerId, + string Status, + int MessageCount, + DateTime StartedAt, + DateTime LastMessageAt, + List Messages +); + +/// +/// EN: DTO for a message. +/// VI: DTO cho tin nhắn. +/// +public record MessageDto( + Guid Id, + string Type, + string Content, + string Direction, + bool IsFromBot, + DateTime SentAt, + string? ZaloMessageId +); diff --git a/services/mkt-zalo-service-net/src/MktZaloService.API/Application/Queries/Conversations/GetConversationHistoryQueryHandler.cs b/services/mkt-zalo-service-net/src/MktZaloService.API/Application/Queries/Conversations/GetConversationHistoryQueryHandler.cs new file mode 100644 index 00000000..8558009d --- /dev/null +++ b/services/mkt-zalo-service-net/src/MktZaloService.API/Application/Queries/Conversations/GetConversationHistoryQueryHandler.cs @@ -0,0 +1,55 @@ +using MediatR; +using MktZaloService.Domain.AggregatesModel.ConversationAggregate; + +namespace MktZaloService.API.Application.Queries.Conversations; + +/// +/// EN: Handler for GetConversationHistoryQuery. +/// VI: Handler cho GetConversationHistoryQuery. +/// +public class GetConversationHistoryQueryHandler + : IRequestHandler +{ + private readonly IConversationRepository _conversationRepository; + + public GetConversationHistoryQueryHandler(IConversationRepository conversationRepository) + { + _conversationRepository = conversationRepository; + } + + public async Task Handle( + GetConversationHistoryQuery request, + CancellationToken cancellationToken) + { + var conversation = await _conversationRepository.GetByIdWithMessagesAsync( + request.ConversationId, + request.Skip, + request.Take, + cancellationToken); + + if (conversation == null) + { + return null; + } + + return new ConversationHistoryDto( + conversation.Id, + conversation.ZaloUserId, + conversation.CustomerId, + conversation.Status.ToString(), + conversation.MessageCount, + conversation.StartedAt, + conversation.LastMessageAt ?? conversation.StartedAt, + conversation.Messages + .OrderByDescending(m => m.SentAt) + .Select(m => new MessageDto( + m.Id, + m.Type.ToString(), + m.Content, + m.Direction.ToString(), + m.IsFromBot, + m.SentAt, + m.ZaloMessageId)) + .ToList()); + } +} diff --git a/services/mkt-zalo-service-net/src/MktZaloService.API/Application/Services/ChatbotRulesService.cs b/services/mkt-zalo-service-net/src/MktZaloService.API/Application/Services/ChatbotRulesService.cs new file mode 100644 index 00000000..5c68ccef --- /dev/null +++ b/services/mkt-zalo-service-net/src/MktZaloService.API/Application/Services/ChatbotRulesService.cs @@ -0,0 +1,99 @@ +using MktZaloService.Domain.AggregatesModel.ChatbotRuleAggregate; +using MktZaloService.Infrastructure.Caching; +using Microsoft.Extensions.Logging; + +namespace MktZaloService.API.Application.Services; + +/// +/// EN: Interface for chatbot rule matching service. +/// VI: Interface cho service khớp quy tắc chatbot. +/// +public interface IChatbotRulesService +{ + /// + /// EN: Find matching rule for user message. + /// VI: Tìm quy tắc khớp cho tin nhắn người dùng. + /// + Task FindMatchingRuleAsync( + string userMessage, + CancellationToken ct = default); + + /// + /// EN: Get response text for matched rule. + /// VI: Lấy văn bản phản hồi cho quy tắc khớp. + /// + string? GetResponseText(ChatbotRule rule); +} + +/// +/// EN: Service for matching chatbot rules against user messages. +/// VI: Service để khớp quy tắc chatbot với tin nhắn người dùng. +/// +public class ChatbotRulesService : IChatbotRulesService +{ + private readonly IChatbotRuleRepository _ruleRepository; + private readonly IChatbotRuleCacheService _cacheService; + private readonly ILogger _logger; + + public ChatbotRulesService( + IChatbotRuleRepository ruleRepository, + IChatbotRuleCacheService cacheService, + ILogger logger) + { + _ruleRepository = ruleRepository; + _cacheService = cacheService; + _logger = logger; + } + + /// + /// EN: Find matching rule for user message (highest priority first). + /// VI: Tìm quy tắc khớp cho tin nhắn người dùng (ưu tiên cao nhất trước). + /// + public async Task FindMatchingRuleAsync( + string userMessage, + CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(userMessage)) + { + return null; + } + + // EN: Get active rules (cached or from DB) + // VI: Lấy các quy tắc đang hoạt động (từ cache hoặc DB) + var rules = await _cacheService.GetActiveRulesAsync( + () => _ruleRepository.GetActiveRulesOrderedByPriorityAsync(ct), + ct); + + // EN: Evaluate rules in priority order, return first match + // VI: Đánh giá các quy tắc theo thứ tự ưu tiên, trả về khớp đầu tiên + foreach (var rule in rules) + { + if (rule.Evaluate(userMessage)) + { + _logger.LogInformation( + "EN: Message matched rule '{RuleName}' (Priority: {Priority}). VI: Tin nhắn khớp quy tắc '{RuleName}' (Ưu tiên: {Priority})", + rule.Name, rule.Priority); + return rule; + } + } + + _logger.LogDebug( + "EN: No matching rule found for message. VI: Không tìm thấy quy tắc khớp cho tin nhắn"); + return null; + } + + /// + /// EN: Get response text for matched rule. + /// VI: Lấy văn bản phản hồi cho quy tắc khớp. + /// + public string? GetResponseText(ChatbotRule rule) + { + return rule.Action.ActionType switch + { + Domain.Enums.ActionType.SendText => rule.Action.ResponseText, + Domain.Enums.ActionType.SendTemplate => null, // Template requires separate handling + Domain.Enums.ActionType.ForwardToHuman => null, // No auto-response + _ => null + }; + } +} diff --git a/services/mkt-zalo-service-net/src/MktZaloService.API/Controllers/ConversationsController.cs b/services/mkt-zalo-service-net/src/MktZaloService.API/Controllers/ConversationsController.cs new file mode 100644 index 00000000..cb69f8cc --- /dev/null +++ b/services/mkt-zalo-service-net/src/MktZaloService.API/Controllers/ConversationsController.cs @@ -0,0 +1,75 @@ +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using MktZaloService.API.Application.Commands.Messages; +using MktZaloService.API.Application.Queries.Conversations; + +namespace MktZaloService.API.Controllers; + +/// +/// EN: Controller for managing conversations. +/// VI: Controller để quản lý cuộc hội thoại. +/// +[ApiController] +[Route("api/v1/conversations")] +[Authorize] +public class ConversationsController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public ConversationsController(IMediator mediator, ILogger logger) + { + _mediator = mediator; + _logger = logger; + } + + /// + /// EN: Get conversation history with paginated messages. + /// VI: Lấy lịch sử cuộc hội thoại với tin nhắn phân trang. + /// + [HttpGet("{id:guid}")] + public async Task> GetConversation( + Guid id, + [FromQuery] int skip = 0, + [FromQuery] int take = 50, + CancellationToken ct = default) + { + var query = new GetConversationHistoryQuery(id, skip, take); + var result = await _mediator.Send(query, ct); + + if (result == null) + { + return NotFound(new { error = "Conversation not found" }); + } + + return Ok(result); + } + + /// + /// EN: Send a message to a conversation. + /// VI: Gửi tin nhắn đến cuộc hội thoại. + /// + [HttpPost("{id:guid}/messages")] + public async Task> SendMessage( + Guid id, + [FromBody] SendMessageRequest request, + CancellationToken ct = default) + { + var command = new SendMessageCommand(id, request.Text, IsFromBot: false); + var result = await _mediator.Send(command, ct); + + if (!result.Success) + { + return BadRequest(new { error = result.Error }); + } + + return Ok(result); + } +} + +/// +/// EN: Request to send a message. +/// VI: Request để gửi tin nhắn. +/// +public record SendMessageRequest(string Text); diff --git a/services/mkt-zalo-service-net/src/MktZaloService.API/Controllers/WebhooksController.cs b/services/mkt-zalo-service-net/src/MktZaloService.API/Controllers/WebhooksController.cs new file mode 100644 index 00000000..51bc147f --- /dev/null +++ b/services/mkt-zalo-service-net/src/MktZaloService.API/Controllers/WebhooksController.cs @@ -0,0 +1,110 @@ +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using MktZaloService.API.Application.Commands.Webhook; +using MktZaloService.Infrastructure.Zalo.Models; +using System.Text.Json; + +namespace MktZaloService.API.Controllers; + +/// +/// EN: Controller for handling Zalo webhook events. +/// VI: Controller để xử lý các sự kiện webhook từ Zalo. +/// +[ApiController] +[Route("api/v1/webhooks")] +[AllowAnonymous] // Zalo webhooks don't use JWT +public class WebhooksController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public WebhooksController(IMediator mediator, ILogger logger) + { + _mediator = mediator; + _logger = logger; + } + + /// + /// EN: Webhook verification endpoint (GET) - Zalo sends challenge token. + /// VI: Endpoint xác thực webhook (GET) - Zalo gửi token thử thách. + /// + [HttpGet("zalo")] + public IActionResult VerifyWebhook([FromQuery(Name = "hub.challenge")] string? challenge) + { + _logger.LogInformation( + "EN: Webhook verification request received. VI: Đã nhận yêu cầu xác thực webhook"); + + if (!string.IsNullOrEmpty(challenge)) + { + return Ok(challenge); + } + + return Ok("Webhook endpoint ready"); + } + + /// + /// EN: Webhook event handler endpoint (POST). + /// VI: Endpoint xử lý sự kiện webhook (POST). + /// + [HttpPost("zalo")] + public async Task HandleWebhook( + [FromHeader(Name = "X-ZaloOA-Signature")] string? signature, + CancellationToken ct) + { + // EN: Read raw body for signature verification + // VI: Đọc body thô để xác thực chữ ký + using var reader = new StreamReader(Request.Body); + var rawBody = await reader.ReadToEndAsync(ct); + + if (string.IsNullOrEmpty(rawBody)) + { + return BadRequest(new { error = "Empty request body" }); + } + + // EN: Parse webhook event + // VI: Phân tích sự kiện webhook + ZaloWebhookEvent? webhookEvent; + try + { + webhookEvent = JsonSerializer.Deserialize(rawBody, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower + }); + } + catch (JsonException ex) + { + _logger.LogWarning(ex, "EN: Invalid webhook JSON. VI: JSON webhook không hợp lệ"); + return BadRequest(new { error = "Invalid JSON" }); + } + + if (webhookEvent == null) + { + return BadRequest(new { error = "Could not parse webhook event" }); + } + + // EN: Process webhook (must respond quickly < 5s) + // VI: Xử lý webhook (phải phản hồi nhanh < 5s) + var command = new ProcessWebhookCommand( + webhookEvent, + rawBody, + signature ?? string.Empty); + + var result = await _mediator.Send(command, ct); + + // EN: Always return 200 to acknowledge receipt (per Zalo spec) + // VI: Luôn trả về 200 để xác nhận đã nhận (theo spec Zalo) + if (result.Success) + { + return Ok(new { success = true, message_id = result.MessageId }); + } + + _logger.LogWarning( + "EN: Webhook processing failed: {Error}. VI: Xử lý webhook thất bại: {Error}", + result.Error); + + // EN: Still return 200 to avoid Zalo retries for business logic errors + // VI: Vẫn trả về 200 để tránh Zalo retry cho lỗi business logic + return Ok(new { success = false, error = result.Error }); + } +}