diff --git a/deployments/local/docker-compose.yml b/deployments/local/docker-compose.yml index f37ad155..5590a390 100644 --- a/deployments/local/docker-compose.yml +++ b/deployments/local/docker-compose.yml @@ -846,6 +846,57 @@ services: - "traefik.http.services.booking-service.loadbalancer.healthcheck.interval=10s" + # Ads Manager Service .NET - Campaign & Ad Management + ads-manager-service-net: + build: + context: ../../services/ads-manager-service-net + dockerfile: Dockerfile + image: goodgo/ads-manager-service-net:latest + container_name: ads-manager-service-net-local + environment: + - ASPNETCORE_ENVIRONMENT=Development + - ASPNETCORE_URLS=http://+:8080 + # EN: Database - Neon PostgreSQL + # VI: Cơ sở dữ liệu - Neon PostgreSQL + - ConnectionStrings__DefaultConnection=Host=ep-holy-glitter-a4hongg7-pooler.us-east-1.aws.neon.tech;Port=5432;Database=ads_manager_service;Username=neondb_owner;Password=npg_Ssfy6HKO0cXI;SSL Mode=Require + # EN: IAM Service Communication + # VI: Giao tiếp IAM Service + - IamService__BaseUrl=http://iam-service-net:8080 + - IamService__ServiceName=ads-manager-service + # EN: JWT Configuration + # VI: Cấu hình JWT + - Jwt__Authority=http://iam-service-net:8080 + - Jwt__Audience=goodgo-api + - Jwt__RequireHttpsMetadata=false + # EN: Redis Cache + # VI: Cache Redis + - Redis__Host=167.114.174.113 + - Redis__Port=6379 + - Redis__Password=Velik@2026 + ports: + - "5021:8080" + depends_on: + iam-service-net: + condition: service_healthy + traefik: + condition: service_started + networks: + - microservices-network + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health/live"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + labels: + - "traefik.enable=true" + - "traefik.http.routers.ads-manager-service.rule=PathPrefix(`/api/v1/ads-manager`)" + - "traefik.http.routers.ads-manager-service.entrypoints=web" + - "traefik.http.services.ads-manager-service.loadbalancer.server.port=8080" + - "traefik.http.services.ads-manager-service.loadbalancer.healthcheck.path=/health/live" + - "traefik.http.services.ads-manager-service.loadbalancer.healthcheck.interval=10s" + # Jaeger - Distributed Tracing # jaeger: # image: jaegertracing/all-in-one:1.47 diff --git a/services/ads-analytics-service-net/src/AdsAnalyticsService.API/AdsAnalyticsService.API.csproj b/services/ads-analytics-service-net/src/AdsAnalyticsService.API/AdsAnalyticsService.API.csproj index 65a1e43e..8fa0d8e5 100644 --- a/services/ads-analytics-service-net/src/AdsAnalyticsService.API/AdsAnalyticsService.API.csproj +++ b/services/ads-analytics-service-net/src/AdsAnalyticsService.API/AdsAnalyticsService.API.csproj @@ -14,6 +14,10 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + diff --git a/services/ads-analytics-service-net/src/AdsAnalyticsService.API/Application/Queries/GetCampaignMetricsQuery.cs b/services/ads-analytics-service-net/src/AdsAnalyticsService.API/Application/Queries/GetCampaignMetricsQuery.cs new file mode 100644 index 00000000..fc4b67c0 --- /dev/null +++ b/services/ads-analytics-service-net/src/AdsAnalyticsService.API/Application/Queries/GetCampaignMetricsQuery.cs @@ -0,0 +1,34 @@ +using MediatR; + +namespace AdsAnalyticsService.API.Application.Queries; + +/// +/// EN: Query to get campaign metrics for a specific date range. +/// VI: Query lấy metrics chiến dịch cho khoảng thời gian cụ thể. +/// +public record GetCampaignMetricsQuery : IRequest +{ + public Guid CampaignId { get; init; } + public DateTime StartDate { get; init; } + public DateTime EndDate { get; init; } +} + +/// +/// EN: Campaign metrics DTO. +/// VI: DTO metrics chiến dịch. +/// +public record CampaignMetricsDto +{ + public Guid CampaignId { get; init; } + public DateTime StartDate { get; init; } + public DateTime EndDate { get; init; } + public long TotalImpressions { get; init; } + public long TotalClicks { get; init; } + public long TotalConversions { get; init; } + public decimal TotalSpend { get; init; } + public decimal TotalRevenue { get; init; } + public decimal CTR { get; init; } + public decimal CPC { get; init; } + public decimal CPA { get; init; } + public decimal ROAS { get; init; } +} diff --git a/services/ads-analytics-service-net/src/AdsAnalyticsService.API/Application/Queries/GetCampaignMetricsQueryHandler.cs b/services/ads-analytics-service-net/src/AdsAnalyticsService.API/Application/Queries/GetCampaignMetricsQueryHandler.cs new file mode 100644 index 00000000..7937b5ae --- /dev/null +++ b/services/ads-analytics-service-net/src/AdsAnalyticsService.API/Application/Queries/GetCampaignMetricsQueryHandler.cs @@ -0,0 +1,71 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using AdsAnalyticsService.Infrastructure; + +namespace AdsAnalyticsService.API.Application.Queries; + +/// +/// EN: Handler for GetCampaignMetricsQuery. +/// VI: Handler cho GetCampaignMetricsQuery. +/// +public class GetCampaignMetricsQueryHandler : IRequestHandler +{ + private readonly AdsAnalyticsServiceContext _context; + private readonly ILogger _logger; + + public GetCampaignMetricsQueryHandler( + AdsAnalyticsServiceContext context, + ILogger logger) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle(GetCampaignMetricsQuery request, CancellationToken cancellationToken) + { + // EN: Aggregate metrics for the campaign within date range + // VI: Tổng hợp metrics cho chiến dịch trong khoảng thời gian + var metrics = await _context.CampaignMetrics + .Where(m => m.CampaignId == request.CampaignId + && m.Date >= request.StartDate.Date + && m.Date <= request.EndDate.Date) + .ToListAsync(cancellationToken); + + if (!metrics.Any()) + { + _logger.LogInformation("No metrics found for Campaign {CampaignId}", request.CampaignId); + return null; + } + + // EN: Aggregate totals + // VI: Tổng hợp tổng cộng + var totalImpressions = metrics.Sum(m => m.Impressions); + var totalClicks = metrics.Sum(m => m.Clicks); + var totalConversions = metrics.Sum(m => m.Conversions); + var totalSpend = metrics.Sum(m => m.Spend); + var totalRevenue = metrics.Sum(m => m.Revenue); + + // EN: Calculate aggregate KPIs + // VI: Tính toán KPIs tổng hợp + var ctr = totalImpressions > 0 ? (decimal)totalClicks / totalImpressions * 100 : 0; + var cpc = totalClicks > 0 ? totalSpend / totalClicks : 0; + var cpa = totalConversions > 0 ? totalSpend / totalConversions : 0; + var roas = totalSpend > 0 ? totalRevenue / totalSpend : 0; + + return new CampaignMetricsDto + { + CampaignId = request.CampaignId, + StartDate = request.StartDate, + EndDate = request.EndDate, + TotalImpressions = totalImpressions, + TotalClicks = totalClicks, + TotalConversions = totalConversions, + TotalSpend = totalSpend, + TotalRevenue = totalRevenue, + CTR = ctr, + CPC = cpc, + CPA = cpa, + ROAS = roas + }; + } +} diff --git a/services/ads-analytics-service-net/src/AdsAnalyticsService.API/Controllers/MetricsController.cs b/services/ads-analytics-service-net/src/AdsAnalyticsService.API/Controllers/MetricsController.cs new file mode 100644 index 00000000..9e9ee6b9 --- /dev/null +++ b/services/ads-analytics-service-net/src/AdsAnalyticsService.API/Controllers/MetricsController.cs @@ -0,0 +1,88 @@ +using Asp.Versioning; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using AdsAnalyticsService.API.Application.Queries; + +namespace AdsAnalyticsService.API.Controllers; + +/// +/// EN: API Controller for ads analytics metrics. +/// VI: API Controller cho metrics phân tích quảng cáo. +/// +[ApiController] +[ApiVersion("1.0")] +[Route("api/v{version:apiVersion}/ads-analytics")] +[Produces("application/json")] +public class MetricsController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public MetricsController(IMediator mediator, ILogger logger) + { + _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// EN: Get campaign metrics for a date range. + /// VI: Lấy metrics chiến dịch cho khoảng thời gian. + /// + /// Campaign ID + /// Start date (YYYY-MM-DD) + /// End date (YYYY-MM-DD) + /// Campaign metrics + [HttpGet("campaigns/{id}/metrics")] + [ProducesResponseType(typeof(CampaignMetricsDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetCampaignMetrics( + Guid id, + [FromQuery] DateTime? startDate = null, + [FromQuery] DateTime? endDate = null) + { + // EN: Default to last 30 days if not specified + // VI: Mặc định 30 ngày gần nhất nếu không chỉ định + var start = startDate ?? DateTime.UtcNow.AddDays(-30).Date; + var end = endDate ?? DateTime.UtcNow.Date; + + var query = new GetCampaignMetricsQuery + { + CampaignId = id, + StartDate = start, + EndDate = end + }; + + var metrics = await _mediator.Send(query); + + if (metrics == null) + return NotFound(new { message = $"No metrics found for campaign {id}" }); + + return Ok(metrics); + } + + /// + /// EN: Get ad set metrics (placeholder for future implementation). + /// VI: Lấy metrics ad set (placeholder cho triển khai sau). + /// + [HttpGet("adsets/{id}/metrics")] + [ProducesResponseType(StatusCodes.Status501NotImplemented)] + public IActionResult GetAdSetMetrics(Guid id) + { + _logger.LogWarning("AdSet metrics not yet implemented"); + return StatusCode(StatusCodes.Status501NotImplemented, + new { message = "AdSet metrics endpoint not yet implemented" }); + } + + /// + /// EN: Get ad metrics (placeholder for future implementation). + /// VI: Lấy metrics ad (placeholder cho triển khai sau). + /// + [HttpGet("ads/{id}/metrics")] + [ProducesResponseType(StatusCodes.Status501NotImplemented)] + public IActionResult GetAdMetrics(Guid id) + { + _logger.LogWarning("Ad metrics not yet implemented"); + return StatusCode(StatusCodes.Status501NotImplemented, + new { message = "Ad metrics endpoint not yet implemented" }); + } +} diff --git a/services/ads-analytics-service-net/src/AdsAnalyticsService.Infrastructure/AdsAnalyticsServiceContext.cs b/services/ads-analytics-service-net/src/AdsAnalyticsService.Infrastructure/AdsAnalyticsServiceContext.cs index 70f20927..6a229a96 100644 --- a/services/ads-analytics-service-net/src/AdsAnalyticsService.Infrastructure/AdsAnalyticsServiceContext.cs +++ b/services/ads-analytics-service-net/src/AdsAnalyticsService.Infrastructure/AdsAnalyticsServiceContext.cs @@ -1,7 +1,8 @@ using MediatR; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; -using AdsAnalyticsService.Domain.AggregatesModel.SampleAggregate; +using AdsAnalyticsService.Domain.AggregatesModel.MetricsAggregate; +using AdsAnalyticsService.Domain.AggregatesModel.ReportAggregate; using AdsAnalyticsService.Domain.SeedWork; using AdsAnalyticsService.Infrastructure.EntityConfigurations; @@ -17,10 +18,16 @@ public class AdsAnalyticsServiceContext : DbContext, IUnitOfWork private IDbContextTransaction? _currentTransaction; /// - /// EN: Samples table. - /// VI: Bảng Samples. + /// EN: Campaign metrics table. + /// VI: Bảng metrics chiến dịch. /// - public DbSet Samples => Set(); + public DbSet CampaignMetrics => Set(); + + /// + /// EN: Reports table. + /// VI: Bảng báo cáo. + /// + public DbSet Reports => Set(); /// /// EN: Read-only access to current transaction. @@ -50,8 +57,8 @@ public class AdsAnalyticsServiceContext : DbContext, IUnitOfWork { // EN: Apply entity configurations // VI: Áp dụng các cấu hình entity - modelBuilder.ApplyConfiguration(new SampleEntityTypeConfiguration()); - modelBuilder.ApplyConfiguration(new SampleStatusEntityTypeConfiguration()); + modelBuilder.ApplyConfiguration(new CampaignMetricsEntityTypeConfiguration()); + modelBuilder.ApplyConfiguration(new ReportEntityTypeConfiguration()); } /// diff --git a/services/ads-analytics-service-net/src/AdsAnalyticsService.Infrastructure/EntityConfigurations/CampaignMetricsEntityTypeConfiguration.cs b/services/ads-analytics-service-net/src/AdsAnalyticsService.Infrastructure/EntityConfigurations/CampaignMetricsEntityTypeConfiguration.cs new file mode 100644 index 00000000..caf9772c --- /dev/null +++ b/services/ads-analytics-service-net/src/AdsAnalyticsService.Infrastructure/EntityConfigurations/CampaignMetricsEntityTypeConfiguration.cs @@ -0,0 +1,86 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using AdsAnalyticsService.Domain.AggregatesModel.MetricsAggregate; + +namespace AdsAnalyticsService.Infrastructure.EntityConfigurations; + +/// +/// EN: Entity type configuration for CampaignMetrics aggregate. +/// VI: Cấu hình entity type cho CampaignMetrics aggregate. +/// +public class CampaignMetricsEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + // EN: Table name + // VI: Tên bảng + builder.ToTable("campaign_metrics"); + + // EN: Primary key + // VI: Khóa chính + builder.HasKey(m => m.Id); + builder.Property(m => m.Id) + .HasColumnName("id") + .IsRequired(); + + // EN: Campaign reference + // VI: Tham chiếu chiến dịch + builder.Property(m => m.CampaignId) + .HasColumnName("campaign_id") + .IsRequired(); + + builder.HasIndex(m => m.CampaignId) + .HasDatabaseName("idx_campaign_metrics_campaign_id"); + + // EN: Date (for daily aggregation) + // VI: Ngày (cho tổng hợp theo ngày) + builder.Property(m => m.Date) + .HasColumnName("date") + .HasColumnType("date") + .IsRequired(); + + builder.HasIndex(m => new { m.CampaignId, m.Date }) + .IsUnique() + .HasDatabaseName("idx_campaign_metrics_campaign_date"); + + // EN: Performance metrics + // VI: Chỉ số hiệu suất + builder.Property(m => m.Impressions) + .HasColumnName("impressions") + .HasDefaultValue(0) + .IsRequired(); + + builder.Property(m => m.Clicks) + .HasColumnName("clicks") + .HasDefaultValue(0) + .IsRequired(); + + builder.Property(m => m.Conversions) + .HasColumnName("conversions") + .HasDefaultValue(0) + .IsRequired(); + + builder.Property(m => m.Spend) + .HasColumnName("spend") + .HasColumnType("decimal(18,2)") + .HasDefaultValue(0m) + .IsRequired(); + + builder.Property(m => m.Revenue) + .HasColumnName("revenue") + .HasColumnType("decimal(18,2)") + .HasDefaultValue(0m) + .IsRequired(); + + // EN: Ignore calculated properties + // VI: Bỏ qua các thuộc tính được tính toán + builder.Ignore(m => m.CTR); + builder.Ignore(m => m.CPC); + builder.Ignore(m => m.CPA); + builder.Ignore(m => m.ROAS); + + // EN: Ignore domain events + // VI: Bỏ qua domain events + builder.Ignore(m => m.DomainEvents); + } +} diff --git a/services/ads-analytics-service-net/src/AdsAnalyticsService.Infrastructure/EntityConfigurations/ReportEntityTypeConfiguration.cs b/services/ads-analytics-service-net/src/AdsAnalyticsService.Infrastructure/EntityConfigurations/ReportEntityTypeConfiguration.cs new file mode 100644 index 00000000..808f030e --- /dev/null +++ b/services/ads-analytics-service-net/src/AdsAnalyticsService.Infrastructure/EntityConfigurations/ReportEntityTypeConfiguration.cs @@ -0,0 +1,83 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using AdsAnalyticsService.Domain.AggregatesModel.ReportAggregate; + +namespace AdsAnalyticsService.Infrastructure.EntityConfigurations; + +/// +/// EN: Entity type configuration for Report aggregate. +/// VI: Cấu hình entity type cho Report aggregate. +/// +public class ReportEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + // EN: Table name + // VI: Tên bảng + builder.ToTable("reports"); + + // EN: Primary key + // VI: Khóa chính + builder.HasKey(r => r.Id); + builder.Property(r => r.Id) + .HasColumnName("id") + .IsRequired(); + + // EN: Advertiser reference + // VI: Tham chiếu advertiser + builder.Property(r => r.AdvertiserId) + .HasColumnName("advertiser_id") + .IsRequired(); + + builder.HasIndex(r => r.AdvertiserId) + .HasDatabaseName("idx_reports_advertiser_id"); + + // EN: Report properties + // VI: Thuộc tính báo cáo + builder.Property(r => r.Name) + .HasColumnName("name") + .HasMaxLength(200) + .IsRequired(); + + builder.Property(r => r.ReportType) + .HasColumnName("report_type") + .HasConversion() + .IsRequired(); + + builder.Property(r => r.StartDate) + .HasColumnName("start_date") + .HasColumnType("date") + .IsRequired(); + + builder.Property(r => r.EndDate) + .HasColumnName("end_date") + .HasColumnType("date") + .IsRequired(); + + builder.Property(r => r.Status) + .HasColumnName("status") + .HasConversion() + .IsRequired(); + + builder.Property(r => r.DataJson) + .HasColumnName("data_json") + .HasColumnType("jsonb") + .IsRequired(false); + + builder.Property(r => r.CreatedAt) + .HasColumnName("created_at") + .IsRequired(); + + // EN: Indexes + // VI: Chỉ mục + builder.HasIndex(r => r.Status) + .HasDatabaseName("idx_reports_status"); + + builder.HasIndex(r => r.CreatedAt) + .HasDatabaseName("idx_reports_created_at"); + + // EN: Ignore domain events + // VI: Bỏ qua domain events + builder.Ignore(r => r.DomainEvents); + } +} diff --git a/services/ads-billing-service-net/src/AdsBillingService.API/AdsBillingService.API.csproj b/services/ads-billing-service-net/src/AdsBillingService.API/AdsBillingService.API.csproj index 360418fc..17da9c40 100644 --- a/services/ads-billing-service-net/src/AdsBillingService.API/AdsBillingService.API.csproj +++ b/services/ads-billing-service-net/src/AdsBillingService.API/AdsBillingService.API.csproj @@ -14,6 +14,10 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + diff --git a/services/ads-billing-service-net/src/AdsBillingService.Infrastructure/AdsBillingServiceContext.cs b/services/ads-billing-service-net/src/AdsBillingService.Infrastructure/AdsBillingServiceContext.cs index 2c148f93..f08fd9d5 100644 --- a/services/ads-billing-service-net/src/AdsBillingService.Infrastructure/AdsBillingServiceContext.cs +++ b/services/ads-billing-service-net/src/AdsBillingService.Infrastructure/AdsBillingServiceContext.cs @@ -32,7 +32,12 @@ public class AdsBillingServiceContext : DbContext, IUnitOfWork protected override void OnModelCreating(ModelBuilder modelBuilder) { - // Entity configurations will be applied here + // EN: Apply entity configurations / VI: Áp dụng entity configurations + modelBuilder.ApplyConfiguration(new EntityConfigurations.BillingAccountEntityTypeConfiguration()); + modelBuilder.ApplyConfiguration(new EntityConfigurations.InvoiceEntityTypeConfiguration()); + modelBuilder.ApplyConfiguration(new EntityConfigurations.InvoiceLineItemEntityTypeConfiguration()); + modelBuilder.ApplyConfiguration(new EntityConfigurations.AdChargeEntityTypeConfiguration()); + modelBuilder.ApplyConfiguration(new EntityConfigurations.ClientRequestEntityTypeConfiguration()); } public async Task SaveEntitiesAsync(CancellationToken cancellationToken = default) diff --git a/services/ads-billing-service-net/src/AdsBillingService.Infrastructure/EntityConfigurations/AdChargeEntityTypeConfiguration.cs b/services/ads-billing-service-net/src/AdsBillingService.Infrastructure/EntityConfigurations/AdChargeEntityTypeConfiguration.cs new file mode 100644 index 00000000..93a393cd --- /dev/null +++ b/services/ads-billing-service-net/src/AdsBillingService.Infrastructure/EntityConfigurations/AdChargeEntityTypeConfiguration.cs @@ -0,0 +1,86 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using AdsBillingService.Domain.AggregatesModel.ChargeAggregate; + +namespace AdsBillingService.Infrastructure.EntityConfigurations; + +/// +/// EN: Entity configuration for AdCharge aggregate. +/// VI: Cấu hình entity cho aggregate AdCharge. +/// +public class AdChargeEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + // EN: Table name / VI: Tên bảng + builder.ToTable("ad_charges"); + + // EN: Primary key / VI: Khóa chính + builder.HasKey(c => c.Id); + builder.Property(c => c.Id) + .HasColumnName("id") + .IsRequired(); + + // EN: Advertiser ID / VI: ID Advertiser + builder.Property(c => c.AdvertiserId) + .HasColumnName("advertiser_id") + .IsRequired(); + + builder.HasIndex(c => c.AdvertiserId) + .HasDatabaseName("ix_ad_charges_advertiser_id"); + + // EN: Campaign ID / VI: ID chiến dịch + builder.Property(c => c.CampaignId) + .HasColumnName("campaign_id") + .IsRequired(); + + builder.HasIndex(c => c.CampaignId) + .HasDatabaseName("ix_ad_charges_campaign_id"); + + // EN: Ad ID / VI: ID quảng cáo + builder.Property(c => c.AdId) + .HasColumnName("ad_id") + .IsRequired(); + + // EN: Charge type / VI: Loại charge + builder.Property(c => c.ChargeType) + .HasColumnName("charge_type") + .HasConversion() + .IsRequired(); + + builder.HasIndex(c => c.ChargeType) + .HasDatabaseName("ix_ad_charges_charge_type"); + + // EN: Amount / VI: Số tiền + builder.Property(c => c.Amount) + .HasColumnName("amount") + .HasColumnType("decimal(18,6)") + .IsRequired(); + + // EN: Currency / VI: Loại tiền + builder.Property(c => c.Currency) + .HasColumnName("currency") + .HasMaxLength(10) + .IsRequired(); + + // EN: Charged at / VI: Thời điểm charge + builder.Property(c => c.ChargedAt) + .HasColumnName("charged_at") + .IsRequired(); + + builder.HasIndex(c => c.ChargedAt) + .HasDatabaseName("ix_ad_charges_charged_at"); + + // EN: Processed flag / VI: Cờ đã xử lý + builder.Property(c => c.Processed) + .HasColumnName("processed") + .IsRequired(); + + builder.HasIndex(c => c.Processed) + .HasDatabaseName("ix_ad_charges_processed"); + + // EN: Composite index for unprocessed charges / VI: Chỉ mục kết hợp cho charges chưa xử lý + builder.HasIndex(c => new { c.AdvertiserId, c.Processed, c.ChargedAt }) + .HasDatabaseName("ix_ad_charges_advertiser_processed_charged"); + } +} diff --git a/services/ads-billing-service-net/src/AdsBillingService.Infrastructure/EntityConfigurations/BillingAccountEntityTypeConfiguration.cs b/services/ads-billing-service-net/src/AdsBillingService.Infrastructure/EntityConfigurations/BillingAccountEntityTypeConfiguration.cs new file mode 100644 index 00000000..4b8ccac2 --- /dev/null +++ b/services/ads-billing-service-net/src/AdsBillingService.Infrastructure/EntityConfigurations/BillingAccountEntityTypeConfiguration.cs @@ -0,0 +1,88 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using AdsBillingService.Domain.AggregatesModel.BillingAccountAggregate; + +namespace AdsBillingService.Infrastructure.EntityConfigurations; + +/// +/// EN: Entity configuration for BillingAccount aggregate. +/// VI: Cấu hình entity cho aggregate BillingAccount. +/// +public class BillingAccountEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + // EN: Table name / VI: Tên bảng + builder.ToTable("billing_accounts"); + + // EN: Primary key / VI: Khóa chính + builder.HasKey(b => b.Id); + builder.Property(b => b.Id) + .HasColumnName("id") + .IsRequired(); + + // EN: Advertiser ID / VI: ID Advertiser + builder.Property(b => b.AdvertiserId) + .HasColumnName("advertiser_id") + .IsRequired(); + + builder.HasIndex(b => b.AdvertiserId) + .HasDatabaseName("ix_billing_accounts_advertiser_id"); + + // EN: Wallet ID / VI: ID Ví + builder.Property(b => b.WalletId) + .HasColumnName("wallet_id") + .IsRequired(false); + + // EN: Payment method / VI: Phương thức thanh toán + builder.Property(b => b.PaymentMethod) + .HasColumnName("payment_method") + .HasConversion() + .IsRequired(); + + // EN: Account status / VI: Trạng thái tài khoản + builder.Property(b => b.Status) + .HasColumnName("status") + .HasConversion() + .IsRequired(); + + // EN: Balance / VI: Số dư + builder.Property(b => b.Balance) + .HasColumnName("balance") + .HasColumnType("decimal(18,2)") + .IsRequired(); + + // EN: Credit limit / VI: Hạn mức tín dụng + builder.Property(b => b.CreditLimit) + .HasColumnName("credit_limit") + .HasColumnType("decimal(18,2)") + .IsRequired(); + + // EN: Billing threshold as owned entity (value object) / VI: Ngưỡng billing như owned entity + builder.OwnsOne(b => b.Threshold, threshold => + { + threshold.Property(t => t.Amount) + .HasColumnName("threshold_amount") + .HasColumnType("decimal(18,2)"); + + threshold.Property(t => t.AutoCharge) + .HasColumnName("threshold_auto_charge"); + }); + + // EN: Navigation for owned entity / VI: Navigation cho owned entity + builder.Navigation(b => b.Threshold).IsRequired(false); + + // EN: Timestamps / VI: Thời gian + builder.Property(b => b.CreatedAt) + .HasColumnName("created_at") + .IsRequired(); + + builder.Property(b => b.UpdatedAt) + .HasColumnName("updated_at") + .IsRequired(false); + + // EN: Indexes / VI: Chỉ mục + builder.HasIndex(b => b.CreatedAt) + .HasDatabaseName("ix_billing_accounts_created_at"); + } +} diff --git a/services/ads-billing-service-net/src/AdsBillingService.Infrastructure/EntityConfigurations/ClientRequestEntityTypeConfiguration.cs b/services/ads-billing-service-net/src/AdsBillingService.Infrastructure/EntityConfigurations/ClientRequestEntityTypeConfiguration.cs new file mode 100644 index 00000000..c71e8024 --- /dev/null +++ b/services/ads-billing-service-net/src/AdsBillingService.Infrastructure/EntityConfigurations/ClientRequestEntityTypeConfiguration.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using AdsBillingService.Infrastructure.Idempotency; + +namespace AdsBillingService.Infrastructure.EntityConfigurations; + +/// +/// EN: Entity configuration for ClientRequest (idempotency). +/// VI: Cấu hình entity cho ClientRequest (idempotency). +/// +public class ClientRequestEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + // EN: Table name / VI: Tên bảng + builder.ToTable("client_requests"); + + // EN: Primary key / VI: Khóa chính + builder.HasKey(cr => cr.Id); + builder.Property(cr => cr.Id) + .HasColumnName("id") + .IsRequired(); + + // EN: Request ID / VI: ID yêu cầu + builder.Property(cr => cr.Name) + .HasColumnName("name") + .HasMaxLength(200) + .IsRequired(); + + builder.HasIndex(cr => cr.Name) + .IsUnique() + .HasDatabaseName("ix_client_requests_name"); + + // EN: Timestamp / VI: Thời gian + builder.Property(cr => cr.Time) + .HasColumnName("time") + .IsRequired(); + } +} diff --git a/services/ads-billing-service-net/src/AdsBillingService.Infrastructure/EntityConfigurations/InvoiceEntityTypeConfiguration.cs b/services/ads-billing-service-net/src/AdsBillingService.Infrastructure/EntityConfigurations/InvoiceEntityTypeConfiguration.cs new file mode 100644 index 00000000..b0635e5a --- /dev/null +++ b/services/ads-billing-service-net/src/AdsBillingService.Infrastructure/EntityConfigurations/InvoiceEntityTypeConfiguration.cs @@ -0,0 +1,80 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using AdsBillingService.Domain.AggregatesModel.InvoiceAggregate; + +namespace AdsBillingService.Infrastructure.EntityConfigurations; + +/// +/// EN: Entity configuration for Invoice aggregate. +/// VI: Cấu hình entity cho aggregate Invoice. +/// +public class InvoiceEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + // EN: Table name / VI: Tên bảng + builder.ToTable("invoices"); + + // EN: Primary key / VI: Khóa chính + builder.HasKey(i => i.Id); + builder.Property(i => i.Id) + .HasColumnName("id") + .IsRequired(); + + // EN: Billing account ID / VI: ID tài khoản billing + builder.Property(i => i.BillingAccountId) + .HasColumnName("billing_account_id") + .IsRequired(); + + builder.HasIndex(i => i.BillingAccountId) + .HasDatabaseName("ix_invoices_billing_account_id"); + + // EN: Invoice number / VI: Số hóa đơn + builder.Property(i => i.InvoiceNumber) + .HasColumnName("invoice_number") + .HasMaxLength(50) + .IsRequired(); + + builder.HasIndex(i => i.InvoiceNumber) + .IsUnique() + .HasDatabaseName("ix_invoices_invoice_number"); + + // EN: Status / VI: Trạng thái + builder.Property(i => i.Status) + .HasColumnName("status") + .HasConversion() + .IsRequired(); + + builder.HasIndex(i => i.Status) + .HasDatabaseName("ix_invoices_status"); + + // EN: Issue date / VI: Ngày phát hành + builder.Property(i => i.IssueDate) + .HasColumnName("issue_date") + .IsRequired(); + + // EN: Due date / VI: Ngày đến hạn + builder.Property(i => i.DueDate) + .HasColumnName("due_date") + .IsRequired(); + + builder.HasIndex(i => i.DueDate) + .HasDatabaseName("ix_invoices_due_date"); + + // EN: Total amount / VI: Tổng tiền + builder.Property(i => i.TotalAmount) + .HasColumnName("total_amount") + .HasColumnType("decimal(18,2)") + .IsRequired(); + + // EN: Line items as collection / VI: Các dòng chi tiết + builder.HasMany() + .WithOne() + .HasForeignKey("InvoiceId") + .OnDelete(DeleteBehavior.Cascade); + + // EN: Metadata / VI: Ignore navigation property for EF Core + builder.Metadata.FindNavigation(nameof(Invoice.LineItems))! + .SetPropertyAccessMode(PropertyAccessMode.Field); + } +} diff --git a/services/ads-billing-service-net/src/AdsBillingService.Infrastructure/EntityConfigurations/InvoiceLineItemEntityTypeConfiguration.cs b/services/ads-billing-service-net/src/AdsBillingService.Infrastructure/EntityConfigurations/InvoiceLineItemEntityTypeConfiguration.cs new file mode 100644 index 00000000..6b2bbee5 --- /dev/null +++ b/services/ads-billing-service-net/src/AdsBillingService.Infrastructure/EntityConfigurations/InvoiceLineItemEntityTypeConfiguration.cs @@ -0,0 +1,57 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using AdsBillingService.Domain.AggregatesModel.InvoiceAggregate; + +namespace AdsBillingService.Infrastructure.EntityConfigurations; + +/// +/// EN: Entity configuration for InvoiceLineItem. +/// VI: Cấu hình entity cho InvoiceLineItem. +/// +public class InvoiceLineItemEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + // EN: Table name / VI: Tên bảng + builder.ToTable("invoice_line_items"); + + // EN: Primary key / VI: Khóa chính + builder.HasKey(li => li.Id); + builder.Property(li => li.Id) + .HasColumnName("id") + .IsRequired(); + + // EN: Invoice ID (foreign key) / VI: ID hóa đơn + builder.Property("InvoiceId") + .HasColumnName("invoice_id") + .IsRequired(); + + builder.HasIndex("InvoiceId") + .HasDatabaseName("ix_invoice_line_items_invoice_id"); + + // EN: Campaign ID / VI: ID chiến dịch + builder.Property(li => li.CampaignId) + .HasColumnName("campaign_id") + .IsRequired(); + + // EN: Description / VI: Mô tả + builder.Property(li => li.Description) + .HasColumnName("description") + .HasMaxLength(500) + .IsRequired(); + + // EN: Quantity / VI: Số lượng + builder.Property(li => li.Quantity) + .HasColumnName("quantity") + .IsRequired(); + + // EN: Unit price / VI: Đơn giá + builder.Property(li => li.UnitPrice) + .HasColumnName("unit_price") + .HasColumnType("decimal(18,2)") + .IsRequired(); + + // EN: Total amount is computed / VI: Tổng tiền được tính toán + builder.Ignore(li => li.TotalAmount); + } +} diff --git a/services/ads-serving-service-net/src/AdsServingService.API/AdsServingService.API.csproj b/services/ads-serving-service-net/src/AdsServingService.API/AdsServingService.API.csproj index 9a6f39e4..f3dc6957 100644 --- a/services/ads-serving-service-net/src/AdsServingService.API/AdsServingService.API.csproj +++ b/services/ads-serving-service-net/src/AdsServingService.API/AdsServingService.API.csproj @@ -33,6 +33,12 @@ + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + diff --git a/services/ads-serving-service-net/src/AdsServingService.API/Application/Queries/GetAuctionsQuery.cs b/services/ads-serving-service-net/src/AdsServingService.API/Application/Queries/GetAuctionsQuery.cs new file mode 100644 index 00000000..3f00206c --- /dev/null +++ b/services/ads-serving-service-net/src/AdsServingService.API/Application/Queries/GetAuctionsQuery.cs @@ -0,0 +1,38 @@ +using MediatR; + +namespace AdsServingService.API.Application.Queries; + +/// +/// EN: Query to get paginated list of auctions with filters. +/// VI: Query lấy danh sách auctions phân trang với bộ lọc. +/// +public class GetAuctionsQuery : IRequest> +{ + public Guid? UserId { get; init; } + public string? PlacementType { get; init; } + public DateTime? StartDate { get; init; } + public DateTime? EndDate { get; init; } + public int Page { get; init; } = 1; + public int PageSize { get; init; } = 20; +} + +public record AuctionDto +{ + public Guid Id { get; init; } + public Guid UserId { get; init; } + public string PlacementType { get; init; } = string.Empty; + public DateTime AuctionTime { get; init; } + public int BidCount { get; init; } + public Guid? WinningAdId { get; init; } + public decimal? FinalPrice { get; init; } + public decimal? WinningeCPM { get; init; } +} + +public record PagedResult +{ + public List Items { get; init; } = new(); + public int TotalCount { get; init; } + public int Page { get; init; } + public int PageSize { get; init; } + public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize); +} diff --git a/services/ads-serving-service-net/src/AdsServingService.API/Application/Queries/GetAuctionsQueryHandler.cs b/services/ads-serving-service-net/src/AdsServingService.API/Application/Queries/GetAuctionsQueryHandler.cs new file mode 100644 index 00000000..1602e908 --- /dev/null +++ b/services/ads-serving-service-net/src/AdsServingService.API/Application/Queries/GetAuctionsQueryHandler.cs @@ -0,0 +1,66 @@ +using AdsServingService.Infrastructure; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace AdsServingService.API.Application.Queries; + +/// +/// EN: Handler for GetAuctionsQuery. +/// VI: Handler cho GetAuctionsQuery. +/// +public class GetAuctionsQueryHandler : IRequestHandler> +{ + private readonly AdsServingServiceContext _context; + + public GetAuctionsQueryHandler(AdsServingServiceContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + public async Task> Handle(GetAuctionsQuery request, CancellationToken cancellationToken) + { + var query = _context.Auctions.AsQueryable(); + + // Apply filters + if (request.UserId.HasValue) + query = query.Where(a => a.UserId == request.UserId.Value); + + if (!string.IsNullOrEmpty(request.PlacementType)) + query = query.Where(a => a.PlacementType == request.PlacementType); + + if (request.StartDate.HasValue) + query = query.Where(a => EF.Property(a, "_auctionTime") >= request.StartDate.Value); + + if (request.EndDate.HasValue) + query = query.Where(a => EF.Property(a, "_auctionTime") <= request.EndDate.Value); + + // Get total count + var totalCount = await query.CountAsync(cancellationToken); + + // Apply pagination + var auctions = await query + .OrderByDescending(a => EF.Property(a, "_auctionTime")) + .Skip((request.Page - 1) * request.PageSize) + .Take(request.PageSize) + .Select(a => new AuctionDto + { + Id = a.Id, + UserId = a.UserId, + PlacementType = a.PlacementType, + AuctionTime = EF.Property(a, "_auctionTime"), + BidCount = a.Bids.Count, + WinningAdId = a.Result != null ? a.Result.WinningAdId : null, + FinalPrice = a.Result != null ? a.Result.FinalPrice : null, + WinningeCPM = a.Result != null ? a.Result.WinningeCPM : null + }) + .ToListAsync(cancellationToken); + + return new PagedResult + { + Items = auctions, + TotalCount = totalCount, + Page = request.Page, + PageSize = request.PageSize + }; + } +} diff --git a/services/ads-serving-service-net/src/AdsServingService.API/Application/Queries/GetBudgetPacersQuery.cs b/services/ads-serving-service-net/src/AdsServingService.API/Application/Queries/GetBudgetPacersQuery.cs new file mode 100644 index 00000000..6422cf49 --- /dev/null +++ b/services/ads-serving-service-net/src/AdsServingService.API/Application/Queries/GetBudgetPacersQuery.cs @@ -0,0 +1,35 @@ +using MediatR; + +namespace AdsServingService.API.Application.Queries; + +/// +/// EN: Query to get paginated list of budget pacers. +/// VI: Query lấy danh sách budget pacers phân trang. +/// +public class GetBudgetPacersQuery : IRequest> +{ + public Guid? CampaignId { get; init; } + public int Page { get; init; } = 1; + public int PageSize { get; init; } = 20; +} + +public record BudgetPacerDto +{ + public Guid Id { get; init; } + public Guid CampaignId { get; init; } + public decimal DailyBudget { get; init; } + public decimal SpentToday { get; init; } + public decimal RemainingBudget { get; init; } + public decimal UtilizationPercent { get; init; } + public string Strategy { get; init; } = string.Empty; + public DateTime LastUpdated { get; init; } +} + +public record BudgetStatisticsDto +{ + public int TotalCampaigns { get; init; } + public decimal TotalDailyBudget { get; init; } + public decimal TotalSpentToday { get; init; } + public decimal AverageUtilization { get; init; } + public int CampaignsExceeded { get; init; } +} diff --git a/services/ads-serving-service-net/src/AdsServingService.API/Application/Queries/GetBudgetPacersQueryHandler.cs b/services/ads-serving-service-net/src/AdsServingService.API/Application/Queries/GetBudgetPacersQueryHandler.cs new file mode 100644 index 00000000..d2b6570f --- /dev/null +++ b/services/ads-serving-service-net/src/AdsServingService.API/Application/Queries/GetBudgetPacersQueryHandler.cs @@ -0,0 +1,57 @@ +using AdsServingService.Infrastructure; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace AdsServingService.API.Application.Queries; + +/// +/// EN: Handler for GetBudgetPacersQuery. +/// VI: Handler cho GetBudgetPacersQuery. +/// +public class GetBudgetPacersQueryHandler : IRequestHandler> +{ + private readonly AdsServingServiceContext _context; + + public GetBudgetPacersQueryHandler(AdsServingServiceContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + public async Task> Handle(GetBudgetPacersQuery request, CancellationToken cancellationToken) + { + var query = _context.BudgetPacers.AsQueryable(); + + // Apply filters + if (request.CampaignId.HasValue) + query = query.Where(bp => EF.Property(bp, "_campaignId") == request.CampaignId.Value); + + // Get total count + var totalCount = await query.CountAsync(cancellationToken); + + // Apply pagination + var pacers = await query + .OrderByDescending(bp => EF.Property(bp, "_lastUpdated")) + .Skip((request.Page - 1) * request.PageSize) + .Take(request.PageSize) + .Select(bp => new BudgetPacerDto + { + Id = bp.Id, + CampaignId = EF.Property(bp, "_campaignId"), + DailyBudget = EF.Property(bp, "_dailyBudget"), + SpentToday = EF.Property(bp, "_spentToday"), + RemainingBudget = bp.RemainingBudget, + UtilizationPercent = bp.UtilizationPercent, + Strategy = bp.Strategy.ToString(), + LastUpdated = EF.Property(bp, "_lastUpdated") + }) + .ToListAsync(cancellationToken); + + return new PagedResult + { + Items = pacers, + TotalCount = totalCount, + Page = request.Page, + PageSize = request.PageSize + }; + } +} diff --git a/services/ads-serving-service-net/src/AdsServingService.API/Controllers/AdminAuctionsController.cs b/services/ads-serving-service-net/src/AdsServingService.API/Controllers/AdminAuctionsController.cs new file mode 100644 index 00000000..2bf41c21 --- /dev/null +++ b/services/ads-serving-service-net/src/AdsServingService.API/Controllers/AdminAuctionsController.cs @@ -0,0 +1,80 @@ +using AdsServingService.API.Application.Queries; +using MediatR; +using Microsoft.AspNetCore.Mvc; + +namespace AdsServingService.API.Controllers; + +/// +/// EN: Admin API Controller for managing and monitoring auctions. +/// VI: API Controller Admin để quản lý và giám sát các phiên đấu giá. +/// +[ApiController] +[Route("api/v1/admin/auctions")] +[Produces("application/json")] +public class AdminAuctionsController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public AdminAuctionsController(IMediator mediator, ILogger logger) + { + _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// EN: Get paginated list of auctions with optional filters. + /// VI: Lấy danh sách auctions phân trang với bộ lọc tùy chọn. + /// + [HttpGet] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + public async Task>> GetAuctions( + [FromQuery] Guid? userId, + [FromQuery] string? placementType, + [FromQuery] DateTime? startDate, + [FromQuery] DateTime? endDate, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20) + { + var query = new GetAuctionsQuery + { + UserId = userId, + PlacementType = placementType, + StartDate = startDate, + EndDate = endDate, + Page = page, + PageSize = pageSize + }; + + var result = await _mediator.Send(query); + return Ok(result); + } + + /// + /// EN: Get auction statistics (win rates, average eCPM, etc.). + /// VI: Lấy thống kê đấu giá (tỷ lệ thắng, eCPM trung bình, v.v.). + /// + [HttpGet("statistics")] + [ProducesResponseType(typeof(AuctionStatisticsDto), StatusCodes.Status200OK)] + public ActionResult GetStatistics() + { + // TODO: Implement statistics query handler + _logger.LogInformation("Auction statistics requested"); + + return Ok(new AuctionStatisticsDto + { + TotalAuctions = 0, + AverageWinRate = 0, + AverageeCPM = 0, + TotalBidsPlaced = 0 + }); + } +} + +public record AuctionStatisticsDto +{ + public int TotalAuctions { get; init; } + public decimal AverageWinRate { get; init; } + public decimal AverageeCPM { get; init; } + public long TotalBidsPlaced { get; init; } +} diff --git a/services/ads-serving-service-net/src/AdsServingService.Infrastructure/AdsServingServiceContext.cs b/services/ads-serving-service-net/src/AdsServingService.Infrastructure/AdsServingServiceContext.cs index 627e1d35..54fde8ec 100644 --- a/services/ads-serving-service-net/src/AdsServingService.Infrastructure/AdsServingServiceContext.cs +++ b/services/ads-serving-service-net/src/AdsServingService.Infrastructure/AdsServingServiceContext.cs @@ -36,7 +36,9 @@ public class AdsServingServiceContext : DbContext, IUnitOfWork protected override void OnModelCreating(ModelBuilder modelBuilder) { - // Entity configurations will be added here + // EN: Apply all entity configurations from current assembly + // VI: Áp dụng tất cả entity configurations từ assembly hiện tại + modelBuilder.ApplyConfigurationsFromAssembly(typeof(AdsServingServiceContext).Assembly); } public async Task SaveEntitiesAsync(CancellationToken cancellationToken = default) diff --git a/services/ads-serving-service-net/src/AdsServingService.Infrastructure/EntityConfigurations/AuctionEntityTypeConfiguration.cs b/services/ads-serving-service-net/src/AdsServingService.Infrastructure/EntityConfigurations/AuctionEntityTypeConfiguration.cs new file mode 100644 index 00000000..f5c0235d --- /dev/null +++ b/services/ads-serving-service-net/src/AdsServingService.Infrastructure/EntityConfigurations/AuctionEntityTypeConfiguration.cs @@ -0,0 +1,85 @@ +using AdsServingService.Domain.AggregatesModel.AuctionAggregate; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace AdsServingService.Infrastructure.EntityConfigurations; + +/// +/// EN: EF Core entity configuration for Auction aggregate. +/// VI: Cấu hình entity EF Core cho aggregate Auction. +/// +public class AuctionEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + // Table mapping + builder.ToTable("auctions"); + + // Primary key + builder.HasKey(a => a.Id); + builder.Property(a => a.Id) + .HasColumnName("id") + .ValueGeneratedNever(); + + // Properties + builder.Property(a => a.UserId) + .HasColumnName("user_id") + .IsRequired(); + + builder.Property(a => a.PlacementType) + .HasColumnName("placement_type") + .HasMaxLength(50) + .IsRequired(); + + builder.Property("_auctionTime") + .HasColumnName("auction_time") + .IsRequired(); + + // Owned collection: Bids (stored as JSONB for performance) + // EN: Store bids as JSONB for fast read/write in high-throughput scenarios + // VI: Lưu bids dưới dạng JSONB để đọc/ghi nhanh trong các tình huống throughput cao + // Note: EF Core 10 has issues with OwnsMany + ToJson, so we store as a simple property + builder.Ignore(a => a.Bids); + builder.Property("_bidsJson") + .HasColumnName("bids") + .HasColumnType("jsonb"); + + // Owned value object: AuctionResult (nullable) + builder.OwnsOne("_result", resultBuilder => + { + resultBuilder.Property(r => r.WinningAdId) + .HasColumnName("winning_ad_id"); + + resultBuilder.Property(r => r.WinningCampaignId) + .HasColumnName("winning_campaign_id"); + + resultBuilder.Property(r => r.FinalPrice) + .HasColumnName("final_price") + .HasColumnType("decimal(18,4)"); + + resultBuilder.Property(r => r.WinningeCPM) + .HasColumnName("winning_ecpm") + .HasColumnType("decimal(18,4)"); + }); + + // Indexes for query performance + // EN: Index on user_id for user-specific queries + // VI: Index trên user_id cho các truy vấn theo người dùng + builder.HasIndex(a => a.UserId) + .HasDatabaseName("ix_auctions_user_id"); + + // EN: Index on placement_type for placement-specific queries + // VI: Index trên placement_type cho các truy vấn theo vị trí + builder.HasIndex(a => a.PlacementType) + .HasDatabaseName("ix_auctions_placement_type"); + + // EN: Index on auction_time for time-range queries + // VI: Index trên auction_time cho các truy vấn theo khoảng thời gian + builder.HasIndex("_auctionTime") + .HasDatabaseName("ix_auctions_auction_time"); + + // Composite index for common queries + builder.HasIndex(a => new { a.PlacementType, a.UserId }) + .HasDatabaseName("ix_auctions_placement_user"); + } +} diff --git a/services/ads-serving-service-net/src/AdsServingService.Infrastructure/EntityConfigurations/BudgetPacerEntityTypeConfiguration.cs b/services/ads-serving-service-net/src/AdsServingService.Infrastructure/EntityConfigurations/BudgetPacerEntityTypeConfiguration.cs new file mode 100644 index 00000000..a46588cf --- /dev/null +++ b/services/ads-serving-service-net/src/AdsServingService.Infrastructure/EntityConfigurations/BudgetPacerEntityTypeConfiguration.cs @@ -0,0 +1,61 @@ +using AdsServingService.Domain.AggregatesModel.PacingAggregate; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace AdsServingService.Infrastructure.EntityConfigurations; + +/// +/// EN: EF Core entity configuration for BudgetPacer aggregate. +/// VI: Cấu hình entity EF Core cho aggregate BudgetPacer. +/// +public class BudgetPacerEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + // Table mapping + builder.ToTable("budget_pacers"); + + // Primary key + builder.HasKey(bp => bp.Id); + builder.Property(bp => bp.Id) + .HasColumnName("id") + .ValueGeneratedNever(); + + // Properties + builder.Property("_campaignId") + .HasColumnName("campaign_id") + .IsRequired(); + + builder.Property("_dailyBudget") + .HasColumnName("daily_budget") + .HasColumnType("decimal(18,4)") + .IsRequired(); + + builder.Property("_spentToday") + .HasColumnName("spent_today") + .HasColumnType("decimal(18,4)") + .IsRequired(); + + builder.Property("_strategy") + .HasColumnName("strategy") + .HasConversion() + .HasMaxLength(20) + .IsRequired(); + + builder.Property("_lastUpdated") + .HasColumnName("last_updated") + .IsRequired(); + + // Indexes + // EN: Unique index on campaign_id (one pacer per campaign) + // VI: Index duy nhất trên campaign_id (một pacer cho mỗi campaign) + builder.HasIndex("_campaignId") + .IsUnique() + .HasDatabaseName("ix_budget_pacers_campaign_id"); + + // EN: Index on last_updated for finding stale pacers + // VI: Index trên last_updated để tìm các pacer cũ + builder.HasIndex("_lastUpdated") + .HasDatabaseName("ix_budget_pacers_last_updated"); + } +} diff --git a/services/ads-serving-service-net/src/AdsServingService.Infrastructure/EntityConfigurations/FrequencyCapEntityTypeConfiguration.cs b/services/ads-serving-service-net/src/AdsServingService.Infrastructure/EntityConfigurations/FrequencyCapEntityTypeConfiguration.cs new file mode 100644 index 00000000..0168c98a --- /dev/null +++ b/services/ads-serving-service-net/src/AdsServingService.Infrastructure/EntityConfigurations/FrequencyCapEntityTypeConfiguration.cs @@ -0,0 +1,45 @@ +using AdsServingService.Domain.AggregatesModel.FrequencyAggregate; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace AdsServingService.Infrastructure.EntityConfigurations; + +/// +/// EN: EF Core entity configuration for FrequencyCap aggregate. +/// VI: Cấu hình entity EF Core cho aggregate FrequencyCap. +/// +public class FrequencyCapEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + // Table mapping + builder.ToTable("frequency_caps"); + + // Primary key + builder.HasKey(fc => fc.Id); + builder.Property(fc => fc.Id) + .HasColumnName("id") + .ValueGeneratedNever(); + + // Properties + builder.Property("_adId") + .HasColumnName("ad_id") + .IsRequired(); + + builder.Property("_maxImpressionsPerUser") + .HasColumnName("max_impressions_per_user") + .IsRequired(); + + builder.Property("_window") + .HasColumnName("window") + .HasConversion() + .HasMaxLength(20) + .IsRequired(); + + // Indexes + // EN: Index on ad_id for ad-specific frequency cap lookups + // VI: Index trên ad_id để tra cứu frequency cap theo quảng cáo + builder.HasIndex("_adId") + .HasDatabaseName("ix_frequency_caps_ad_id"); + } +} diff --git a/services/booking-service-net/src/BookingService.Domain/AggregatesModel/AppointmentAggregate/AppointmentStatus.cs b/services/booking-service-net/src/BookingService.Domain/AggregatesModel/AppointmentAggregate/AppointmentStatus.cs new file mode 100644 index 00000000..5443dbbd --- /dev/null +++ b/services/booking-service-net/src/BookingService.Domain/AggregatesModel/AppointmentAggregate/AppointmentStatus.cs @@ -0,0 +1,24 @@ +// EN: Appointment status enumeration. +// VI: Enumeration trạng thái cuộc hẹn. + +using BookingService.Domain.SeedWork; + +namespace BookingService.Domain.AggregatesModel.AppointmentAggregate; + +/// +/// EN: Appointment status enumeration - represents the lifecycle of an appointment. +/// VI: Enumeration trạng thái cuộc hẹn - đại diện cho vòng đời của cuộc hẹn. +/// +public class AppointmentStatus : Enumeration +{ + public static AppointmentStatus Pending = new(1, nameof(Pending)); + public static AppointmentStatus Confirmed = new(2, nameof(Confirmed)); + public static AppointmentStatus InProgress = new(3, nameof(InProgress)); + public static AppointmentStatus Completed = new(4, nameof(Completed)); + public static AppointmentStatus Cancelled = new(5, nameof(Cancelled)); + public static AppointmentStatus NoShow = new(6, nameof(NoShow)); + + public AppointmentStatus(int id, string name) : base(id, name) + { + } +} diff --git a/services/booking-service-net/src/BookingService.Infrastructure/BookingContext.cs b/services/booking-service-net/src/BookingService.Infrastructure/BookingContext.cs index 5cb2fb43..6f2df09a 100644 --- a/services/booking-service-net/src/BookingService.Infrastructure/BookingContext.cs +++ b/services/booking-service-net/src/BookingService.Infrastructure/BookingContext.cs @@ -2,6 +2,8 @@ using MediatR; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; using BookingService.Domain.AggregatesModel.AppointmentAggregate; +using BookingService.Domain.AggregatesModel.ResourceAggregate; +using BookingService.Domain.AggregatesModel.StaffAggregate; using BookingService.Domain.SeedWork; using BookingService.Infrastructure.EntityConfigurations; @@ -13,6 +15,8 @@ public class BookingContext : DbContext, IUnitOfWork private IDbContextTransaction? _currentTransaction; public DbSet Appointments => Set(); + public DbSet Resources => Set(); + public DbSet StaffSchedules => Set(); public IDbContextTransaction? CurrentTransaction => _currentTransaction; public bool HasActiveTransaction => _currentTransaction != null; @@ -25,6 +29,8 @@ public class BookingContext : DbContext, IUnitOfWork { modelBuilder.ApplyConfiguration(new AppointmentEntityTypeConfiguration()); modelBuilder.ApplyConfiguration(new AppointmentStatusEntityTypeConfiguration()); + modelBuilder.ApplyConfiguration(new ResourceEntityTypeConfiguration()); + modelBuilder.ApplyConfiguration(new StaffScheduleEntityTypeConfiguration()); } public async Task SaveEntitiesAsync(CancellationToken cancellationToken = default) diff --git a/services/booking-service-net/src/BookingService.Infrastructure/DependencyInjection.cs b/services/booking-service-net/src/BookingService.Infrastructure/DependencyInjection.cs index 2fcf7edb..ba5c88d4 100644 --- a/services/booking-service-net/src/BookingService.Infrastructure/DependencyInjection.cs +++ b/services/booking-service-net/src/BookingService.Infrastructure/DependencyInjection.cs @@ -22,7 +22,7 @@ public static class DependencyInjection IConfiguration configuration) { // EN: Add DbContext with PostgreSQL / VI: Thêm DbContext với PostgreSQL - services.AddDbContext(options => + services.AddDbContext(options => { var connectionString = configuration.GetConnectionString("DefaultConnection") ?? configuration["DATABASE_URL"] @@ -30,7 +30,7 @@ public static class DependencyInjection options.UseNpgsql(connectionString, npgsqlOptions => { - npgsqlOptions.MigrationsAssembly(typeof(BookingServiceContext).Assembly.FullName); + npgsqlOptions.MigrationsAssembly(typeof(BookingContext).Assembly.FullName); npgsqlOptions.EnableRetryOnFailure( maxRetryCount: 5, maxRetryDelay: TimeSpan.FromSeconds(30), @@ -47,7 +47,7 @@ public static class DependencyInjection }); // EN: Register repositories / VI: Đăng ký repositories - services.AddScoped(); + services.AddScoped(); // EN: Register idempotency services / VI: Đăng ký idempotency services services.AddScoped(); diff --git a/services/booking-service-net/src/BookingService.Infrastructure/Entity Configurations/AppointmentEntityTypeConfiguration.cs b/services/booking-service-net/src/BookingService.Infrastructure/Entity Configurations/AppointmentEntityTypeConfiguration.cs deleted file mode 100644 index b4bf2c30..00000000 --- a/services/booking-service-net/src/BookingService.Infrastructure/Entity Configurations/AppointmentEntityTypeConfiguration.cs +++ /dev/null @@ -1,38 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using BookingService.Domain.AggregatesModel.AppointmentAggregate; - -namespace BookingService.Infrastructure.EntityConfigurations; - -public class AppointmentEntityTypeConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("appointments"); - builder.HasKey(a => a.Id); - builder.Property(a => a.Id).HasColumnName("id").ValueGeneratedNever(); - builder.Property("_shopId").HasColumnName("shop_id").IsRequired(); - builder.Property("_customerId").HasColumnName("customer_id").IsRequired(); - builder.Property("_serviceId").HasColumnName("service_id").IsRequired(); - builder.Property(a => a.StatusId).HasColumnName("status_id").IsRequired(); - builder.Property("_scheduledAt").HasColumnName("scheduled_at").IsRequired(); - builder.Property("_durationMinutes").HasColumnName("duration_minutes").IsRequired(); - builder.Property("_staffId").HasColumnName("staff_id"); - builder.Property("_resourceId").HasColumnName("resource_id"); - builder.Property("_notes").HasColumnName("notes").HasMaxLength(1000); - - builder.HasIndex("_shopId").HasDatabaseName("ix_appointments_shop_id"); - builder.HasIndex("_customerId").HasDatabaseName("ix_appointments_customer_id"); - builder.HasIndex("_scheduledAt").HasDatabaseName("ix_appointments_scheduled_at"); - - builder.Ignore(a => a.ShopId); - builder.Ignore(a => a.CustomerId); - builder.Ignore(a => a.ServiceId); - builder.Ignore(a => a.Status); - builder.Ignore(a => a.ScheduledAt); - builder.Ignore(a => a.DurationMinutes); - builder.Ignore(a => a.StaffId); - builder.Ignore(a => a.ResourceId); - builder.Ignore(a => a.Notes); - } -} diff --git a/services/booking-service-net/src/BookingService.Infrastructure/EntityConfigurations/ResourceEntityTypeConfiguration.cs b/services/booking-service-net/src/BookingService.Infrastructure/EntityConfigurations/ResourceEntityTypeConfiguration.cs new file mode 100644 index 00000000..60161e69 --- /dev/null +++ b/services/booking-service-net/src/BookingService.Infrastructure/EntityConfigurations/ResourceEntityTypeConfiguration.cs @@ -0,0 +1,64 @@ +// EN: Entity type configuration for Resource. +// VI: Cấu hình entity type cho Resource. + +using BookingService.Domain.AggregatesModel.ResourceAggregate; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace BookingService.Infrastructure.EntityConfigurations; + +/// +/// EN: Entity type configuration for Resource aggregate. +/// VI: Cấu hình entity type cho aggregate Resource. +/// +public class ResourceEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("resources"); + + builder.HasKey(r => r.Id); + builder.Property(r => r.Id) + .HasColumnName("id") + .ValueGeneratedNever(); + + builder.Property("_shopId") + .HasColumnName("shop_id") + .IsRequired(); + + builder.Property("_name") + .HasColumnName("name") + .HasMaxLength(200) + .IsRequired(); + + builder.Property("_resourceType") + .HasColumnName("resource_type") + .HasMaxLength(50) + .IsRequired(); + + builder.Property("_capacity") + .HasColumnName("capacity") + .IsRequired(); + + builder.Property("_isActive") + .HasColumnName("is_active") + .IsRequired(); + + builder.Property("_createdAt") + .HasColumnName("created_at") + .IsRequired(); + + // EN: Indexes / VI: Indexes + builder.HasIndex("_shopId") + .HasDatabaseName("ix_resources_shop_id"); + + // EN: Ignore public properties (mapped via backing fields) + // VI: Bỏ qua các properties công khai (đã map qua backing fields) + builder.Ignore(r => r.ShopId); + builder.Ignore(r => r.Name); + builder.Ignore(r => r.ResourceType); + builder.Ignore(r => r.Capacity); + builder.Ignore(r => r.IsActive); + builder.Ignore(r => r.CreatedAt); + } +} diff --git a/services/booking-service-net/src/BookingService.Infrastructure/EntityConfigurations/StaffScheduleEntityTypeConfiguration.cs b/services/booking-service-net/src/BookingService.Infrastructure/EntityConfigurations/StaffScheduleEntityTypeConfiguration.cs new file mode 100644 index 00000000..55a8cdf9 --- /dev/null +++ b/services/booking-service-net/src/BookingService.Infrastructure/EntityConfigurations/StaffScheduleEntityTypeConfiguration.cs @@ -0,0 +1,60 @@ +// EN: Entity type configuration for StaffSchedule. +// VI: Cấu hình entity type cho StaffSchedule. + +using BookingService.Domain.AggregatesModel.StaffAggregate; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace BookingService.Infrastructure.EntityConfigurations; + +/// +/// EN: Entity type configuration for StaffSchedule entity. +/// VI: Cấu hình entity type cho entity StaffSchedule. +/// +public class StaffScheduleEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("staff_schedules"); + + builder.HasKey(s => s.Id); + builder.Property(s => s.Id) + .HasColumnName("id") + .ValueGeneratedNever(); + + builder.Property("_staffId") + .HasColumnName("staff_id") + .IsRequired(); + + builder.Property("_shopId") + .HasColumnName("shop_id") + .IsRequired(); + + builder.Property("_dayOfWeek") + .HasColumnName("day_of_week") + .IsRequired(); + + builder.Property("_startTime") + .HasColumnName("start_time") + .IsRequired(); + + builder.Property("_endTime") + .HasColumnName("end_time") + .IsRequired(); + + // EN: Indexes / VI: Indexes + builder.HasIndex("_staffId", "_dayOfWeek") + .HasDatabaseName("ix_staff_schedules_staff_day"); + + builder.HasIndex("_shopId") + .HasDatabaseName("ix_staff_schedules_shop_id"); + + // EN: Ignore public properties (mapped via backing fields) + // VI: Bỏ qua các properties công khai (đã map qua backing fields) + builder.Ignore(s => s.StaffId); + builder.Ignore(s => s.ShopId); + builder.Ignore(s => s.DayOfWeek); + builder.Ignore(s => s.StartTime); + builder.Ignore(s => s.EndTime); + } +} diff --git a/services/booking-service-net/src/BookingService.Infrastructure/Idempotency/RequestManager.cs b/services/booking-service-net/src/BookingService.Infrastructure/Idempotency/RequestManager.cs index 302a4984..945d5d99 100644 --- a/services/booking-service-net/src/BookingService.Infrastructure/Idempotency/RequestManager.cs +++ b/services/booking-service-net/src/BookingService.Infrastructure/Idempotency/RequestManager.cs @@ -8,9 +8,9 @@ namespace BookingService.Infrastructure.Idempotency; /// public class RequestManager : IRequestManager { - private readonly BookingServiceContext _context; + private readonly BookingContext _context; - public RequestManager(BookingServiceContext context) + public RequestManager(BookingContext context) { _context = context ?? throw new ArgumentNullException(nameof(context)); } diff --git a/services/booking-service-net/src/BookingService.Infrastructure/Repositories/AppointmentRepository.cs b/services/booking-service-net/src/BookingService.Infrastructure/Repositories/AppointmentRepository.cs new file mode 100644 index 00000000..138234e8 --- /dev/null +++ b/services/booking-service-net/src/BookingService.Infrastructure/Repositories/AppointmentRepository.cs @@ -0,0 +1,47 @@ +// EN: Repository implementation for Appointment aggregate. +// VI: Implementation repository cho aggregate Appointment. + +using BookingService.Domain.AggregatesModel.AppointmentAggregate; +using BookingService.Domain.SeedWork; +using Microsoft.EntityFrameworkCore; + +namespace BookingService.Infrastructure.Repositories; + +/// +/// EN: Repository implementation for managing Appointment aggregate persistence. +/// VI: Implementation repository để quản lý persistence của aggregate Appointment. +/// +public class AppointmentRepository : IAppointmentRepository +{ + private readonly BookingContext _context; + + public IUnitOfWork UnitOfWork => _context; + + public AppointmentRepository(BookingContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + public Appointment Add(Appointment appointment) + { + return _context.Appointments.Add(appointment).Entity; + } + + public void Update(Appointment appointment) + { + _context.Entry(appointment).State = EntityState.Modified; + } + + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _context.Appointments + .FirstOrDefaultAsync(a => a.Id == id, cancellationToken); + } + + public async Task> GetByShopIdAsync(Guid shopId, CancellationToken cancellationToken = default) + { + return await _context.Appointments + .Where(a => a.ShopId == shopId) + .ToListAsync(cancellationToken); + } +} diff --git a/services/booking-service-net/src/BookingService.Infrastructure/Repositories/IAppointmentRepository.cs b/services/booking-service-net/src/BookingService.Infrastructure/Repositories/IAppointmentRepository.cs new file mode 100644 index 00000000..15fa32fa --- /dev/null +++ b/services/booking-service-net/src/BookingService.Infrastructure/Repositories/IAppointmentRepository.cs @@ -0,0 +1,38 @@ +// EN: Repository interface for Appointment aggregate. +// VI: Interface repository cho aggregate Appointment. + +using BookingService.Domain.AggregatesModel.AppointmentAggregate; +using BookingService.Domain.SeedWork; + +namespace BookingService.Infrastructure.Repositories; + +/// +/// EN: Repository interface for managing Appointment aggregate persistence. +/// VI: Interface repository để quản lý persistence của aggregate Appointment. +/// +public interface IAppointmentRepository : IRepository +{ + /// + /// EN: Add a new appointment. + /// VI: Thêm cuộc hẹn mới. + /// + Appointment Add(Appointment appointment); + + /// + /// EN: Update an existing appointment. + /// VI: Cập nhật cuộc hẹn hiện có. + /// + void Update(Appointment appointment); + + /// + /// EN: Get appointment by ID. + /// VI: Lấy cuộc hẹn theo ID. + /// + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + + /// + /// EN: Get appointments by shop ID. + /// VI: Lấy danh sách cuộc hẹn theo shop ID. + /// + Task> GetByShopIdAsync(Guid shopId, CancellationToken cancellationToken = default); +} diff --git a/services/catalog-service-net/src/CatalogService.API/Application/Commands/CreateCategoryCommand.cs b/services/catalog-service-net/src/CatalogService.API/Application/Commands/CreateCategoryCommand.cs new file mode 100644 index 00000000..709d00e5 --- /dev/null +++ b/services/catalog-service-net/src/CatalogService.API/Application/Commands/CreateCategoryCommand.cs @@ -0,0 +1,43 @@ +// EN: Command to create a new category. +// VI: Command để tạo danh mục mới. + +using MediatR; + +namespace CatalogService.API.Application.Commands; + +/// +/// EN: Command to create a new category. +/// VI: Command để tạo danh mục mới. +/// +public record CreateCategoryCommand : IRequest +{ + /// + /// EN: Shop ID that will own this category. + /// VI: ID shop sẽ sở hữu danh mục. + /// + public Guid ShopId { get; init; } + + /// + /// EN: Category name. + /// VI: Tên danh mục. + /// + public string Name { get; init; } = string.Empty; + + /// + /// EN: Category description. + /// VI: Mô tả danh mục. + /// + public string? Description { get; init; } + + /// + /// EN: Parent category ID for hierarchical categories. + /// VI: ID danh mục cha cho danh mục phân cấp. + /// + public Guid? ParentId { get; init; } + + /// + /// EN: Display order. + /// VI: Thứ tự hiển thị. + /// + public int DisplayOrder { get; init; } +} diff --git a/services/catalog-service-net/src/CatalogService.API/Application/Commands/CreateCategoryCommandHandler.cs b/services/catalog-service-net/src/CatalogService.API/Application/Commands/CreateCategoryCommandHandler.cs new file mode 100644 index 00000000..03bc124c --- /dev/null +++ b/services/catalog-service-net/src/CatalogService.API/Application/Commands/CreateCategoryCommandHandler.cs @@ -0,0 +1,41 @@ +// EN: Handler for CreateCategoryCommand. +// VI: Handler cho CreateCategoryCommand. + +using MediatR; +using CatalogService.Domain.AggregatesModel.ProductAggregate; +using CatalogService.Infrastructure; + +namespace CatalogService.API.Application.Commands; + +/// +/// EN: Handler for creating a new category. +/// VI: Handler để tạo danh mục mới. +/// +public class CreateCategoryCommandHandler : IRequestHandler +{ + private readonly CatalogContext _context; + + public CreateCategoryCommandHandler(CatalogContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + public async Task Handle(CreateCategoryCommand request, CancellationToken cancellationToken) + { + // EN: Create category entity + // VI: Tạo category entity + var category = new Category( + shopId: request.ShopId, + name: request.Name, + description: request.Description, + parentId: request.ParentId, + displayOrder: request.DisplayOrder); + + // EN: Add to context and save + // VI: Thêm vào context và lư + _context.Categories.Add(category); + await _context.SaveChangesAsync(cancellationToken); + + return category.Id; + } +} diff --git a/services/catalog-service-net/src/CatalogService.API/Application/Commands/CreateProductCommand.cs b/services/catalog-service-net/src/CatalogService.API/Application/Commands/CreateProductCommand.cs new file mode 100644 index 00000000..d55dad7c --- /dev/null +++ b/services/catalog-service-net/src/CatalogService.API/Application/Commands/CreateProductCommand.cs @@ -0,0 +1,61 @@ +// EN: Command to create a new product. +// VI: Command để tạo sản phẩm mới. + +using MediatR; + +namespace CatalogService.API.Application.Commands; + +/// +/// EN: Command to create a new product. +/// VI: Command để tạo sản phẩm mới. +/// +public record CreateProductCommand : IRequest +{ + /// + /// EN: Shop ID that will own this product. + /// VI: ID shop sẽ sở hữu sản phẩm. + /// + public Guid ShopId { get; init; } + + /// + /// EN: Product name. + /// VI: Tên sản phẩm. + /// + public string Name { get; init; } = string.Empty; + + /// + /// EN: Product description. + /// VI: Mô tả sản phẩm. + /// + public string? Description { get; init; } + + /// + /// EN: Product price. + /// VI: Giá sản phẩm. + /// + public decimal Price { get; init; } + + /// + /// EN: Product type (Physical, Service, PreparedFood). + /// VI: Loại sản phẩm (Physical, Service, PreparedFood). + /// + public string Type { get; init; } = string.Empty; + + /// + /// EN: Type-specific attributes as JSON. + /// VI: Thuộc tính theo loại dưới dạng JSON. + /// + public Dictionary? Attributes { get; init; } + + /// + /// EN: Stock Keeping Unit. + /// VI: Mã SKU. + /// + public string? Sku { get; init; } + + /// + /// EN: Product image URL. + /// VI: URL hình ảnh sản phẩm. + /// + public string? ImageUrl { get; init; } +} diff --git a/services/catalog-service-net/src/CatalogService.API/Application/Commands/CreateProductCommandHandler.cs b/services/catalog-service-net/src/CatalogService.API/Application/Commands/CreateProductCommandHandler.cs new file mode 100644 index 00000000..2729cb16 --- /dev/null +++ b/services/catalog-service-net/src/CatalogService.API/Application/Commands/CreateProductCommandHandler.cs @@ -0,0 +1,63 @@ +// EN: Handler for CreateProductCommand. +// VI: Handler cho CreateProductCommand. + +using System.Text.Json; +using MediatR; +using CatalogService.Domain.AggregatesModel.ProductAggregate; + +namespace CatalogService.API.Application.Commands; + +/// +/// EN: Handler for creating a new product. +/// VI: Handler để tạo sản phẩm mới. +/// +public class CreateProductCommandHandler : IRequestHandler +{ + private readonly IProductRepository _productRepository; + + public CreateProductCommandHandler(IProductRepository productRepository) + { + _productRepository = productRepository ?? throw new ArgumentNullException(nameof(productRepository)); + } + + public async Task Handle(CreateProductCommand request, CancellationToken cancellationToken) + { + // EN: Get ProductType enumeration + // VI: Lấy ProductType enumeration + var productType = ProductType.FromName(request.Type); + + // EN: Convert attributes dictionary to JsonDocument + // VI: Chuyển attributes dictionary sang JsonDocument + JsonDocument? attributesJson = null; + if (request.Attributes != null) + { + var json = JsonSerializer.Serialize(request.Attributes); + attributesJson = JsonDocument.Parse(json); + } + + // EN: Create product aggregate + // VI: Tạo product aggregate + var product = new Product( + shopId: request.ShopId, + name: request.Name, + price: request.Price, + type: productType, + description: request.Description, + attributes: attributesJson, + sku: request.Sku); + + // EN: Update image if provided + // VI: Cập nhật image nếu được cung cấp + if (!string.IsNullOrWhiteSpace(request.ImageUrl)) + { + product.UpdateImage(request.ImageUrl); + } + + // EN: Add to repository and save + // VI: Thêm vào repository và lưu + _productRepository.Add(product); + await _productRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + return product.Id; + } +} diff --git a/services/catalog-service-net/src/CatalogService.API/Application/Commands/DeleteProductCommand.cs b/services/catalog-service-net/src/CatalogService.API/Application/Commands/DeleteProductCommand.cs new file mode 100644 index 00000000..be53fd02 --- /dev/null +++ b/services/catalog-service-net/src/CatalogService.API/Application/Commands/DeleteProductCommand.cs @@ -0,0 +1,24 @@ +// EN: Command to delete a product. +// VI: Command để xóa sản phẩm. + +using MediatR; + +namespace CatalogService.API.Application.Commands; + +/// +/// EN: Command to delete a product. +/// VI: Command để xóa sản phẩm. +/// +public record DeleteProductCommand : IRequest +{ + /// + /// EN: Product ID to delete. + /// VI: ID sản phẩm cần xóa. + /// + public Guid ProductId { get; init; } + + public DeleteProductCommand(Guid productId) + { + ProductId = productId; + } +} diff --git a/services/catalog-service-net/src/CatalogService.API/Application/Commands/DeleteProductCommandHandler.cs b/services/catalog-service-net/src/CatalogService.API/Application/Commands/DeleteProductCommandHandler.cs new file mode 100644 index 00000000..909fe9d2 --- /dev/null +++ b/services/catalog-service-net/src/CatalogService.API/Application/Commands/DeleteProductCommandHandler.cs @@ -0,0 +1,45 @@ +// EN: Handler for DeleteProductCommand. +// VI: Handler cho DeleteProductCommand. + +using MediatR; +using CatalogService.Domain.AggregatesModel.ProductAggregate; +using CatalogService.Domain.Exceptions; + +namespace CatalogService.API.Application.Commands; + +/// +/// EN: Handler for deleting a product. +/// VI: Handler xóa sản phẩm. +/// +public class DeleteProductCommandHandler : IRequestHandler +{ + private readonly IProductRepository _productRepository; + + public DeleteProductCommandHandler(IProductRepository productRepository) + { + _productRepository = productRepository ?? throw new ArgumentNullException(nameof(productRepository)); + } + + public async Task Handle(DeleteProductCommand request, CancellationToken cancellationToken) + { + // EN: Retrieve product aggregate + // VI: Lấy product aggregate + var product = await _productRepository.GetByIdAsync(request.ProductId, cancellationToken); + + if (product == null) + { + throw new DomainException($"Product with ID {request.ProductId} not found"); + } + + // EN: Deactivate product instead of hard delete + // VI: Vô hiệu hóa sản phẩm thay vì xóa cứng + product.Deactivate(); + + // EN: Update repository and save + // VI: Cập nhật repository và lưu + _productRepository.Update(product); + await _productRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + return true; + } +} diff --git a/services/catalog-service-net/src/CatalogService.API/Application/Commands/UpdateProductCommand.cs b/services/catalog-service-net/src/CatalogService.API/Application/Commands/UpdateProductCommand.cs new file mode 100644 index 00000000..fb7db3ca --- /dev/null +++ b/services/catalog-service-net/src/CatalogService.API/Application/Commands/UpdateProductCommand.cs @@ -0,0 +1,49 @@ +// EN: Command to update an existing product. +// VI: Command để cập nhật sản phẩm hiện tại. + +using MediatR; + +namespace CatalogService.API.Application.Commands; + +/// +/// EN: Command to update product information. +/// VI: Command để cập nhật thông tin sản phẩm. +/// +public record UpdateProductCommand : IRequest +{ + /// + /// EN: Product ID to update. + /// VI: ID sản phẩm cần cập nhật. + /// + public Guid ProductId { get; init; } + + /// + /// EN: Product name. + /// VI: Tên sản phẩm. + /// + public string Name { get; init; } = string.Empty; + + /// + /// EN: Product description. + /// VI: Mô tả sản phẩm. + /// + public string? Description { get; init; } + + /// + /// EN: Product price. + /// VI: Giá sản phẩm. + /// + public decimal Price { get; init; } + + /// + /// EN: Type-specific attributes as JSON. + /// VI: Thuộc tính theo loại dưới dạng JSON. + /// + public Dictionary? Attributes { get; init; } + + /// + /// EN: Product image URL. + /// VI: URL hình ảnh sản phẩm. + /// + public string? ImageUrl { get; init; } +} diff --git a/services/catalog-service-net/src/CatalogService.API/Application/Commands/UpdateProductCommandHandler.cs b/services/catalog-service-net/src/CatalogService.API/Application/Commands/UpdateProductCommandHandler.cs new file mode 100644 index 00000000..58cf8458 --- /dev/null +++ b/services/catalog-service-net/src/CatalogService.API/Application/Commands/UpdateProductCommandHandler.cs @@ -0,0 +1,62 @@ +// EN: Handler for UpdateProductCommand. +// VI: Handler cho UpdateProductCommand. + +using System.Text.Json; +using MediatR; +using CatalogService.Domain.AggregatesModel.ProductAggregate; +using CatalogService.Domain.Exceptions; + +namespace CatalogService.API.Application.Commands; + +/// +/// EN: Handler for updating a product. +/// VI: Handler cập nhật sản phẩm. +/// +public class UpdateProductCommandHandler : IRequestHandler +{ + private readonly IProductRepository _productRepository; + + public UpdateProductCommandHandler(IProductRepository productRepository) + { + _productRepository = productRepository ?? throw new ArgumentNullException(nameof(productRepository)); + } + + public async Task Handle(UpdateProductCommand request, CancellationToken cancellationToken) + { + // EN: Retrieve product aggregate + // VI: Lấy product aggregate + var product = await _productRepository.GetByIdAsync(request.ProductId, cancellationToken); + + if (product == null) + { + throw new DomainException($"Product with ID {request.ProductId} not found"); + } + + // EN: Update basic information + // VI: Cập nhật thông tin cơ bản + product.UpdateInfo(request.Name, request.Description, request.Price); + + // EN: Update attributes if provided + // VI: Cập nhật attributes nếu được cung cấp + if (request.Attributes != null) + { + var json = JsonSerializer.Serialize(request.Attributes); + var attributesJson = JsonDocument.Parse(json); + product.UpdateAttributes(attributesJson); + } + + // EN: Update image if provided + // VI: Cập nhật image nếu được cung cấp + if (request.ImageUrl != null) + { + product.UpdateImage(request.ImageUrl); + } + + // EN: Update repository and save + // VI: Cập nhật repository và lưu + _productRepository.Update(product); + await _productRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + return true; + } +} diff --git a/services/catalog-service-net/src/CatalogService.API/Application/DTOs/CategoryDto.cs b/services/catalog-service-net/src/CatalogService.API/Application/DTOs/CategoryDto.cs new file mode 100644 index 00000000..ed0143a2 --- /dev/null +++ b/services/catalog-service-net/src/CatalogService.API/Application/DTOs/CategoryDto.cs @@ -0,0 +1,65 @@ +// EN: Category Data Transfer Object for read operations. +// VI: DTO Category cho các thao tác đọc. + +namespace CatalogService.API.Application.DTOs; + +/// +/// EN: Category DTO for API responses. +/// VI: DTO Category cho API response. +/// +public record CategoryDto +{ + /// + /// EN: Category ID. + /// VI: ID danh mục. + /// + public Guid Id { get; init; } + + /// + /// EN: Shop ID that owns this category. + /// VI: ID shop sở hữu danh mục. + /// + public Guid ShopId { get; init; } + + /// + /// EN: Category name. + /// VI: Tên danh mục. + /// + public string Name { get; init; } = string.Empty; + + /// + /// EN: Category description. + /// VI: Mô tả danh mục. + /// + public string? Description { get; init; } + + /// + /// EN: Parent category ID for hierarchical categories. + /// VI: ID danh mục cha cho danh mục phân cấp. + /// + public Guid? ParentId { get; init; } + + /// + /// EN: Display order. + /// VI: Thứ tự hiển thị. + /// + public int DisplayOrder { get; init; } + + /// + /// EN: Is category active. + /// VI: Danh mục có đang hoạt động không. + /// + public bool IsActive { get; init; } + + /// + /// EN: Creation timestamp. + /// VI: Thời gian tạo. + /// + public DateTime CreatedAt { get; init; } + + /// + /// EN: Last update timestamp. + /// VI: Thời gian cập nhật cuối. + /// + public DateTime? UpdatedAt { get; init; } +} diff --git a/services/catalog-service-net/src/CatalogService.API/Application/DTOs/PagedResult.cs b/services/catalog-service-net/src/CatalogService.API/Application/DTOs/PagedResult.cs new file mode 100644 index 00000000..3e9c8f3e --- /dev/null +++ b/services/catalog-service-net/src/CatalogService.API/Application/DTOs/PagedResult.cs @@ -0,0 +1,53 @@ +// EN: Paged result wrapper for query responses. +// VI: Wrapper kết quả phân trang cho query response. + +namespace CatalogService.API.Application.DTOs; + +/// +/// EN: Generic paged result for list queries. +/// VI: Kết quả phân trang chung cho các query danh sách. +/// +public record PagedResult +{ + /// + /// EN: Items in the current page. + /// VI: Các items trong trang hiện tại. + /// + public IReadOnlyList Items { get; init; } = Array.Empty(); + + /// + /// EN: Total count of items across all pages. + /// VI: Tổng số items trên tất cả các trang. + /// + public int TotalCount { get; init; } + + /// + /// EN: Current page number (1-indexed). + /// VI: Số trang hiện tại (bắt đầu từ 1). + /// + public int Page { get; init; } + + /// + /// EN: Page size. + /// VI: Kích thước trang. + /// + public int PageSize { get; init; } + + /// + /// EN: Total number of pages. + /// VI: Tổng số trang. + /// + public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize); + + /// + /// EN: Has previous page. + /// VI: Có trang trước không. + /// + public bool HasPrevious => Page > 1; + + /// + /// EN: Has next page. + /// VI: Có trang tiếp theo không. + /// + public bool HasNext => Page < TotalPages; +} diff --git a/services/catalog-service-net/src/CatalogService.API/Application/DTOs/ProductDto.cs b/services/catalog-service-net/src/CatalogService.API/Application/DTOs/ProductDto.cs new file mode 100644 index 00000000..ab9636db --- /dev/null +++ b/services/catalog-service-net/src/CatalogService.API/Application/DTOs/ProductDto.cs @@ -0,0 +1,83 @@ +// EN: Product Data Transfer Object for read operations. +// VI: DTO Product cho các thao tác đọc. + +namespace CatalogService.API.Application.DTOs; + +/// +/// EN: Product DTO for API responses. +/// VI: DTO Product cho API response. +/// +public record ProductDto +{ + /// + /// EN: Product ID. + /// VI: ID sản phẩm. + /// + public Guid Id { get; init; } + + /// + /// EN: Shop ID that owns this product. + /// VI: ID shop sở hữu sản phẩm. + /// + public Guid ShopId { get; init; } + + /// + /// EN: Product name. + /// VI: Tên sản phẩm. + /// + public string Name { get; init; } = string.Empty; + + /// + /// EN: Product description. + /// VI: Mô tả sản phẩm. + /// + public string? Description { get; init; } + + /// + /// EN: Product price. + /// VI: Giá sản phẩm. + /// + public decimal Price { get; init; } + + /// + /// EN: Product type (Physical, Service, PreparedFood). + /// VI: Loại sản phẩm (Physical, Service, PreparedFood). + /// + public string Type { get; init; } = string.Empty; + + /// + /// EN: Type-specific attributes as dictionary. + /// VI: Thuộc tính theo loại dưới dạng dictionary. + /// + public Dictionary? Attributes { get; init; } + + /// + /// EN: Product image URL. + /// VI: URL hình ảnh sản phẩm. + /// + public string? ImageUrl { get; init; } + + /// + /// EN: Stock Keeping Unit. + /// VI: Mã SKU. + /// + public string? Sku { get; init; } + + /// + /// EN: Is product active. + /// VI: Sản phẩm có đang hoạt động không. + /// + public bool IsActive { get; init; } + + /// + /// EN: Creation timestamp. + /// VI: Thời gian tạo. + /// + public DateTime CreatedAt { get; init; } + + /// + /// EN: Last update timestamp. + /// VI: Thời gian cập nhật cuối. + /// + public DateTime? UpdatedAt { get; init; } +} diff --git a/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetCategoriesQuery.cs b/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetCategoriesQuery.cs new file mode 100644 index 00000000..30676b42 --- /dev/null +++ b/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetCategoriesQuery.cs @@ -0,0 +1,26 @@ +// EN: Query to get a list of categories. +// VI: Query để lấy danh sách danh mục. + +using MediatR; +using CatalogService.API.Application.DTOs; + +namespace CatalogService.API.Application.Queries; + +/// +/// EN: Query to get categories by shop with hierarchical support. +/// VI: Query để lấy danh mục theo shop có hỗ trợ phân cấp. +/// +public record GetCategoriesQuery : IRequest> +{ + /// + /// EN: Shop ID to filter categories. + /// VI: ID shop để lọc danh mục. + /// + public Guid ShopId { get; init; } + + /// + /// EN: Filter by parent ID (null = root categories). + /// VI: Lọc theo ID cha (null = danh mục gốc). + /// + public Guid? ParentId { get; init; } +} diff --git a/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetCategoriesQueryHandler.cs b/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetCategoriesQueryHandler.cs new file mode 100644 index 00000000..ba956f34 --- /dev/null +++ b/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetCategoriesQueryHandler.cs @@ -0,0 +1,61 @@ +// EN: Handler for GetCategoriesQuery. +// VI: Handler cho GetCategoriesQuery. + +using MediatR; +using Microsoft.EntityFrameworkCore; +using CatalogService.API.Application.DTOs; +using CatalogService.Infrastructure; + +namespace CatalogService.API.Application.Queries; + +/// +/// EN: Handler for querying categories with hierarchical support. +/// VI: Handler query danh mục có hỗ trợ phân cấp. +/// +public class GetCategoriesQueryHandler : IRequestHandler> +{ + private readonly CatalogContext _context; + + public GetCategoriesQueryHandler(CatalogContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + public async Task> Handle(GetCategoriesQuery request, CancellationToken cancellationToken) + { + var query = _context.Categories + .Where(c => c.ShopId == request.ShopId); + + // EN: Filter by parent ID if specified + // VI: Lọc theo ID cha nếu được chỉ định + if (request.ParentId.HasValue) + { + query = query.Where(c => c.ParentId == request.ParentId.Value); + } + else + { + // EN: Return only root categories if no parent specified + // VI: Trả về chỉ danh mục gốc nếu không chỉ định cha + query = query.Where(c => c.ParentId == null); + } + + var categories = await query + .OrderBy(c => c.DisplayOrder) + .ThenBy(c => c.Name) + .Select(c => new CategoryDto + { + Id = c.Id, + ShopId = c.ShopId, + Name = c.Name, + Description = c.Description, + ParentId = c.ParentId, + DisplayOrder = c.DisplayOrder, + IsActive = c.IsActive, + CreatedAt = c.CreatedAt, + UpdatedAt = c.UpdatedAt + }) + .ToListAsync(cancellationToken); + + return categories; + } +} diff --git a/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetProductByIdQuery.cs b/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetProductByIdQuery.cs new file mode 100644 index 00000000..59fdd15e --- /dev/null +++ b/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetProductByIdQuery.cs @@ -0,0 +1,25 @@ +// EN: Query to get a single product by ID. +// VI: Query để lấy một sản phẩm theo ID. + +using MediatR; +using CatalogService.API.Application.DTOs; + +namespace CatalogService.API.Application.Queries; + +/// +/// EN: Query to get a product by ID. +/// VI: Query để lấy sản phẩm theo ID. +/// +public record GetProductByIdQuery : IRequest +{ + /// + /// EN: Product ID. + /// VI: ID sản phẩm. + /// + public Guid ProductId { get; init; } + + public GetProductByIdQuery(Guid productId) + { + ProductId = productId; + } +} diff --git a/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetProductByIdQueryHandler.cs b/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetProductByIdQueryHandler.cs new file mode 100644 index 00000000..770a2a88 --- /dev/null +++ b/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetProductByIdQueryHandler.cs @@ -0,0 +1,50 @@ +// EN: Handler for GetProductByIdQuery. +// VI: Handler cho GetProductByIdQuery. + +using System.Text.Json; +using MediatR; +using Microsoft.EntityFrameworkCore; +using CatalogService.API.Application.DTOs; +using CatalogService.Infrastructure; + +namespace CatalogService.API.Application.Queries; + +/// +/// EN: Handler for querying a single product by ID. +/// VI: Handler query một sản phẩm theo ID. +/// +public class GetProductByIdQueryHandler : IRequestHandler +{ + private readonly CatalogContext _context; + + public GetProductByIdQueryHandler(CatalogContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + public async Task Handle(GetProductByIdQuery request, CancellationToken cancellationToken) + { + var product = await _context.Products + .Where(p => p.Id == request.ProductId) + .Select(p => new ProductDto + { + Id = p.Id, + ShopId = p.ShopId, + Name = p.Name, + Description = p.Description, + Price = p.Price, + Type = p.Type.Name, + Attributes = p.Attributes != null + ? JsonSerializer.Deserialize>(p.Attributes.RootElement.GetRawText()) + : null, + ImageUrl = p.ImageUrl, + Sku = p.Sku, + IsActive = p.IsActive, + CreatedAt = p.CreatedAt, + UpdatedAt = p.UpdatedAt + }) + .FirstOrDefaultAsync(cancellationToken); + + return product; + } +} diff --git a/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetProductsQuery.cs b/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetProductsQuery.cs new file mode 100644 index 00000000..cf2c85ac --- /dev/null +++ b/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetProductsQuery.cs @@ -0,0 +1,44 @@ +// EN: Query to get a list of products. +// VI: Query để lấy danh sách sản phẩm. + +using MediatR; +using CatalogService.API.Application.DTOs; + +namespace CatalogService.API.Application.Queries; + +/// +/// EN: Query to get products by shop with filtering and pagination. +/// VI: Query để lấy sản phẩm theo shop có lọc và phân trang. +/// +public record GetProductsQuery : IRequest> +{ + /// + /// EN: Shop ID to filter products. + /// VI: ID shop để lọc sản phẩm. + /// + public Guid ShopId { get; init; } + + /// + /// EN: Filter by active status (null = all). + /// VI: Lọc theo trạng thái hoạt động (null = tất cả). + /// + public bool? IsActive { get; init; } + + /// + /// EN: Filter by product type (null = all). + /// VI: Lọc theo loại sản phẩm (null = tất cả). + /// + public string? Type { get; init; } + + /// + /// EN: Page number (1-indexed). + /// VI: Số trang (bắt đầu từ 1). + /// + public int Page { get; init; } = 1; + + /// + /// EN: Page size. + /// VI: Kích thước trang. + /// + public int PageSize { get; init; } = 20; +} diff --git a/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetProductsQueryHandler.cs b/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetProductsQueryHandler.cs new file mode 100644 index 00000000..e48229b5 --- /dev/null +++ b/services/catalog-service-net/src/CatalogService.API/Application/Queries/GetProductsQueryHandler.cs @@ -0,0 +1,79 @@ +// EN: Handler for GetProductsQuery. +// VI: Handler cho GetProductsQuery. + +using System.Text.Json; +using MediatR; +using Microsoft.EntityFrameworkCore; +using CatalogService.API.Application.DTOs; +using CatalogService.Infrastructure; + +namespace CatalogService.API.Application.Queries; + +/// +/// EN: Handler for querying products with filtering and pagination. +/// VI: Handler query sản phẩm có lọc và phân trang. +/// +public class GetProductsQueryHandler : IRequestHandler> +{ + private readonly CatalogContext _context; + + public GetProductsQueryHandler(CatalogContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + public async Task> Handle(GetProductsQuery request, CancellationToken cancellationToken) + { + var query = _context.Products + .Where(p => p.ShopId == request.ShopId); + + // EN: Apply filters + // VI: Áp dụng bộ lọc + if (request.IsActive.HasValue) + { + query = query.Where(p => p.IsActive == request.IsActive.Value); + } + + if (!string.IsNullOrWhiteSpace(request.Type)) + { + query = query.Where(p => p.Type.Name == request.Type); + } + + // EN: Get total count + // VI: Lấy tổng số + var totalCount = await query.CountAsync(cancellationToken); + + // EN: Apply pagination + // VI: Áp dụng phân trang + var products = await query + .OrderBy(p => p.Name) + .Skip((request.Page - 1) * request.PageSize) + .Take(request.PageSize) + .Select(p => new ProductDto + { + Id = p.Id, + ShopId = p.ShopId, + Name = p.Name, + Description = p.Description, + Price = p.Price, + Type = p.Type.Name, + Attributes = p.Attributes != null + ? JsonSerializer.Deserialize>(p.Attributes.RootElement.GetRawText()) + : null, + ImageUrl = p.ImageUrl, + Sku = p.Sku, + IsActive = p.IsActive, + CreatedAt = p.CreatedAt, + UpdatedAt = p.UpdatedAt + }) + .ToListAsync(cancellationToken); + + return new PagedResult + { + Items = products, + TotalCount = totalCount, + Page = request.Page, + PageSize = request.PageSize + }; + } +} diff --git a/services/inventory-service-net/tests/InventoryService.UnitTests/Domain/SampleAggregateTests.cs b/services/inventory-service-net/tests/InventoryService.UnitTests/Domain/SampleAggregateTests.cs deleted file mode 100644 index 52e1da5b..00000000 --- a/services/inventory-service-net/tests/InventoryService.UnitTests/Domain/SampleAggregateTests.cs +++ /dev/null @@ -1,151 +0,0 @@ -using FluentAssertions; -using InventoryService.Domain.AggregatesModel.SampleAggregate; -using InventoryService.Domain.Exceptions; -using Xunit; - -namespace InventoryService.UnitTests.Domain; - -/// -/// EN: Unit tests for Sample aggregate. -/// VI: Unit tests cho Sample aggregate. -/// -public class SampleAggregateTests -{ - [Fact] - public void CreateSample_WithValidName_ShouldCreateWithDraftStatus() - { - // Arrange - var name = "Test Sample"; - var description = "Test Description"; - - // Act - var sample = new Sample(name, description); - - // Assert - sample.Name.Should().Be(name); - sample.Description.Should().Be(description); - sample.Status.Should().Be(SampleStatus.Draft); - sample.Id.Should().NotBeEmpty(); - sample.DomainEvents.Should().ContainSingle(); // SampleCreatedDomainEvent - } - - [Fact] - public void CreateSample_WithEmptyName_ShouldThrowException() - { - // Arrange - var name = ""; - - // Act - var act = () => new Sample(name); - - // Assert - act.Should().Throw() - .WithMessage("Sample name cannot be empty"); - } - - [Fact] - public void Activate_WhenDraft_ShouldChangeToActive() - { - // Arrange - var sample = new Sample("Test Sample"); - sample.ClearDomainEvents(); - - // Act - sample.Activate(); - - // Assert - sample.Status.Should().Be(SampleStatus.Active); - sample.DomainEvents.Should().ContainSingle(); // SampleStatusChangedDomainEvent - } - - [Fact] - public void Activate_WhenNotDraft_ShouldThrowException() - { - // Arrange - var sample = new Sample("Test Sample"); - sample.Activate(); - - // Act - var act = () => sample.Activate(); - - // Assert - act.Should().Throw() - .WithMessage("Only draft samples can be activated"); - } - - [Fact] - public void Complete_WhenActive_ShouldChangeToCompleted() - { - // Arrange - var sample = new Sample("Test Sample"); - sample.Activate(); - sample.ClearDomainEvents(); - - // Act - sample.Complete(); - - // Assert - sample.Status.Should().Be(SampleStatus.Completed); - } - - [Fact] - public void Cancel_WhenDraftOrActive_ShouldChangeToCancelled() - { - // Arrange - var sample = new Sample("Test Sample"); - - // Act - sample.Cancel(); - - // Assert - sample.Status.Should().Be(SampleStatus.Cancelled); - } - - [Fact] - public void Cancel_WhenCompleted_ShouldThrowException() - { - // Arrange - var sample = new Sample("Test Sample"); - sample.Activate(); - sample.Complete(); - - // Act - var act = () => sample.Cancel(); - - // Assert - act.Should().Throw() - .WithMessage("Cannot cancel a completed sample"); - } - - [Fact] - public void Update_WhenNotCancelled_ShouldUpdateNameAndDescription() - { - // Arrange - var sample = new Sample("Original Name", "Original Description"); - var newName = "Updated Name"; - var newDescription = "Updated Description"; - - // Act - sample.Update(newName, newDescription); - - // Assert - sample.Name.Should().Be(newName); - sample.Description.Should().Be(newDescription); - sample.UpdatedAt.Should().NotBeNull(); - } - - [Fact] - public void Update_WhenCancelled_ShouldThrowException() - { - // Arrange - var sample = new Sample("Test Sample"); - sample.Cancel(); - - // Act - var act = () => sample.Update("New Name", null); - - // Assert - act.Should().Throw() - .WithMessage("Cannot update a cancelled sample"); - } -}