diff --git a/.agent/skills/development-lifecycle/SKILL.md b/.agent/skills/development-lifecycle/SKILL.md
new file mode 100644
index 00000000..c804456f
--- /dev/null
+++ b/.agent/skills/development-lifecycle/SKILL.md
@@ -0,0 +1,528 @@
+---
+name: development-lifecycle
+description: Development lifecycle workflow - Code → Build → Test → Fix → Deploy. Use for understanding complete development process, debugging build errors, fixing test failures, and continuous improvement cycles in GoodGo microservices.
+compatibility: ".NET 10+, Docker 24+, docker-compose 2.x"
+metadata:
+ author: Velik Ho
+ version: "1.0"
+---
+
+# Development Lifecycle / Quy Trình Phát Triển
+
+Complete iterative development workflow from code to deployment with focus on troubleshooting and debugging.
+
+## When to Use This Skill / Khi Nào Sử Dụng
+
+Use this skill when:
+- Starting development on a new feature / Bắt đầu phát triển feature mới
+- Debugging build or test failures / Debug lỗi build hoặc test
+- Understanding the complete dev cycle / Hiểu quy trình phát triển hoàn chỉnh
+- Setting up local development environment / Setup môi trường local
+- Troubleshooting deployment issues / Khắc phục sự cố deployment
+
+## Core Concepts / Khái Niệm Cốt Lõi
+
+### Development Cycle / Chu Kỳ Phát Triển
+
+```mermaid
+graph LR
+ A[① CODE
Write Features] --> B[② BUILD
Compile & Package]
+ B --> C[③ TEST
Run Tests]
+ C --> D{Pass?}
+ D -->|No| E[④ FIX
Debug & Resolve]
+ E --> B
+ D -->|Yes| F[⑤ DEPLOY
Local/Staging/Prod]
+ F --> G{Issues?}
+ G -->|Yes| E
+ G -->|No| H[✓ Done]
+```
+
+### Philosophy / Triết Lý
+
+1. **Fail Fast** - Phát hiện lỗi sớm nhất có thể
+2. **Iterative** - Cải tiến liên tục qua từng cycle
+3. **Automated** - Tự động hóa các bước lặp lại
+4. **Observable** - Luôn có visibility vào system state
+
+## Phase 1: CODE ✍️
+
+**Goal**: Viết code sạch, maintainable theo architecture chuẩn
+
+### Workflow
+
+**Use existing skills** cho coding phase:
+- [dotnet-microservice-workflow](../dotnet-microservice-workflow/SKILL.md) - 4-layer architecture
+- [api-design](../api-design/SKILL.md) - RESTful endpoints
+- [cqrs-mediatr](../cqrs-mediatr/SKILL.md) - Commands/Queries
+- [domain-driven-design](../domain-driven-design/SKILL.md) - Domain modeling
+
+### Best Practices
+
+```csharp
+// ✅ GOOD: Clear intention, follows patterns
+public class CreateOrderCommandHandler : IRequestHandler
+{
+ private readonly IOrderRepository _repository;
+
+ public async Task Handle(CreateOrderCommand cmd, CancellationToken ct)
+ {
+ var order = new Order(cmd.UserId, cmd.ShippingAddress);
+ foreach (var item in cmd.Items)
+ order.AddItem(item.ProductId, item.Quantity, item.Price);
+
+ await _repository.AddAsync(order, ct);
+ await _repository.UnitOfWork.SaveChangesAsync(ct);
+
+ return new OrderResult(order.Id);
+ }
+}
+
+// ❌ BAD: Mixed concerns, no separation
+[HttpPost]
+public async Task CreateOrder(CreateOrderRequest request)
+{
+ var order = new Order { UserId = request.UserId };
+ _context.Orders.Add(order);
+ await _context.SaveChangesAsync();
+ return Ok(order.Id);
+}
+```
+
+---
+
+## Phase 2: BUILD 🔨
+
+**Goal**: Compile code và tạo artifacts (DLLs, Docker images)
+
+### Local .NET Build
+
+```bash
+# Restore NuGet packages
+dotnet restore
+
+# Build solution
+dotnet build
+
+# Build specific project
+dotnet build src/MyService.API/
+
+# Release build
+dotnet build -c Release
+
+# Verbose output (for debugging)
+dotnet build -v detailed
+```
+
+### Docker Build
+
+```bash
+# Build service image
+docker build -t my-service:latest -f services/my-service-net/Dockerfile .
+
+# Build via docker-compose
+docker-compose -f deployments/local/docker-compose.yml build my-service-net
+
+# No cache (clean build)
+docker-compose -f deployments/local/docker-compose.yml build --no-cache my-service-net
+
+# Parallel build multiple services
+docker-compose -f deployments/local/docker-compose.yml build --parallel
+```
+
+### Common Build Errors
+
+| Error Code | Cause | Solution |
+|------------|-------|----------|
+| **CS0246** | Missing type/namespace | Run `dotnet restore`, add package reference |
+| **CS0103** | Name does not exist | Add `using` statement |
+| **CS1061** | Missing member | Check property/method name, NuGet version |
+| **NU1101** | Unable to find package | Check package name, NuGet source |
+| **Docker context** | Wrong build context | Verify `-f` and context path |
+
+**See detailed solutions**: [references/build-errors.md](references/build-errors.md)
+
+---
+
+## Phase 3: TEST 🧪
+
+**Goal**: Validate correctness through automated tests
+
+### Test Pyramid
+
+```
+ ┌──────────────┐
+ │ E2E Tests │ ← ~5% - Slow, brittle
+ │ (UI/API) │
+ ├──────────────┤
+ │ Integration │ ← ~15% - Medium speed
+ │ Tests │ Database, HTTP
+ ├──────────────┤
+ │ Unit Tests │ ← ~80% - Fast, isolated
+ │ (Handlers, │ Pure logic
+ │ Domain) │
+ └──────────────┘
+```
+
+### Test Commands
+
+```bash
+# Run all tests
+dotnet test
+
+# Run specific test project
+dotnet test tests/MyService.UnitTests/
+
+# Run with filter
+dotnet test --filter Category=Unit
+dotnet test --filter FullyQualifiedName~CreateOrderHandler
+
+# Watch mode (TDD)
+dotnet watch test --project tests/MyService.UnitTests/
+
+# Coverage report
+dotnet test /p:CollectCoverage=true /p:CoverageReportFormat=opencover
+
+# Detailed output
+dotnet test --logger "console;verbosity=detailed"
+```
+
+### Test Structure
+
+**Use testing skill**: [dotnet-senior-tester](../dotnet-senior-tester/SKILL.md)
+
+```csharp
+// ✅ GOOD: Arrange-Act-Assert, clear mocking
+public class CreateOrderCommandHandlerTests
+{
+ [Fact]
+ public async Task Handle_ValidCommand_CreatesOrder()
+ {
+ // Arrange
+ var repository = Substitute.For();
+ var unitOfWork = Substitute.For();
+ repository.UnitOfWork.Returns(unitOfWork);
+
+ var handler = new CreateOrderCommandHandler(repository);
+ var command = new CreateOrderCommand(UserId: Guid.NewGuid(), /* ... */);
+
+ // Act
+ var result = await handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ await repository.Received(1).AddAsync(Arg.Any(), Arg.Any());
+ await unitOfWork.Received(1).SaveChangesAsync(Arg.Any());
+ Assert.NotEqual(Guid.Empty, result.OrderId);
+ }
+}
+```
+
+**See test failure patterns**: [references/test-failures.md](references/test-failures.md)
+
+---
+
+## Phase 4: FIX 🔧
+
+**Goal**: Debug và resolve issues nhanh chóng
+
+### Debugging Workflow
+
+```mermaid
+graph TD
+ A[Error Detected] --> B{Error Type?}
+ B -->|Build| C[Check Build Logs]
+ B -->|Test| D[Check Test Output]
+ B -->|Runtime| E[Check App Logs]
+
+ C --> F[Identify Root Cause]
+ D --> F
+ E --> F
+
+ F --> G[Apply Fix]
+ G --> H[Verify Fix]
+ H --> I{Fixed?}
+ I -->|No| F
+ I -->|Yes| J[Document Solution]
+```
+
+### Debugging Tools
+
+**Build Errors:**
+```bash
+# Verbose build output
+dotnet build -v detailed
+
+# Clean and rebuild
+dotnet clean && dotnet build
+
+# Check references
+dotnet list package
+```
+
+**Test Failures:**
+```bash
+# Run single test with details
+dotnet test --filter TestMethodName --logger "console;verbosity=detailed"
+
+# Debug test in VS Code
+# Set breakpoint, F5 to debug
+```
+
+**Runtime Issues:**
+```bash
+# Container logs
+docker logs -f my-service-net
+docker logs --tail 100 my-service-net
+
+# Follow logs
+docker-compose -f deployments/local/docker-compose.yml logs -f my-service-net
+
+# Exec into container
+docker exec -it my-service-net sh
+
+# Check health endpoint
+curl http://localhost/api/v1/my-service/health
+```
+
+### Common Fix Patterns
+
+| Issue | Pattern | Example |
+|-------|---------|---------|
+| **NullReferenceException** | Check mock setup | `repository.Setup(x => x.Get()).Returns(entity)` |
+| **Async deadlock** | Use `ConfigureAwait(false)` | `await task.ConfigureAwait(false);` |
+| **Database timeout** | Check connection string, indexes | Add index on frequently queried columns |
+| **Container won't start** | Check environment vars, ports | Verify `docker-compose.yml` config |
+
+**See detailed guide**: [references/debugging-guide.md](references/debugging-guide.md)
+
+---
+
+## Phase 5: DEPLOY 🚀
+
+**Goal**: Deploy và verify application in target environment
+
+### Local Deployment
+
+```bash
+# Start service with docker-compose
+docker-compose -f deployments/local/docker-compose.yml up -d my-service-net
+
+# Check status
+docker-compose -f deployments/local/docker-compose.yml ps
+
+# Health check
+curl http://localhost/api/v1/my-service/health
+
+# Swagger UI
+open http://localhost/api/v1/my-service/swagger
+```
+
+### Health Checks
+
+Every service MUST have `/health` endpoint:
+
+```csharp
+// Program.cs
+builder.Services.AddHealthChecks()
+ .AddNpgSql(connectionString, name: "database")
+ .AddRedis(redisConnection, name: "redis");
+
+app.MapHealthChecks("/health");
+```
+
+### Verification Checklist
+
+- [ ] Service starts without errors
+- [ ] `/health` returns 200 OK
+- [ ] Database migrations applied
+- [ ] Swagger UI accessible
+- [ ] Sample API call works
+- [ ] Logs show no errors
+
+### Staging/Production
+
+**Use deployment skill**: [deployment-kubernetes](../deployment-kubernetes/SKILL.md)
+
+```bash
+# kubectl commands for K8s
+kubectl apply -f deployments/kubernetes/my-service.yaml
+kubectl rollout status deployment/my-service
+kubectl get pods -l app=my-service
+```
+
+---
+
+## Development Patterns / Các Mẫu Phát Triển
+
+### Pattern 1: Test-Driven Development (TDD)
+
+```
+1. ❌ RED - Write failing test
+2. ✅ GREEN - Write minimal code to pass
+3. 🔧 REFACTOR - Improve code quality
+4. Repeat
+```
+
+**Benefits:**
+- Better design (testable code)
+- High confidence in changes
+- Living documentation
+
+### Pattern 2: Incremental Development
+
+```bash
+# Small commits with working state
+git commit -m "feat: add CreateOrder command"
+git commit -m "test: add CreateOrderHandler tests"
+git commit -m "fix: validate order items quantity"
+```
+
+### Pattern 3: Hot Reload Development
+
+```bash
+# .NET hot reload (faster iteration)
+dotnet watch run --project src/MyService.API
+
+# Docker with volume mount (for config changes)
+docker-compose up --build
+```
+
+---
+
+## Helper Scripts / Scripts Hỗ Trợ
+
+### Check Environment
+
+```bash
+# Verify development environment
+./scripts/check-env.sh
+```
+
+### Quick Build
+
+```bash
+# Build single service quickly
+./scripts/quick-build.sh my-service-net
+```
+
+### Debug Build
+
+```bash
+# Debug build errors with verbose output
+./scripts/debug-build.sh my-service-net
+```
+
+### Health Check
+
+```bash
+# Check all services health
+./scripts/health-check.sh
+```
+
+---
+
+## Common Mistakes / Lỗi Thường Gặp
+
+### 1. Skip Tests
+
+**Problem**: Bugs go to production
+**Solution**: Write tests first (TDD) or immediately after feature
+
+```bash
+# ❌ BAD: Push without testing
+git push origin main
+
+# ✅ GOOD: Always test first
+dotnet test && git push origin main
+```
+
+### 2. Ignore Build Warnings
+
+**Problem**: Tech debt accumulates, future breaks
+**Solution**: Treat warnings as errors
+
+```xml
+
+
+ true
+
+```
+
+### 3. No Logging
+
+**Problem**: Hard to debug production issues
+**Solution**: Add structured logging
+
+```csharp
+// ✅ GOOD: Structured logging
+_logger.LogInformation("Creating order for user {UserId} with {ItemCount} items",
+ command.UserId, command.Items.Count);
+```
+
+### 4. Skip Health Checks
+
+**Problem**: Can't verify deployment success
+**Solution**: Always add `/health` endpoint
+
+```csharp
+app.MapHealthChecks("/health", new HealthCheckOptions
+{
+ ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
+});
+```
+
+---
+
+## Quick Reference / Tham Chiếu Nhanh
+
+### Development Commands
+
+| Task | Command |
+|------|---------|
+| **Restore packages** | `dotnet restore` |
+| **Build** | `dotnet build` |
+| **Run tests** | `dotnet test` |
+| **Run locally** | `dotnet run --project src/MyService.API` |
+| **Watch mode** | `dotnet watch run` |
+| **Hot reload tests** | `dotnet watch test` |
+
+### Docker Commands
+
+| Task | Command |
+|------|---------|
+| **Build image** | `docker-compose build my-service-net` |
+| **Start service** | `docker-compose up -d my-service-net` |
+| **View logs** | `docker-compose logs -f my-service-net` |
+| **Restart** | `docker-compose restart my-service-net` |
+| **Stop** | `docker-compose down` |
+| **Rebuild & start** | `docker-compose up -d --build my-service-net` |
+
+### Debugging Commands
+
+| Task | Command |
+|------|---------|
+| **Verbose build** | `dotnet build -v detailed` |
+| **Clean build** | `dotnet clean && dotnet build` |
+| **Test with logs** | `dotnet test --logger console` |
+| **Container logs** | `docker logs -f my-service-net` |
+| **Exec into container** | `docker exec -it my-service-net sh` |
+
+---
+
+## Resources / Tài Nguyên
+
+### Related Skills
+- [dotnet-microservice-workflow](../dotnet-microservice-workflow/SKILL.md) - 4-phase architecture workflow
+- [dotnet-senior-tester](../dotnet-senior-tester/SKILL.md) - Comprehensive testing
+- [docker-traefik](../docker-traefik/SKILL.md) - Docker patterns
+- [error-handling-patterns](../error-handling-patterns/SKILL.md) - Exception handling
+- [project-rules](../project-rules/SKILL.md) - Coding standards
+
+### Helper Resources
+- [Build Errors Catalog](references/build-errors.md) - Common build errors and solutions
+- [Test Failures Guide](references/test-failures.md) - Test debugging patterns
+- [Debugging Guide](references/debugging-guide.md) - Advanced debugging techniques
+
+### External Resources
+- [.NET CLI Reference](https://learn.microsoft.com/en-us/dotnet/core/tools/)
+- [Docker Compose Reference](https://docs.docker.com/compose/compose-file/)
+- [xUnit Documentation](https://xunit.net/)
diff --git a/.agent/skills/development-lifecycle/references/build-errors.md b/.agent/skills/development-lifecycle/references/build-errors.md
new file mode 100644
index 00000000..09427416
--- /dev/null
+++ b/.agent/skills/development-lifecycle/references/build-errors.md
@@ -0,0 +1,298 @@
+# Common Build Errors Catalog
+
+Quick reference for troubleshooting .NET and Docker build errors in GoodGo microservices.
+
+## .NET Build Errors
+
+### CS0246: Type or namespace not found
+
+**Error:**
+```
+error CS0246: The type or namespace name 'X' could not be found
+```
+
+**Common Causes:**
+1. Missing package reference
+2. Incorrect namespace
+3. Package not restored
+
+**Solutions:**
+```bash
+# 1. Restore packages
+dotnet restore
+
+# 2. Add missing package
+dotnet add package
+
+# 3. Check using statements
+# Add: using YourNamespace;
+
+# 4. Clean and rebuild
+dotnet clean && dotnet build
+```
+
+---
+
+### CS0103: Name does not exist in current context
+
+**Error:**
+```
+error CS0103: The name 'X' does not exist in the current context
+```
+
+**Common Causes:**
+1. Missing `using` directive
+2. Typo in variable/method name
+3. Wrong scope
+
+**Solutions:**
+```csharp
+// Add using directive
+using System.Linq;
+using Microsoft.EntityFrameworkCore;
+
+// Check variable is in scope
+public void Method()
+{
+ var item = new Item();
+ // item is only available within Method()
+}
+```
+
+---
+
+### CS1061: Does not contain a definition
+
+**Error:**
+```
+error CS1061: 'Type' does not contain a definition for 'Member'
+```
+
+**Common Causes:**
+1. Typo in property/method name
+2. Wrong NuGet package version
+3. Extension method not imported
+
+**Solutions:**
+```csharp
+// 1. Check spelling
+order.Items // not order.Item
+
+// 2. Import extension methods
+using System.Linq; // for .Where(), .Select(), etc.
+
+// 3. Update package version
+dotnet list package --outdated
+dotnet add package Microsoft.EntityFrameworkCore --version 10.0.0
+```
+
+---
+
+### NU1101: Unable to find package
+
+**Error:**
+```
+error NU1101: Unable to find package 'X'. No packages exist with this id
+```
+
+**Solutions:**
+```bash
+# 1. Check package name (spelling)
+dotnet add package Swashbuckle.AspNetCore # not Swashbuckle.AspNet
+
+# 2. Check NuGet sources
+dotnet nuget list source
+
+# 3. Clear NuGet cache
+dotnet nuget locals all --clear
+
+# 4. Restore with verbose
+dotnet restore -v detailed
+```
+
+---
+
+### MSB3277: Version conflicts
+
+**Error:**
+```
+warning MSB3277: Found conflicts between different versions of "X"
+```
+
+**Solutions:**
+```xml
+
+
+
+
+
+```
+
+```bash
+# Or update all references
+dotnet add package Microsoft.EntityFrameworkCore --version 10.0.2
+```
+
+---
+
+## Docker Build Errors
+
+### Cannot connect to Docker daemon
+
+**Error:**
+```
+Cannot connect to the Docker daemon at unix:///var/run/docker.sock
+```
+
+**Solutions:**
+```bash
+# 1. Start Docker Desktop (macOS/Windows)
+open -a Docker
+
+# 2. Check Docker status
+docker info
+
+# 3. Restart Docker daemon (Linux)
+sudo systemctl restart docker
+```
+
+---
+
+### Context error: Dockerfile not found
+
+**Error:**
+```
+ERROR: failed to solve: failed to read dockerfile: failed to resolve path
+```
+
+**Common Causes:**
+- Wrong build context
+- Dockerfile in wrong location
+
+**Solutions:**
+```bash
+# Correct: Build from repo root with context
+docker build -t my-service -f services/my-service-net/Dockerfile .
+
+# ❌ Wrong: Building from service directory
+cd services/my-service-net
+docker build -t my-service .
+
+# ✅ Correct: Specify context
+docker build -t my-service -f Dockerfile ../..
+```
+
+---
+
+### COPY failed: file not found
+
+**Error:**
+```
+ERROR: failed to copy files: file not found
+```
+
+**Common Cause:** Wrong COPY path in Dockerfile
+
+**Solutions:**
+```dockerfile
+# ❌ Wrong: Path relative to Dockerfile location
+COPY src/ /app/
+
+# ✅ Correct: Path relative to build context (repo root)
+COPY services/my-service-net/src/ /app/
+```
+
+---
+
+### Docker build cache issues
+
+**Problem:** Old files being used despite changes
+
+**Solutions:**
+```bash
+# Build without cache
+docker build --no-cache -t my-service .
+
+# Or via docker-compose
+docker-compose build --no-cache my-service-net
+
+# Clear all build cache
+docker builder prune -a
+```
+
+---
+
+## Entity Framework Errors
+
+### No migrations found
+
+**Error:**
+```
+No migrations were found
+```
+
+**Solutions:**
+```bash
+# Create initial migration
+dotnet ef migrations add InitialCreate --project src/MyService.Infrastructure
+
+# Verify migrations folder exists
+ls src/MyService.Infrastructure/Migrations/
+```
+
+---
+
+### Connection string error
+
+**Error:**
+```
+A connection string could not be constructed
+```
+
+**Solutions:**
+```bash
+# 1. Add connection string to appsettings.json
+{
+ "ConnectionStrings": {
+ "DefaultConnection": "Host=localhost;Database=mydb;..."
+ }
+}
+
+# 2. Set environment variable
+export ConnectionStrings__DefaultConnection="Host=..."
+
+# 3. Verify in Program.cs
+builder.Services.AddDbContext(options =>
+ options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
+```
+
+---
+
+## Quick Troubleshooting Checklist
+
+When build fails:
+
+- [ ] Run `dotnet restore`
+- [ ] Check for typos
+- [ ] Verify `using` statements
+- [ ] Check package versions (`dotnet list package`)
+- [ ] Clean build: `dotnet clean && dotnet build`
+- [ ] Check build logs: `dotnet build -v detailed`
+- [ ] Clear NuGet cache: `dotnet nuget locals all --clear`
+
+When Docker build fails:
+
+- [ ] Check Docker daemon is running
+- [ ] Verify build context path
+- [ ] Check Dockerfile COPY paths
+- [ ] Try `--no-cache` build
+- [ ] Check `.dockerignore` file
+- [ ] Verify base image exists
+
+---
+
+## Related Resources
+
+- [Test Failures Guide](test-failures.md)
+- [Debugging Guide](debugging-guide.md)
+- Main Skill: [development-lifecycle](../SKILL.md)
diff --git a/.agent/skills/development-lifecycle/references/debugging-guide.md b/.agent/skills/development-lifecycle/references/debugging-guide.md
new file mode 100644
index 00000000..b9dc26b2
--- /dev/null
+++ b/.agent/skills/development-lifecycle/references/debugging-guide.md
@@ -0,0 +1,462 @@
+# Debugging Guide
+
+Advanced debugging techniques for .NET microservices development.
+
+## Visual Studio Code Debugging
+
+### Setup launch.json
+
+```json
+{
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "name": ".NET Debug",
+ "type": "coreclr",
+ "request": "launch",
+ "preLaunchTask": "build",
+ "program": "${workspaceFolder}/src/MyService.API/bin/Debug/net10.0/MyService.API.dll",
+ "args": [],
+ "cwd": "${workspaceFolder}/src/MyService.API",
+ "console": "internalConsole",
+ "stopAtEntry": false,
+ "env": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ {
+ "name": ".NET Attach",
+ "type": "coreclr",
+ "request": "attach"
+ }
+ ]
+}
+```
+
+### Debug Tests
+
+```json
+{
+ "name": ".NET Test Debug",
+ "type": "coreclr",
+ "request": "launch",
+ "preLaunchTask": "build",
+ "program": "dotnet",
+ "args": [
+ "test",
+ "--filter",
+ "FullyQualifiedName~MyTestMethod"
+ ],
+ "cwd": "${workspaceFolder}",
+ "console": "internalConsole"
+}
+```
+
+**Usage:**
+1. Set breakpoint in test or code
+2. F5 to start debugging
+3. Step through with F10 (over) / F11 (into)
+
+---
+
+## Remote Debugging Docker Containers
+
+### Enable remote debugging in Dockerfile
+
+```dockerfile
+FROM mcr.microsoft.com/dotnet/sdk:8.0 AS debug
+WORKDIR /src
+COPY . .
+RUN dotnet restore
+RUN dotnet build --no-restore
+
+# Install debugger
+RUN apt-get update && apt-get install -y procps
+
+EXPOSE 8080
+EXPOSE 4024
+
+ENTRYPOINT ["dotnet", "run", "--no-build", "--project", "src/MyService.API"]
+```
+
+### Docker Compose debug config
+
+```yaml
+services:
+ my-service-debug:
+ build:
+ context: ../..
+ dockerfile: services/my-service-net/Dockerfile
+ target: debug
+ environment:
+ - ASPNETCORE_ENVIRONMENT=Development
+ - DOTNET_USE_POLLING_FILE_WATCHER=true
+ ports:
+ - "8080:8080"
+ - "4024:4024" # Debugger port
+ volumes:
+ - ./src:/src:ro
+```
+
+### Attach to running container
+
+```bash
+# 1. Start container with debug enabled
+docker-compose up my-service-debug
+
+# 2. Find process ID
+docker exec my-service-debug ps aux | grep dotnet
+
+# 3. In VS Code, use "Attach to Process" configuration
+# Select the dotnet process from Docker container
+```
+
+---
+
+## Logging Strategies
+
+### Structured Logging with Serilog
+
+```csharp
+// Program.cs
+builder.Host.UseSerilog((context, config) =>
+{
+ config
+ .ReadFrom.Configuration(context.Configuration)
+ .Enrich.FromLogContext()
+ .Enrich.WithProperty("Service", "MyService")
+ .WriteTo.Console(new JsonFormatter())
+ .WriteTo.File("logs/log-.txt", rollingInterval: RollingInterval.Day);
+});
+
+// Usage in code
+_logger.LogInformation(
+ "Creating order for {UserId} with {ItemCount} items and total {Total:C}",
+ command.UserId,
+ command.Items.Count,
+ order.Total
+);
+```
+
+**Benefits:**
+- Searchable structured data
+- Context preservation
+- Easy filtering
+
+---
+
+### Log Scopes
+
+```csharp
+using (_logger.BeginScope("OrderId: {OrderId}", orderId))
+{
+ _logger.LogInformation("Processing order");
+ _logger.LogInformation("Validating items");
+ _logger.LogInformation("Calculating total");
+}
+
+// All logs include OrderId automatically
+```
+
+---
+
+## Debugging Tools
+
+### dotnet-dump (Memory dumps)
+
+```bash
+# Install tool
+dotnet tool install -g dotnet-dump
+
+# Create dump of running process
+dotnet-dump collect -p
+
+# Analyze dump
+dotnet-dump analyze
+
+# Inside analyzer
+> clrstack # View call stack
+> dumpheap -stat # Heap statistics
+> eeheap -gc # GC heap info
+```
+
+---
+
+### dotnet-trace (Performance tracing)
+
+```bash
+# Install tool
+dotnet tool install -g dotnet-trace
+
+# Collect trace
+dotnet-trace collect -p --duration 00:00:30
+
+# Convert to speedscope format
+dotnet-trace convert trace.nettrace --format speedscope
+
+# View in browser
+open https://www.speedscope.app/
+```
+
+---
+
+### dotnet-counters (Live metrics)
+
+```bash
+# Install tool
+dotnet tool install -g dotnet-counters
+
+# Monitor live
+dotnet-counters monitor -p
+
+# Custom metrics
+dotnet-counters monitor \
+ -p \
+ --counters System.Runtime,Microsoft.AspNetCore.Hosting
+```
+
+---
+
+## Database Debugging
+
+### EF Core Query Logging
+
+```csharp
+// Enable sensitive data logging (Development only!)
+builder.Services.AddDbContext(options =>
+{
+ options.UseNpgsql(connectionString);
+
+ if (builder.Environment.IsDevelopment())
+ {
+ options
+ .EnableSensitiveDataLogging()
+ .EnableDetailedErrors();
+ }
+});
+```
+
+**Output:**
+```
+Executed DbCommand (23ms) [Parameters=[@p0='123'], CommandType='Text']
+SELECT * FROM orders WHERE id = @p0
+```
+
+---
+
+### SQL Profiling
+
+```bash
+# PostgreSQL: Enable query logging
+# In postgresql.conf or docker-compose
+POSTGRES_EXTRA_FLAGS="-c log_statement=all"
+
+# View logs
+docker-compose logs -f postgres | grep "statement:"
+```
+
+---
+
+## HTTP Debugging
+
+### Fiddler / Postman for API testing
+
+**Setup authentication:**
+```bash
+# Get JWT token
+curl -X POST http://localhost/api/v1/auth/login \
+ -H "Content-Type: application/json" \
+ -d '{"username":"test","password":"test123"}'
+
+# Use token in requests
+curl http://localhost/api/v1/orders \
+ -H "Authorization: Bearer "
+```
+
+---
+
+### Request/Response logging middleware
+
+```csharp
+// Custom middleware
+public class RequestResponseLoggingMiddleware
+{
+ private readonly RequestDelegate _next;
+ private readonly ILogger _logger;
+
+ public async Task InvokeAsync(HttpContext context)
+ {
+ // Log request
+ context.Request.EnableBuffering();
+ var requestBody = await ReadBodyAsync(context.Request.Body);
+
+ _logger.LogInformation(
+ "HTTP {Method} {Path} Body: {Body}",
+ context.Request.Method,
+ context.Request.Path,
+ requestBody
+ );
+
+ // Capture response
+ var originalBody = context.Response.Body;
+ using var responseBody = new MemoryStream();
+ context.Response.Body = responseBody;
+
+ await _next(context);
+
+ // Log response
+ responseBody.Seek(0, SeekOrigin.Begin);
+ var response = await new StreamReader(responseBody).ReadToEndAsync();
+
+ _logger.LogInformation(
+ "HTTP {StatusCode} Response: {Response}",
+ context.Response.StatusCode,
+ response
+ );
+
+ // Copy to original stream
+ responseBody.Seek(0, SeekOrigin.Begin);
+ await responseBody.CopyToAsync(originalBody);
+ }
+}
+```
+
+---
+
+## Common Debugging Scenarios
+
+### Find source of exception
+
+```bash
+# Enable first-chance exceptions in VS Code
+# .vscode/launch.json
+"exceptionOptions": {
+ "breakpoints": {
+ "raised": true,
+ "userUnhandled": false
+ }
+}
+```
+
+---
+
+### Memory leak investigation
+
+```bash
+# 1. Take baseline dump
+dotnet-dump collect -p -o baseline.dump
+
+# 2. Exercise application
+# ... use the app normally ...
+
+# 3. Take second dump
+dotnet-dump collect -p -o leaked.dump
+
+# 4. Compare
+dotnet-dump analyze leaked.dump
+> dumpheap -stat
+# Look for growing object counts
+```
+
+---
+
+### Slow request diagnosis
+
+**Add request timing:**
+```csharp
+app.Use(async (context, next) =>
+{
+ var sw = Stopwatch.StartNew();
+ await next();
+ sw.Stop();
+
+ if (sw.ElapsedMilliseconds > 1000)
+ {
+ _logger.LogWarning(
+ "Slow request: {Method} {Path} took {Elapsed}ms",
+ context.Request.Method,
+ context.Request.Path,
+ sw.ElapsedMilliseconds
+ );
+ }
+});
+```
+
+---
+
+## Performance Profiling
+
+### BenchmarkDotNet
+
+```csharp
+using BenchmarkDotNet.Attributes;
+using BenchmarkDotNet.Running;
+
+[MemoryDiagnoser]
+public class OrderBenchmarks
+{
+ private Order _order;
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ _order = new Order(/* ... */);
+ }
+
+ [Benchmark]
+ public void AddItem()
+ {
+ _order.AddItem(Guid.NewGuid(), 1, 10.0m);
+ }
+}
+
+// Run benchmarks
+BenchmarkRunner.Run();
+```
+
+---
+
+## Quick Reference
+
+### VS Code Shortcuts
+
+| Action | Shortcut |
+|--------|----------|
+| Start debugging | F5 |
+| Step over | F10 |
+| Step into | F11 |
+| Step out | Shift+F11 |
+| Continue | F5 |
+| Toggle breakpoint | F9 |
+| Add watch | Right-click → Add to Watch |
+
+### Useful Commands
+
+```bash
+# View running processes
+ps aux | grep dotnet
+
+# Find process by port
+lsof -i :8080
+
+# Kill process
+kill -9
+
+# View environment variables
+docker exec my-service env
+
+# Execute command in container
+docker exec -it my-service sh
+
+# Follow logs with filter
+docker logs -f my-service | grep ERROR
+```
+
+---
+
+## Related Resources
+
+- [Build Errors Catalog](build-errors.md)
+- [Test Failures Guide](test-failures.md)
+- Main Skill: [development-lifecycle](../SKILL.md)
+- [.NET Debugging Documentation](https://learn.microsoft.com/en-us/dotnet/core/diagnostics/)
diff --git a/.agent/skills/development-lifecycle/references/test-failures.md b/.agent/skills/development-lifecycle/references/test-failures.md
new file mode 100644
index 00000000..f0b04ecf
--- /dev/null
+++ b/.agent/skills/development-lifecycle/references/test-failures.md
@@ -0,0 +1,354 @@
+# Test Failures Guide
+
+Common test failure patterns and how to fix them in .NET microservices.
+
+## Mock Setup Issues
+
+### NullReferenceException in tests
+
+**Symptom:**
+```
+System.NullReferenceException: Object reference not set to an instance of an object
+```
+
+**Common Cause:** Mock not properly configured
+
+**Solution:**
+```csharp
+// ❌ BAD: Mock returns null by default
+var repository = Substitute.For();
+var order = await repository.GetByIdAsync(orderId); // returns null!
+order.Status; // NullReferenceException
+
+// ✅ GOOD: Configure mock to return data
+var repository = Substitute.For();
+var expectedOrder = new Order(/* ... */);
+repository.GetByIdAsync(orderId).Returns(expectedOrder);
+
+var order = await repository.GetByIdAsync(orderId);
+Assert.NotNull(order);
+```
+
+---
+
+### UnitOfWork mock setup
+
+**Problem:** `SaveChangesAsync` not properly mocked
+
+**Solution:**
+```csharp
+// ✅ Complete mock setup
+var repository = Substitute.For();
+var unitOfWork = Substitute.For();
+
+// Important: Link repository to unitOfWork
+repository.UnitOfWork.Returns(unitOfWork);
+
+// Configure SaveChangesAsync behavior
+unitOfWork.SaveChangesAsync(Arg.Any())
+ .Returns(Task.FromResult(1));
+
+// Now handler can use it
+var handler = new CreateOrderHandler(repository);
+await handler.Handle(command, CancellationToken.None);
+
+// Verify SaveChangesAsync was called
+await unitOfWork.Received(1).SaveChangesAsync(Arg.Any());
+```
+
+---
+
+## Async/Await Issues
+
+### Test hangs or times out
+
+**Common Causes:**
+1. Missing `await`
+2. Deadlock from `.Result` or `.Wait()`
+3. Infinite loop
+
+**Solutions:**
+```csharp
+// ❌ BAD: Forgetting await
+[Fact]
+public async Task TestMethod()
+{
+ var result = handler.Handle(command, ct); // Missing await!
+ Assert.NotNull(result); // Wrong - this is Task, not result
+}
+
+// ✅ GOOD: Proper await
+[Fact]
+public async Task TestMethod()
+{
+ var result = await handler.Handle(command, ct);
+ Assert.NotNull(result);
+}
+
+// ❌ BAD: Blocking async code
+var result = repository.GetByIdAsync(id).Result; // Deadlock risk!
+
+// ✅ GOOD: Await properly
+var result = await repository.GetByIdAsync(id);
+```
+
+---
+
+## Assertion Failures
+
+### Expected vs Actual mismatch
+
+**Symptom:**
+```
+Assert.Equal() Failure
+Expected: 5
+Actual: 0
+```
+
+**Debugging:**
+```csharp
+// ❌ Unclear what went wrong
+Assert.Equal(5, order.Items.Count);
+
+// ✅ Better: Add message
+Assert.Equal(5, order.Items.Count,
+ $"Expected 5 items but got {order.Items.Count}");
+
+// ✅ Best: Use specific assertions
+Assert.NotEmpty(order.Items);
+Assert.Equal(5, order.Items.Count);
+Assert.All(order.Items, item => Assert.NotNull(item.ProductId));
+```
+
+---
+
+### Collection comparison failures
+
+**Problem:** Comparing collections incorrectly
+
+**Solutions:**
+```csharp
+// ❌ BAD: Reference comparison
+var expected = new List { 1, 2, 3 };
+var actual = service.GetNumbers();
+Assert.Equal(expected, actual); // May fail even if content same
+
+// ✅ GOOD: Value comparison
+Assert.Equal(expected.Count, actual.Count);
+Assert.All(expected, item => Assert.Contains(item, actual));
+
+// ✅ Or use FluentAssertions
+actual.Should().BeEquivalentTo(expected);
+```
+
+---
+
+## Database Test Issues
+
+### Test fails due to database state
+
+**Problem:** Tests interfere with each other
+
+**Solutions:**
+```csharp
+// ✅ Use in-memory database per test
+public class OrderRepositoryTests : IDisposable
+{
+ private readonly DbContextOptions _options;
+ private readonly OrderContext _context;
+
+ public OrderRepositoryTests()
+ {
+ _options = new DbContextOptionsBuilder()
+ .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
+ .Options;
+
+ _context = new OrderContext(_options);
+ }
+
+ public void Dispose()
+ {
+ _context.Database.EnsureDeleted();
+ _context.Dispose();
+ }
+}
+
+// ✅ Or use Testcontainers for real database
+public class OrderRepositoryIntegrationTests : IAsyncLifetime
+{
+ private PostgreSqlContainer _container;
+
+ public async Task InitializeAsync()
+ {
+ _container = new PostgreSqlBuilder().Build();
+ await _container.StartAsync();
+ }
+
+ public async Task DisposeAsync()
+ {
+ await _container.DisposeAsync();
+ }
+}
+```
+
+---
+
+## Integration Test Issues
+
+### TestServer authentication failures
+
+**Problem:** Requests return 401 Unauthorized
+
+**Solutions:**
+```csharp
+// ✅ Setup test authentication
+public class CustomWebApplicationFactory : WebApplicationFactory
+{
+ protected override void ConfigureWebHost(IWebHostBuilder builder)
+ {
+ builder.ConfigureServices(services =>
+ {
+ // Remove real auth
+ services.RemoveAll();
+
+ // Add test auth
+ services.AddAuthentication("Test")
+ .AddScheme(
+ "Test", options => { });
+ });
+ }
+}
+
+// Usage in test
+var client = _factory.CreateClient();
+client.DefaultRequestHeaders.Add("X-Test-User-Id", userId.ToString());
+```
+
+---
+
+### Port already in use
+
+**Error:**
+```
+Address already in use: bind
+```
+
+**Solutions:**
+```csharp
+// ✅ Let TestServer choose random port
+var factory = new WebApplicationFactory()
+ .WithWebHostBuilder(builder =>
+ {
+ builder.UseUrls(); // Empty = random port
+ });
+
+// ✅ Or use different port per test class
+builder.UseUrls("http://localhost:0"); // 0 = random port
+```
+
+---
+
+## Flaky Tests
+
+### Tests pass/fail intermittently
+
+**Common Causes:**
+1. Race conditions
+2. Time-dependent logic
+3. Shared state
+4. External dependencies
+
+**Solutions:**
+```csharp
+// ❌ BAD: Time-dependent test
+[Fact]
+public void TestCreatedDate()
+{
+ var order = new Order();
+ Assert.Equal(DateTime.UtcNow, order.CreatedAt); // Flaky!
+}
+
+// ✅ GOOD: Test with tolerance
+[Fact]
+public void TestCreatedDate()
+{
+ var before = DateTime.UtcNow;
+ var order = new Order();
+ var after = DateTime.UtcNow;
+
+ Assert.InRange(order.CreatedAt, before, after);
+}
+
+// ✅ Or inject time provider
+public class Order
+{
+ public DateTime CreatedAt { get; }
+
+ public Order(ITimeProvider timeProvider)
+ {
+ CreatedAt = timeProvider.UtcNow;
+ }
+}
+
+// In test: mock time
+var timeProvider = Substitute.For();
+timeProvider.UtcNow.Returns(new DateTime(2024, 1, 1));
+```
+
+---
+
+## Test Coverage Issues
+
+### Low coverage on critical code
+
+**Problem:** Important logic not tested
+
+**Solutions:**
+```bash
+# Generate coverage report
+dotnet test /p:CollectCoverage=true /p:CoverageReportFormat=cobertura
+
+# View HTML report
+reportgenerator -reports:coverage.cobertura.xml -targetdir:coveragereport
+open coveragereport/index.html
+
+# Or use coverlet
+dotnet add package coverlet.collector
+dotnet test --collect:"XPlat Code Coverage"
+```
+
+**Focus on:**
+- Domain logic (business rules)
+- Command/Query handlers
+- Critical paths (checkout, payment, etc.)
+
+---
+
+## Quick Troubleshooting Checklist
+
+When tests fail:
+
+- [ ] Check mock setup (returns correct values?)
+- [ ] Verify async/await (no missing `await`?)
+- [ ] Check assertions (expected vs actual clear?)
+- [ ] Isolate test (run alone, not in suite)
+- [ ] Check test output logs
+- [ ] Add debug logging
+- [ ] Use debugger with breakpoints
+
+For integration tests:
+
+- [ ] Check TestServer configuration
+- [ ] Verify authentication setup
+- [ ] Check database state (clean between tests?)
+- [ ] Review test ordering (independent?)
+- [ ] Check external dependencies (mocked?)
+
+---
+
+## Related Resources
+
+- [Build Errors Catalog](build-errors.md)
+- [Debugging Guide](debugging-guide.md)
+- [dotnet-senior-tester skill](../../dotnet-senior-tester/SKILL.md)
+- Main Skill: [development-lifecycle](../SKILL.md)
diff --git a/.agent/skills/development-lifecycle/scripts/check-env.sh b/.agent/skills/development-lifecycle/scripts/check-env.sh
new file mode 100755
index 00000000..68b4f143
--- /dev/null
+++ b/.agent/skills/development-lifecycle/scripts/check-env.sh
@@ -0,0 +1,120 @@
+#!/bin/bash
+# EN: Check development environment for GoodGo microservices
+# VI: Kiểm tra môi trường phát triển cho GoodGo microservices
+
+set -e
+
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+echo " Development Environment Check"
+echo " Kiểm tra Môi Trường Phát Triển"
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+echo ""
+
+# Colors
+GREEN='\033[0;32m'
+RED='\033[0;31m'
+YELLOW='\033[1;33m'
+NC='\033[0m' # No Color
+
+check_command() {
+ local cmd=$1
+ local required_version=$2
+
+ if command -v $cmd &> /dev/null; then
+ local version=$($cmd --version 2>&1 | head -n 1)
+ echo -e "${GREEN}✓${NC} $cmd: $version"
+ return 0
+ else
+ echo -e "${RED}✗${NC} $cmd: Not installed"
+ if [ ! -z "$required_version" ]; then
+ echo -e " ${YELLOW}Required: $required_version${NC}"
+ fi
+ return 1
+ fi
+}
+
+check_dotnet() {
+ if command -v dotnet &> /dev/null; then
+ local version=$(dotnet --version)
+ echo -e "${GREEN}✓${NC} .NET SDK: $version"
+
+ # Check if version >= 8.0
+ local major=$(echo $version | cut -d. -f1)
+ if [ "$major" -lt 8 ]; then
+ echo -e " ${YELLOW}Warning: .NET 8.0+ recommended${NC}"
+ fi
+ return 0
+ else
+ echo -e "${RED}✗${NC} .NET SDK: Not installed"
+ echo -e " ${YELLOW}Required: .NET 8.0+${NC}"
+ return 1
+ fi
+}
+
+check_docker() {
+ if command -v docker &> /dev/null; then
+ local version=$(docker --version | cut -d' ' -f3 | tr -d ',')
+ echo -e "${GREEN}✓${NC} Docker: $version"
+
+ # Check if Docker daemon is running
+ if docker ps &> /dev/null; then
+ echo -e " ${GREEN}Docker daemon is running${NC}"
+ else
+ echo -e " ${RED}Docker daemon is not running${NC}"
+ return 1
+ fi
+ return 0
+ else
+ echo -e "${RED}✗${NC} Docker: Not installed"
+ return 1
+ fi
+}
+
+check_docker_compose() {
+ if command -v docker-compose &> /dev/null; then
+ local version=$(docker-compose --version | cut -d' ' -f4 | tr -d ',')
+ echo -e "${GREEN}✓${NC} docker-compose: $version"
+ return 0
+ elif docker compose version &> /dev/null; then
+ local version=$(docker compose version | cut -d' ' -f4 | tr -d 'v')
+ echo -e "${GREEN}✓${NC} docker compose: $version"
+ return 0
+ else
+ echo -e "${RED}✗${NC} docker-compose: Not installed"
+ return 1
+ fi
+}
+
+# Main checks
+echo "Core Tools:"
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ERRORS=0
+
+check_dotnet || ((ERRORS++))
+check_docker || ((ERRORS++))
+check_docker_compose || ((ERRORS++))
+
+echo ""
+echo "Optional Tools:"
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+
+check_command "git" || true
+check_command "curl" || true
+check_command "jq" || true
+
+echo ""
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+
+if [ $ERRORS -eq 0 ]; then
+ echo -e "${GREEN}✓ All core tools are installed!${NC}"
+ echo ""
+ exit 0
+else
+ echo -e "${RED}✗ $ERRORS core tool(s) missing or not working${NC}"
+ echo ""
+ echo "Installation guides:"
+ echo " .NET SDK: https://dotnet.microsoft.com/download"
+ echo " Docker: https://docs.docker.com/get-docker/"
+ echo ""
+ exit 1
+fi
diff --git a/.agent/skills/development-lifecycle/scripts/debug-build.sh b/.agent/skills/development-lifecycle/scripts/debug-build.sh
new file mode 100755
index 00000000..d95c0bcb
--- /dev/null
+++ b/.agent/skills/development-lifecycle/scripts/debug-build.sh
@@ -0,0 +1,85 @@
+#!/bin/bash
+# EN: Debug build errors with verbose output
+# VI: Debug lỗi build với output chi tiết
+
+set -e
+
+# Colors
+GREEN='\033[0;32m'
+RED='\033[0;31m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+NC='\033[0m' # No Color
+
+if [ $# -eq 0 ]; then
+ echo -e "${RED}Error: Service name required${NC}"
+ echo "Usage: $0 "
+ echo "Example: $0 my-service-net"
+ exit 1
+fi
+
+SERVICE_NAME=$1
+SERVICE_DIR="services/${SERVICE_NAME}"
+LOG_FILE="build-debug-$(date +%Y%m%d-%H%M%S).log"
+
+echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
+echo -e "${BLUE} Debug Build: ${SERVICE_NAME}${NC}"
+echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
+echo ""
+
+# Check if service exists
+if [ ! -d "$SERVICE_DIR" ]; then
+ echo -e "${RED}✗ Service not found: $SERVICE_DIR${NC}"
+ exit 1
+fi
+
+cd "$SERVICE_DIR"
+
+# Find solution file
+SOLUTION_FILE=$(find . -maxdepth 1 -name "*.slnx" -o -name "*.sln" | head -n 1)
+
+if [ -z "$SOLUTION_FILE" ]; then
+ echo -e "${RED}✗ No solution file found${NC}"
+ exit 1
+fi
+
+echo -e "${YELLOW}Step 1: Cleaning previous build artifacts...${NC}"
+dotnet clean "$SOLUTION_FILE"
+
+echo ""
+echo -e "${YELLOW}Step 2: Restoring packages with detailed output...${NC}"
+dotnet restore "$SOLUTION_FILE" -v detailed 2>&1 | tee "$LOG_FILE"
+
+echo ""
+echo -e "${YELLOW}Step 3: Building with detailed verbosity...${NC}"
+echo -e "${YELLOW}(This will take longer but show all information)${NC}"
+echo ""
+
+# Build with detailed output
+dotnet build "$SOLUTION_FILE" --no-restore -v detailed 2>&1 | tee -a "$LOG_FILE"
+
+BUILD_STATUS=$?
+
+echo ""
+echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
+
+if [ $BUILD_STATUS -eq 0 ]; then
+ echo -e "${GREEN}✓ Build completed successfully!${NC}"
+ echo ""
+ echo "Log saved to: $LOG_FILE"
+else
+ echo -e "${RED}✗ Build failed${NC}"
+ echo ""
+ echo "Full debug log saved to: $LOG_FILE"
+ echo ""
+ echo -e "${YELLOW}Common issues to check:${NC}"
+ echo " 1. Missing package references (dotnet add package)"
+ echo " 2. Using statements (add 'using' directive)"
+ echo " 3. NuGet package version conflicts"
+ echo " 4. Target framework mismatch"
+ echo ""
+ echo "Grep for errors:"
+ echo " cat $LOG_FILE | grep -i error"
+ echo " cat $LOG_FILE | grep -i CS[0-9]"
+ exit 1
+fi
diff --git a/.agent/skills/development-lifecycle/scripts/health-check.sh b/.agent/skills/development-lifecycle/scripts/health-check.sh
new file mode 100755
index 00000000..0e5dc689
--- /dev/null
+++ b/.agent/skills/development-lifecycle/scripts/health-check.sh
@@ -0,0 +1,90 @@
+#!/bin/bash
+# EN: Check health of all running services
+# VI: Kiểm tra health của tất cả services đang chạy
+
+set -e
+
+# Colors
+GREEN='\033[0;32m'
+RED='\033[0;31m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+NC='\033[0m' # No Color
+
+echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
+echo -e "${BLUE} Service Health Check${NC}"
+echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
+echo ""
+
+# Default base URL
+BASE_URL="${BASE_URL:-http://localhost}"
+TIMEOUT=5
+
+check_health() {
+ local service=$1
+ local path=$2
+ local url="${BASE_URL}${path}"
+
+ echo -n "Checking $service... "
+
+ if response=$(curl -s -o /dev/null -w "%{http_code}" --max-time $TIMEOUT "$url" 2>/dev/null); then
+ if [ "$response" = "200" ]; then
+ echo -e "${GREEN}✓ Healthy (200)${NC}"
+ return 0
+ else
+ echo -e "${YELLOW}⚠ Response: $response${NC}"
+ return 1
+ fi
+ else
+ echo -e "${RED}✗ Not reachable${NC}"
+ return 1
+ fi
+}
+
+# Track results
+TOTAL=0
+HEALTHY=0
+UNHEALTHY=0
+
+# Service list (customize based on your services)
+SERVICES=(
+ "IAM Service:/api/v1/iam/health"
+ "Storage Service:/api/v1/storage/health"
+ "Catalog Service:/api/v1/catalog/health"
+ "Order Service:/api/v1/order/health"
+ "Inventory Service:/api/v1/inventory/health"
+ "F&B Engine:/api/v1/fnb-engine/health"
+ "Booking Service:/api/v1/booking/health"
+)
+
+for SERVICE_INFO in "${SERVICES[@]}"; do
+ IFS=':' read -r NAME PATH <<< "$SERVICE_INFO"
+ ((TOTAL++))
+
+ if check_health "$NAME" "$PATH"; then
+ ((HEALTHY++))
+ else
+ ((UNHEALTHY++))
+ fi
+done
+
+echo ""
+echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
+echo "Summary:"
+echo " Total services: $TOTAL"
+echo -e " Healthy: ${GREEN}$HEALTHY${NC}"
+echo -e " Unhealthy: ${RED}$UNHEALTHY${NC}"
+echo ""
+
+if [ $UNHEALTHY -eq 0 ]; then
+ echo -e "${GREEN}✓ All services are healthy!${NC}"
+ exit 0
+else
+ echo -e "${YELLOW}⚠ Some services are not healthy${NC}"
+ echo ""
+ echo "Troubleshooting:"
+ echo " • Check if services are running: docker-compose ps"
+ echo " • View logs: docker-compose logs -f "
+ echo " • Restart service: docker-compose restart "
+ exit 1
+fi
diff --git a/.agent/skills/development-lifecycle/scripts/quick-build.sh b/.agent/skills/development-lifecycle/scripts/quick-build.sh
new file mode 100755
index 00000000..0dc3fcb9
--- /dev/null
+++ b/.agent/skills/development-lifecycle/scripts/quick-build.sh
@@ -0,0 +1,58 @@
+#!/bin/bash
+# EN: Quick build script for a single service
+# VI: Script build nhanh cho một service
+
+set -e
+
+# Colors
+GREEN='\033[0;32m'
+RED='\033[0;31m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+NC='\033[0m' # No Color
+
+if [ $# -eq 0 ]; then
+ echo -e "${RED}Error: Service name required${NC}"
+ echo "Usage: $0 "
+ echo "Example: $0 my-service-net"
+ exit 1
+fi
+
+SERVICE_NAME=$1
+SERVICE_DIR="services/${SERVICE_NAME}"
+
+echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
+echo -e "${BLUE} Quick Build: ${SERVICE_NAME}${NC}"
+echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
+echo ""
+
+# Check if service exists
+if [ ! -d "$SERVICE_DIR" ]; then
+ echo -e "${RED}✗ Service not found: $SERVICE_DIR${NC}"
+ exit 1
+fi
+
+cd "$SERVICE_DIR"
+
+# Find solution file
+SOLUTION_FILE=$(find . -maxdepth 1 -name "*.slnx" -o -name "*.sln" | head -n 1)
+
+if [ -z "$SOLUTION_FILE" ]; then
+ echo -e "${RED}✗ No solution file found${NC}"
+ exit 1
+fi
+
+echo -e "${YELLOW}📦 Restoring packages...${NC}"
+dotnet restore "$SOLUTION_FILE"
+
+echo ""
+echo -e "${YELLOW}🔨 Building solution...${NC}"
+dotnet build "$SOLUTION_FILE" --no-restore
+
+echo ""
+echo -e "${GREEN}✓ Build completed successfully!${NC}"
+echo ""
+echo "Next steps:"
+echo " • Run tests: dotnet test"
+echo " • Run locally: dotnet run --project src/*/API/"
+echo " • Build Docker: docker-compose -f deployments/local/docker-compose.yml build ${SERVICE_NAME}"
diff --git a/deployments/local/docker-compose.yml b/deployments/local/docker-compose.yml
index b76e755d..f37ad155 100644
--- a/deployments/local/docker-compose.yml
+++ b/deployments/local/docker-compose.yml
@@ -580,6 +580,271 @@ services:
- "traefik.http.services.promotion-service.loadbalancer.healthcheck.path=/health/live"
- "traefik.http.services.promotion-service.loadbalancer.healthcheck.interval=10s"
+ # ===========================================================================
+ # MULTI-VERTICAL SERVICES - Product, Order, Inventory Management
+ # ===========================================================================
+
+ # Catalog Service .NET - Polymorphic Product Management
+ catalog-service-net:
+ build:
+ context: ../../services/catalog-service-net
+ dockerfile: Dockerfile
+ image: goodgo/catalog-service-net:latest
+ container_name: catalog-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;Database=catalog_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=catalog-service
+ # EN: JWT Configuration
+ # VI: Cấu hình JWT
+ - Jwt__Authority=http://iam-service-net:8080
+ - Jwt__Audience=goodgo-api
+ - Jwt__RequireHttpsMetadata=false
+ ports:
+ - "5016:8080"
+ depends_on:
+ iam-service-net:
+ condition: service_healthy
+ merchant-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.catalog-service.rule=PathPrefix(`/api/v1/products`) || PathPrefix(`/api/v1/categories`)"
+ - "traefik.http.routers.catalog-service.entrypoints=web"
+ - "traefik.http.services.catalog-service.loadbalancer.server.port=8080"
+ - "traefik.http.services.catalog-service.loadbalancer.healthcheck.path=/health/live"
+ - "traefik.http.services.catalog-service.loadbalancer.healthcheck.interval=10s"
+
+ # Order Service .NET - Order Orchestration with Strategy Pattern
+ order-service-net:
+ build:
+ context: ../../services/order-service-net
+ dockerfile: Dockerfile
+ image: goodgo/order-service-net:latest
+ container_name: order-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;Database=order_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=order-service
+ # EN: Service Communication
+ # VI: Giao tiếp Service
+ - CatalogService__BaseUrl=http://catalog-service-net:8080
+ - InventoryService__BaseUrl=http://inventory-service-net:8080
+ - WalletService__BaseUrl=http://wallet-service-net:8080
+ # EN: JWT Configuration
+ # VI: Cấu hình JWT
+ - Jwt__Authority=http://iam-service-net:8080
+ - Jwt__Audience=goodgo-api
+ - Jwt__RequireHttpsMetadata=false
+ ports:
+ - "5017:8080"
+ depends_on:
+ iam-service-net:
+ condition: service_healthy
+ catalog-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.order-service.rule=PathPrefix(`/api/v1/orders`)"
+ - "traefik.http.routers.order-service.entrypoints=web"
+ - "traefik.http.services.order-service.loadbalancer.server.port=8080"
+ - "traefik.http.services.order-service.loadbalancer.healthcheck.path=/health/live"
+ - "traefik.http.services.order-service.loadbalancer.healthcheck.interval=10s"
+
+ # Inventory Service .NET - Stock Management (Retail + FnB)
+ inventory-service-net:
+ build:
+ context: ../../services/inventory-service-net
+ dockerfile: Dockerfile
+ image: goodgo/inventory-service-net:latest
+ container_name: inventory-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;Database=inventory_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=inventory-service
+ # EN: JWT Configuration
+ # VI: Cấu hình JWT
+ - Jwt__Authority=http://iam-service-net:8080
+ - Jwt__Audience=goodgo-api
+ - Jwt__RequireHttpsMetadata=false
+ ports:
+ - "5018:8080"
+ depends_on:
+ iam-service-net:
+ condition: service_healthy
+ catalog-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.inventory-service.rule=PathPrefix(`/api/v1/inventory`) || PathPrefix(`/api/v1/stock`)"
+ - "traefik.http.routers.inventory-service.entrypoints=web"
+ - "traefik.http.services.inventory-service.loadbalancer.server.port=8080"
+ - "traefik.http.services.inventory-service.loadbalancer.healthcheck.path=/health/live"
+ - "traefik.http.services.inventory-service.loadbalancer.healthcheck.interval=10s"
+
+ # FnB Engine .NET - Table, Session & Kitchen Management
+ fnb-engine-net:
+ build:
+ context: ../../services/fnb-engine-net
+ dockerfile: Dockerfile
+ image: goodgo/fnb-engine-net:latest
+ container_name: fnb-engine-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;Database=fnb_engine;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=fnb-engine
+ # 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 for SignalR (Kitchen Display)
+ # VI: Redis cho SignalR (Màn hình bếp)
+ - ConnectionStrings__Redis=167.114.174.113:6379,password=Velik@2026
+ ports:
+ - "5019:8080"
+ depends_on:
+ iam-service-net:
+ condition: service_healthy
+ merchant-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.fnb-engine.rule=PathPrefix(`/api/v1/tables`) || PathPrefix(`/api/v1/sessions`) || PathPrefix(`/api/v1/kitchen`)"
+ - "traefik.http.routers.fnb-engine.entrypoints=web"
+ - "traefik.http.services.fnb-engine.loadbalancer.server.port=8080"
+ - "traefik.http.services.fnb-engine.loadbalancer.healthcheck.path=/health/live"
+ - "traefik.http.services.fnb-engine.loadbalancer.healthcheck.interval=10s"
+ # EN: SignalR Hub route for Kitchen Display
+ # VI: Route cho SignalR Hub màn hình bếp
+ - "traefik.http.routers.fnb-hub.rule=PathPrefix(`/hubs/kitchen`)"
+ - "traefik.http.routers.fnb-hub.entrypoints=web"
+ - "traefik.http.routers.fnb-hub.service=fnb-engine"
+ - "traefik.http.services.fnb-engine.loadbalancer.sticky.cookie=true"
+ - "traefik.http.services.fnb-engine.loadbalancer.sticky.cookie.name=fnb_session"
+
+ # Booking Service .NET - Appointment Scheduling (Services, Spa, Salon)
+ booking-service-net:
+ build:
+ context: ../../services/booking-service-net
+ dockerfile: Dockerfile
+ image: goodgo/booking-service-net:latest
+ container_name: booking-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;Database=booking_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=booking-service
+ # EN: Service Communication
+ # VI: Giao tiếp Service
+ - CatalogService__BaseUrl=http://catalog-service-net:8080
+ - MerchantService__BaseUrl=http://merchant-service-net:8080
+ # EN: JWT Configuration
+ # VI: Cấu hình JWT
+ - Jwt__Authority=http://iam-service-net:8080
+ - Jwt__Audience=goodgo-api
+ - Jwt__RequireHttpsMetadata=false
+ ports:
+ - "5020:8080"
+ depends_on:
+ iam-service-net:
+ condition: service_healthy
+ catalog-service-net:
+ condition: service_healthy
+ merchant-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.booking-service.rule=PathPrefix(`/api/v1/appointments`) || PathPrefix(`/api/v1/resources`) || PathPrefix(`/api/v1/schedules`)"
+ - "traefik.http.routers.booking-service.entrypoints=web"
+ - "traefik.http.services.booking-service.loadbalancer.server.port=8080"
+ - "traefik.http.services.booking-service.loadbalancer.healthcheck.path=/health/live"
+ - "traefik.http.services.booking-service.loadbalancer.healthcheck.interval=10s"
+
# Jaeger - Distributed Tracing
# jaeger:
diff --git a/services/ads-analytics-service-net/docker-compose.yml b/services/ads-analytics-service-net/docker-compose.yml
deleted file mode 100644
index 254ceb12..00000000
--- a/services/ads-analytics-service-net/docker-compose.yml
+++ /dev/null
@@ -1,72 +0,0 @@
-version: '3.8'
-
-# EN: Docker Compose for local development
-# VI: Docker Compose cho phát triển local
-
-services:
- myservice-api:
- build:
- context: .
- dockerfile: Dockerfile
- container_name: myservice-api
- ports:
- - "5000:8080"
- environment:
- - ASPNETCORE_ENVIRONMENT=Development
- - DATABASE_URL=Host=postgres;Port=5432;Database=myservice_db;Username=postgres;Password=postgres
- - REDIS_URL=redis:6379
- depends_on:
- postgres:
- condition: service_healthy
- redis:
- condition: service_healthy
- networks:
- - myservice-network
- healthcheck:
- test: ["CMD", "curl", "-f", "http://localhost:8080/health/live"]
- interval: 30s
- timeout: 10s
- retries: 3
- start_period: 10s
-
- postgres:
- image: postgres:16-alpine
- container_name: myservice-postgres
- environment:
- POSTGRES_USER: postgres
- POSTGRES_PASSWORD: postgres
- POSTGRES_DB: myservice_db
- ports:
- - "5432:5432"
- volumes:
- - postgres_data:/var/lib/postgresql/data
- networks:
- - myservice-network
- healthcheck:
- test: ["CMD-SHELL", "pg_isready -U postgres"]
- interval: 10s
- timeout: 5s
- retries: 5
-
- redis:
- image: redis:7-alpine
- container_name: myservice-redis
- ports:
- - "6379:6379"
- volumes:
- - redis_data:/data
- networks:
- - myservice-network
- healthcheck:
- test: ["CMD", "redis-cli", "ping"]
- interval: 10s
- timeout: 5s
- retries: 5
-
-volumes:
- postgres_data:
- redis_data:
-
-networks:
- myservice-network:
- driver: bridge
diff --git a/services/ads-analytics-service-net/tests/MyService.FunctionalTests/MyService.FunctionalTests.csproj b/services/ads-analytics-service-net/tests/AdsAnalyticsService.FunctionalTests/AdsAnalyticsService.FunctionalTests.csproj
similarity index 100%
rename from services/ads-analytics-service-net/tests/MyService.FunctionalTests/MyService.FunctionalTests.csproj
rename to services/ads-analytics-service-net/tests/AdsAnalyticsService.FunctionalTests/AdsAnalyticsService.FunctionalTests.csproj
diff --git a/services/ads-analytics-service-net/tests/MyService.FunctionalTests/Controllers/SamplesControllerTests.cs b/services/ads-analytics-service-net/tests/AdsAnalyticsService.FunctionalTests/Controllers/SamplesControllerTests.cs
similarity index 100%
rename from services/ads-analytics-service-net/tests/MyService.FunctionalTests/Controllers/SamplesControllerTests.cs
rename to services/ads-analytics-service-net/tests/AdsAnalyticsService.FunctionalTests/Controllers/SamplesControllerTests.cs
diff --git a/services/ads-analytics-service-net/tests/MyService.FunctionalTests/CustomWebApplicationFactory.cs b/services/ads-analytics-service-net/tests/AdsAnalyticsService.FunctionalTests/CustomWebApplicationFactory.cs
similarity index 100%
rename from services/ads-analytics-service-net/tests/MyService.FunctionalTests/CustomWebApplicationFactory.cs
rename to services/ads-analytics-service-net/tests/AdsAnalyticsService.FunctionalTests/CustomWebApplicationFactory.cs
diff --git a/services/ads-analytics-service-net/tests/MyService.UnitTests/MyService.UnitTests.csproj b/services/ads-analytics-service-net/tests/AdsAnalyticsService.UnitTests/AdsAnalyticsService.UnitTests.csproj
similarity index 100%
rename from services/ads-analytics-service-net/tests/MyService.UnitTests/MyService.UnitTests.csproj
rename to services/ads-analytics-service-net/tests/AdsAnalyticsService.UnitTests/AdsAnalyticsService.UnitTests.csproj
diff --git a/services/ads-analytics-service-net/tests/MyService.UnitTests/Application/CreateSampleCommandHandlerTests.cs b/services/ads-analytics-service-net/tests/AdsAnalyticsService.UnitTests/Application/CreateSampleCommandHandlerTests.cs
similarity index 100%
rename from services/ads-analytics-service-net/tests/MyService.UnitTests/Application/CreateSampleCommandHandlerTests.cs
rename to services/ads-analytics-service-net/tests/AdsAnalyticsService.UnitTests/Application/CreateSampleCommandHandlerTests.cs
diff --git a/services/ads-analytics-service-net/tests/MyService.UnitTests/Domain/SampleAggregateTests.cs b/services/ads-analytics-service-net/tests/AdsAnalyticsService.UnitTests/Domain/SampleAggregateTests.cs
similarity index 100%
rename from services/ads-analytics-service-net/tests/MyService.UnitTests/Domain/SampleAggregateTests.cs
rename to services/ads-analytics-service-net/tests/AdsAnalyticsService.UnitTests/Domain/SampleAggregateTests.cs
diff --git a/services/ads-billing-service-net/docker-compose.yml b/services/ads-billing-service-net/docker-compose.yml
deleted file mode 100644
index 254ceb12..00000000
--- a/services/ads-billing-service-net/docker-compose.yml
+++ /dev/null
@@ -1,72 +0,0 @@
-version: '3.8'
-
-# EN: Docker Compose for local development
-# VI: Docker Compose cho phát triển local
-
-services:
- myservice-api:
- build:
- context: .
- dockerfile: Dockerfile
- container_name: myservice-api
- ports:
- - "5000:8080"
- environment:
- - ASPNETCORE_ENVIRONMENT=Development
- - DATABASE_URL=Host=postgres;Port=5432;Database=myservice_db;Username=postgres;Password=postgres
- - REDIS_URL=redis:6379
- depends_on:
- postgres:
- condition: service_healthy
- redis:
- condition: service_healthy
- networks:
- - myservice-network
- healthcheck:
- test: ["CMD", "curl", "-f", "http://localhost:8080/health/live"]
- interval: 30s
- timeout: 10s
- retries: 3
- start_period: 10s
-
- postgres:
- image: postgres:16-alpine
- container_name: myservice-postgres
- environment:
- POSTGRES_USER: postgres
- POSTGRES_PASSWORD: postgres
- POSTGRES_DB: myservice_db
- ports:
- - "5432:5432"
- volumes:
- - postgres_data:/var/lib/postgresql/data
- networks:
- - myservice-network
- healthcheck:
- test: ["CMD-SHELL", "pg_isready -U postgres"]
- interval: 10s
- timeout: 5s
- retries: 5
-
- redis:
- image: redis:7-alpine
- container_name: myservice-redis
- ports:
- - "6379:6379"
- volumes:
- - redis_data:/data
- networks:
- - myservice-network
- healthcheck:
- test: ["CMD", "redis-cli", "ping"]
- interval: 10s
- timeout: 5s
- retries: 5
-
-volumes:
- postgres_data:
- redis_data:
-
-networks:
- myservice-network:
- driver: bridge
diff --git a/services/ads-billing-service-net/tests/MyService.FunctionalTests/MyService.FunctionalTests.csproj b/services/ads-billing-service-net/tests/AdsBillingService.FunctionalTests/AdsBillingService.FunctionalTests.csproj
similarity index 100%
rename from services/ads-billing-service-net/tests/MyService.FunctionalTests/MyService.FunctionalTests.csproj
rename to services/ads-billing-service-net/tests/AdsBillingService.FunctionalTests/AdsBillingService.FunctionalTests.csproj
diff --git a/services/ads-billing-service-net/tests/MyService.FunctionalTests/Controllers/SamplesControllerTests.cs b/services/ads-billing-service-net/tests/AdsBillingService.FunctionalTests/Controllers/SamplesControllerTests.cs
similarity index 100%
rename from services/ads-billing-service-net/tests/MyService.FunctionalTests/Controllers/SamplesControllerTests.cs
rename to services/ads-billing-service-net/tests/AdsBillingService.FunctionalTests/Controllers/SamplesControllerTests.cs
diff --git a/services/ads-billing-service-net/tests/MyService.FunctionalTests/CustomWebApplicationFactory.cs b/services/ads-billing-service-net/tests/AdsBillingService.FunctionalTests/CustomWebApplicationFactory.cs
similarity index 100%
rename from services/ads-billing-service-net/tests/MyService.FunctionalTests/CustomWebApplicationFactory.cs
rename to services/ads-billing-service-net/tests/AdsBillingService.FunctionalTests/CustomWebApplicationFactory.cs
diff --git a/services/ads-billing-service-net/tests/MyService.UnitTests/MyService.UnitTests.csproj b/services/ads-billing-service-net/tests/AdsBillingService.UnitTests/AdsBillingService.UnitTests.csproj
similarity index 100%
rename from services/ads-billing-service-net/tests/MyService.UnitTests/MyService.UnitTests.csproj
rename to services/ads-billing-service-net/tests/AdsBillingService.UnitTests/AdsBillingService.UnitTests.csproj
diff --git a/services/ads-billing-service-net/tests/MyService.UnitTests/Application/CreateSampleCommandHandlerTests.cs b/services/ads-billing-service-net/tests/AdsBillingService.UnitTests/Application/CreateSampleCommandHandlerTests.cs
similarity index 100%
rename from services/ads-billing-service-net/tests/MyService.UnitTests/Application/CreateSampleCommandHandlerTests.cs
rename to services/ads-billing-service-net/tests/AdsBillingService.UnitTests/Application/CreateSampleCommandHandlerTests.cs
diff --git a/services/ads-billing-service-net/tests/MyService.UnitTests/Domain/SampleAggregateTests.cs b/services/ads-billing-service-net/tests/AdsBillingService.UnitTests/Domain/SampleAggregateTests.cs
similarity index 100%
rename from services/ads-billing-service-net/tests/MyService.UnitTests/Domain/SampleAggregateTests.cs
rename to services/ads-billing-service-net/tests/AdsBillingService.UnitTests/Domain/SampleAggregateTests.cs
diff --git a/services/ads-manager-service-net/docker-compose.yml b/services/ads-manager-service-net/docker-compose.yml
deleted file mode 100644
index 254ceb12..00000000
--- a/services/ads-manager-service-net/docker-compose.yml
+++ /dev/null
@@ -1,72 +0,0 @@
-version: '3.8'
-
-# EN: Docker Compose for local development
-# VI: Docker Compose cho phát triển local
-
-services:
- myservice-api:
- build:
- context: .
- dockerfile: Dockerfile
- container_name: myservice-api
- ports:
- - "5000:8080"
- environment:
- - ASPNETCORE_ENVIRONMENT=Development
- - DATABASE_URL=Host=postgres;Port=5432;Database=myservice_db;Username=postgres;Password=postgres
- - REDIS_URL=redis:6379
- depends_on:
- postgres:
- condition: service_healthy
- redis:
- condition: service_healthy
- networks:
- - myservice-network
- healthcheck:
- test: ["CMD", "curl", "-f", "http://localhost:8080/health/live"]
- interval: 30s
- timeout: 10s
- retries: 3
- start_period: 10s
-
- postgres:
- image: postgres:16-alpine
- container_name: myservice-postgres
- environment:
- POSTGRES_USER: postgres
- POSTGRES_PASSWORD: postgres
- POSTGRES_DB: myservice_db
- ports:
- - "5432:5432"
- volumes:
- - postgres_data:/var/lib/postgresql/data
- networks:
- - myservice-network
- healthcheck:
- test: ["CMD-SHELL", "pg_isready -U postgres"]
- interval: 10s
- timeout: 5s
- retries: 5
-
- redis:
- image: redis:7-alpine
- container_name: myservice-redis
- ports:
- - "6379:6379"
- volumes:
- - redis_data:/data
- networks:
- - myservice-network
- healthcheck:
- test: ["CMD", "redis-cli", "ping"]
- interval: 10s
- timeout: 5s
- retries: 5
-
-volumes:
- postgres_data:
- redis_data:
-
-networks:
- myservice-network:
- driver: bridge
diff --git a/services/ads-manager-service-net/src/AdsManagerService.API/AdsManagerService.API.csproj b/services/ads-manager-service-net/src/AdsManagerService.API/AdsManagerService.API.csproj
index c464beeb..7f54f606 100644
--- a/services/ads-manager-service-net/src/AdsManagerService.API/AdsManagerService.API.csproj
+++ b/services/ads-manager-service-net/src/AdsManagerService.API/AdsManagerService.API.csproj
@@ -14,6 +14,10 @@
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
diff --git a/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/ActivateCampaignCommand.cs b/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/ActivateCampaignCommand.cs
new file mode 100644
index 00000000..7cf1c290
--- /dev/null
+++ b/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/ActivateCampaignCommand.cs
@@ -0,0 +1,12 @@
+using MediatR;
+
+namespace AdsManagerService.API.Application.Commands;
+
+///
+/// EN: Command to activate a campaign.
+/// VI: Command kích hoạt chiến dịch.
+///
+public record ActivateCampaignCommand : IRequest
+{
+ public Guid CampaignId { get; init; }
+}
diff --git a/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/ActivateCampaignCommandHandler.cs b/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/ActivateCampaignCommandHandler.cs
new file mode 100644
index 00000000..0afca9a1
--- /dev/null
+++ b/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/ActivateCampaignCommandHandler.cs
@@ -0,0 +1,27 @@
+using AdsManagerService.Domain.AggregatesModel.CampaignAggregate;
+using MediatR;
+
+namespace AdsManagerService.API.Application.Commands;
+
+public class ActivateCampaignCommandHandler : IRequestHandler
+{
+ private readonly ICampaignRepository _campaignRepository;
+
+ public ActivateCampaignCommandHandler(ICampaignRepository campaignRepository)
+ {
+ _campaignRepository = campaignRepository ?? throw new ArgumentNullException(nameof(campaignRepository));
+ }
+
+ public async Task Handle(ActivateCampaignCommand request, CancellationToken cancellationToken)
+ {
+ var campaign = await _campaignRepository.GetByIdAsync(request.CampaignId, cancellationToken);
+
+ if (campaign == null)
+ return false;
+
+ campaign.Activate();
+ _campaignRepository.Update(campaign);
+
+ return await _campaignRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
+ }
+}
diff --git a/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/ChangeSampleStatusCommand.cs b/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/ChangeSampleStatusCommand.cs
deleted file mode 100644
index e78303ce..00000000
--- a/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/ChangeSampleStatusCommand.cs
+++ /dev/null
@@ -1,14 +0,0 @@
-using MediatR;
-
-namespace AdsManagerService.API.Application.Commands;
-
-///
-/// EN: Command to change status of a Sample.
-/// VI: Command để thay đổi trạng thái của Sample.
-///
-/// EN: Sample ID / VI: ID sample
-/// EN: New status (activate, complete, cancel) / VI: Trạng thái mới (activate, complete, cancel)
-public record ChangeSampleStatusCommand(
- Guid SampleId,
- string NewStatus
-) : IRequest;
diff --git a/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/ChangeSampleStatusCommandHandler.cs b/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/ChangeSampleStatusCommandHandler.cs
deleted file mode 100644
index 179c5f91..00000000
--- a/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/ChangeSampleStatusCommandHandler.cs
+++ /dev/null
@@ -1,70 +0,0 @@
-using MediatR;
-using AdsManagerService.Domain.AggregatesModel.SampleAggregate;
-
-namespace AdsManagerService.API.Application.Commands;
-
-///
-/// EN: Handler for ChangeSampleStatusCommand.
-/// VI: Handler cho ChangeSampleStatusCommand.
-///
-public class ChangeSampleStatusCommandHandler : IRequestHandler
-{
- private readonly ISampleRepository _sampleRepository;
- private readonly ILogger _logger;
-
- public ChangeSampleStatusCommandHandler(
- ISampleRepository sampleRepository,
- ILogger logger)
- {
- _sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
- _logger = logger ?? throw new ArgumentNullException(nameof(logger));
- }
-
- public async Task Handle(
- ChangeSampleStatusCommand request,
- CancellationToken cancellationToken)
- {
- _logger.LogInformation(
- "Changing status of sample {SampleId} to {NewStatus} / Thay đổi trạng thái sample {SampleId} thành {NewStatus}",
- request.SampleId, request.NewStatus);
-
- // EN: Get existing sample / VI: Lấy sample đã tồn tại
- var sample = await _sampleRepository.GetAsync(request.SampleId);
-
- if (sample is null)
- {
- _logger.LogWarning(
- "Sample {SampleId} not found / Sample {SampleId} không tìm thấy",
- request.SampleId);
- return false;
- }
-
- // EN: Change status based on action / VI: Thay đổi trạng thái dựa trên action
- switch (request.NewStatus.ToLowerInvariant())
- {
- case "activate":
- sample.Activate();
- break;
- case "complete":
- sample.Complete();
- break;
- case "cancel":
- sample.Cancel();
- break;
- default:
- _logger.LogWarning(
- "Invalid status action: {NewStatus} / Action trạng thái không hợp lệ: {NewStatus}",
- request.NewStatus);
- return false;
- }
-
- // EN: Save changes / VI: Lưu thay đổi
- await _sampleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
-
- _logger.LogInformation(
- "Sample {SampleId} status changed to {NewStatus} / Trạng thái sample {SampleId} đã đổi thành {NewStatus}",
- request.SampleId, request.NewStatus);
-
- return true;
- }
-}
diff --git a/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/CreateAdCommand.cs b/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/CreateAdCommand.cs
new file mode 100644
index 00000000..0a623d01
--- /dev/null
+++ b/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/CreateAdCommand.cs
@@ -0,0 +1,19 @@
+using MediatR;
+
+namespace AdsManagerService.API.Application.Commands;
+
+///
+/// EN: Command to create a new ad.
+/// VI: Command tạo quảng cáo mới.
+///
+public record CreateAdCommand : IRequest
+{
+ public Guid AdSetId { get; init; }
+ public string Name { get; init; } = null!;
+ public string Format { get; init; } = "single_image"; // single_image, single_video, carousel, etc.
+ public string? Headline { get; init; }
+ public string? PrimaryText { get; init; }
+ public string? CallToAction { get; init; }
+ public string? DestinationUrl { get; init; }
+ public string? CreativeUrl { get; init; }
+}
diff --git a/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/CreateAdCommandHandler.cs b/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/CreateAdCommandHandler.cs
new file mode 100644
index 00000000..bbe0f95f
--- /dev/null
+++ b/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/CreateAdCommandHandler.cs
@@ -0,0 +1,45 @@
+using AdsManagerService.Domain.AggregatesModel.AdAggregate;
+using MediatR;
+
+namespace AdsManagerService.API.Application.Commands;
+
+public class CreateAdCommandHandler : IRequestHandler
+{
+ private readonly IAdRepository _adRepository;
+
+ public CreateAdCommandHandler(IAdRepository adRepository)
+ {
+ _adRepository = adRepository ?? throw new ArgumentNullException(nameof(adRepository));
+ }
+
+ public async Task Handle(CreateAdCommand request, CancellationToken cancellationToken)
+ {
+ // Parse ad format
+ var format = request.Format.ToLower() switch
+ {
+ "single_image" => AdFormat.SingleImage,
+ "single_video" => AdFormat.SingleVideo,
+ "carousel" => AdFormat.Carousel,
+ "collection" => AdFormat.Collection,
+ "stories" => AdFormat.Stories,
+ _ => AdFormat.SingleImage
+ };
+
+ // Create ad
+ var ad = new Ad(
+ adSetId: request.AdSetId,
+ name: request.Name,
+ format: format,
+ headline: request.Headline,
+ primaryText: request.PrimaryText,
+ callToAction: request.CallToAction,
+ destinationUrl: request.DestinationUrl,
+ creativeUrl: request.CreativeUrl
+ );
+
+ _adRepository.Add(ad);
+ await _adRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
+
+ return ad.Id;
+ }
+}
diff --git a/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/CreateAdSetCommand.cs b/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/CreateAdSetCommand.cs
new file mode 100644
index 00000000..71a2d532
--- /dev/null
+++ b/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/CreateAdSetCommand.cs
@@ -0,0 +1,23 @@
+using MediatR;
+
+namespace AdsManagerService.API.Application.Commands;
+
+///
+/// EN: Command to create a new ad set.
+/// VI: Command tạo ad set mới.
+///
+public record CreateAdSetCommand : IRequest
+{
+ public Guid CampaignId { get; init; }
+ public string Name { get; init; } = null!;
+ public decimal DailyBudget { get; init; }
+ public string BidType { get; init; } = "cpc"; // cpc, cpm, ocpm, automatic
+ public decimal? BidAmount { get; init; }
+
+ // Targeting
+ public int? MinAge { get; init; }
+ public int? MaxAge { get; init; }
+ public string? Genders { get; init; }
+ public string? Locations { get; init; }
+ public string? Interests { get; init; }
+}
diff --git a/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/CreateAdSetCommandHandler.cs b/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/CreateAdSetCommandHandler.cs
new file mode 100644
index 00000000..2a31b9ca
--- /dev/null
+++ b/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/CreateAdSetCommandHandler.cs
@@ -0,0 +1,50 @@
+using AdsManagerService.Domain.AggregatesModel.AdSetAggregate;
+using MediatR;
+
+namespace AdsManagerService.API.Application.Commands;
+
+public class CreateAdSetCommandHandler : IRequestHandler
+{
+ private readonly IAdSetRepository _adSetRepository;
+
+ public CreateAdSetCommandHandler(IAdSetRepository adSetRepository)
+ {
+ _adSetRepository = adSetRepository ?? throw new ArgumentNullException(nameof(adSetRepository));
+ }
+
+ public async Task Handle(CreateAdSetCommand request, CancellationToken cancellationToken)
+ {
+ // Create targeting
+ var targeting = new Targeting(
+ minAge: request.MinAge,
+ maxAge: request.MaxAge,
+ genders: request.Genders,
+ locations: request.Locations,
+ interests: request.Interests
+ );
+
+ // Create bid strategy
+ var bidStrategy = request.BidType.ToLower() switch
+ {
+ "cpc" => BidStrategy.CPC(request.BidAmount ?? 1000),
+ "cpm" => BidStrategy.CPM(request.BidAmount ?? 10000),
+ "ocpm" => BidStrategy.OCPM(request.BidAmount ?? 5000),
+ "automatic" => BidStrategy.Automatic(),
+ _ => BidStrategy.CPC(1000)
+ };
+
+ // Create ad set
+ var adSet = new AdSet(
+ campaignId: request.CampaignId,
+ name: request.Name,
+ targeting: targeting,
+ bidStrategy: bidStrategy,
+ dailyBudget: request.DailyBudget
+ );
+
+ _adSetRepository.Add(adSet);
+ await _adSetRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
+
+ return adSet.Id;
+ }
+}
diff --git a/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/CreateCampaignCommand.cs b/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/CreateCampaignCommand.cs
new file mode 100644
index 00000000..80e6f9f4
--- /dev/null
+++ b/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/CreateCampaignCommand.cs
@@ -0,0 +1,20 @@
+using MediatR;
+
+namespace AdsManagerService.API.Application.Commands;
+
+///
+/// EN: Command to create a new campaign.
+/// VI: Command tạo chiến dịch mới.
+///
+public record CreateCampaignCommand : IRequest
+{
+ public Guid AdvertiserId { get; init; }
+ public string Name { get; init; } = null!;
+ public string? Description { get; init; }
+ public string Objective { get; init; } = null!; // "awareness", "traffic", "conversion", etc.
+ public string BudgetType { get; init; } = null!; // "daily" or "lifetime"
+ public decimal BudgetAmount { get; init; }
+ public string Currency { get; init; } = "VND";
+ public DateTime? StartDate { get; init; }
+ public DateTime? EndDate { get; init; }
+}
diff --git a/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/CreateCampaignCommandHandler.cs b/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/CreateCampaignCommandHandler.cs
new file mode 100644
index 00000000..ebcb6539
--- /dev/null
+++ b/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/CreateCampaignCommandHandler.cs
@@ -0,0 +1,52 @@
+using AdsManagerService.Domain.AggregatesModel.CampaignAggregate;
+using MediatR;
+
+namespace AdsManagerService.API.Application.Commands;
+
+///
+/// EN: Handler for CreateCampaignCommand.
+/// VI: Handler cho CreateCampaignCommand.
+///
+public class CreateCampaignCommandHandler : IRequestHandler
+{
+ private readonly ICampaignRepository _campaignRepository;
+
+ public CreateCampaignCommandHandler(ICampaignRepository campaignRepository)
+ {
+ _campaignRepository = campaignRepository ?? throw new ArgumentNullException(nameof(campaignRepository));
+ }
+
+ public async Task Handle(CreateCampaignCommand request, CancellationToken cancellationToken)
+ {
+ // Parse objective
+ var objective = CampaignObjective.FromName(request.Objective);
+
+ // Create budget
+ var budget = request.BudgetType.ToLower() == "daily"
+ ? CampaignBudget.Daily(request.BudgetAmount, request.Currency)
+ : CampaignBudget.Lifetime(request.BudgetAmount, request.Currency);
+
+ // Create campaign
+ var campaign = new Campaign(
+ advertiserId: request.AdvertiserId,
+ name: request.Name,
+ objective: objective,
+ budget: budget,
+ description: request.Description
+ );
+
+ // Set schedule if provided
+ if (request.StartDate.HasValue)
+ {
+ campaign.SetSchedule(request.StartDate.Value, request.EndDate);
+ }
+
+ // Add to repository
+ _campaignRepository.Add(campaign);
+
+ // Save changes
+ await _campaignRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
+
+ return campaign.Id;
+ }
+}
diff --git a/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/CreateSampleCommand.cs b/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/CreateSampleCommand.cs
deleted file mode 100644
index 691a6b3b..00000000
--- a/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/CreateSampleCommand.cs
+++ /dev/null
@@ -1,21 +0,0 @@
-using MediatR;
-
-namespace AdsManagerService.API.Application.Commands;
-
-///
-/// EN: Command to create a new Sample.
-/// VI: Command để tạo một Sample mới.
-///
-/// EN: Sample name / VI: Tên sample
-/// EN: Optional description / VI: Mô tả tùy chọn
-public record CreateSampleCommand(
- string Name,
- string? Description
-) : IRequest;
-
-///
-/// EN: Result of CreateSampleCommand.
-/// VI: Kết quả của CreateSampleCommand.
-///
-/// EN: Created sample ID / VI: ID sample đã tạo
-public record CreateSampleCommandResult(Guid Id);
diff --git a/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/CreateSampleCommandHandler.cs b/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/CreateSampleCommandHandler.cs
deleted file mode 100644
index 1dfb8c70..00000000
--- a/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/CreateSampleCommandHandler.cs
+++ /dev/null
@@ -1,46 +0,0 @@
-using MediatR;
-using AdsManagerService.Domain.AggregatesModel.SampleAggregate;
-
-namespace AdsManagerService.API.Application.Commands;
-
-///
-/// EN: Handler for CreateSampleCommand.
-/// VI: Handler cho CreateSampleCommand.
-///
-public class CreateSampleCommandHandler : IRequestHandler
-{
- private readonly ISampleRepository _sampleRepository;
- private readonly ILogger _logger;
-
- public CreateSampleCommandHandler(
- ISampleRepository sampleRepository,
- ILogger logger)
- {
- _sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
- _logger = logger ?? throw new ArgumentNullException(nameof(logger));
- }
-
- public async Task Handle(
- CreateSampleCommand request,
- CancellationToken cancellationToken)
- {
- _logger.LogInformation(
- "Creating new sample with name: {Name} / Tạo sample mới với tên: {Name}",
- request.Name);
-
- // EN: Create domain entity / VI: Tạo domain entity
- var sample = new Sample(request.Name, request.Description);
-
- // EN: Add to repository / VI: Thêm vào repository
- _sampleRepository.Add(sample);
-
- // EN: Save changes (dispatches domain events) / VI: Lưu thay đổi (dispatch domain events)
- await _sampleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
-
- _logger.LogInformation(
- "Sample created successfully with ID: {SampleId} / Sample đã tạo thành công với ID: {SampleId}",
- sample.Id);
-
- return new CreateSampleCommandResult(sample.Id);
- }
-}
diff --git a/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/DeleteSampleCommand.cs b/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/DeleteSampleCommand.cs
deleted file mode 100644
index a0ccc220..00000000
--- a/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/DeleteSampleCommand.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-using MediatR;
-
-namespace AdsManagerService.API.Application.Commands;
-
-///
-/// EN: Command to delete a Sample.
-/// VI: Command để xóa một Sample.
-///
-/// EN: Sample ID to delete / VI: ID sample cần xóa
-public record DeleteSampleCommand(Guid SampleId) : IRequest;
diff --git a/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/DeleteSampleCommandHandler.cs b/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/DeleteSampleCommandHandler.cs
deleted file mode 100644
index 20f91d16..00000000
--- a/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/DeleteSampleCommandHandler.cs
+++ /dev/null
@@ -1,54 +0,0 @@
-using MediatR;
-using AdsManagerService.Domain.AggregatesModel.SampleAggregate;
-
-namespace AdsManagerService.API.Application.Commands;
-
-///
-/// EN: Handler for DeleteSampleCommand.
-/// VI: Handler cho DeleteSampleCommand.
-///
-public class DeleteSampleCommandHandler : IRequestHandler
-{
- private readonly ISampleRepository _sampleRepository;
- private readonly ILogger _logger;
-
- public DeleteSampleCommandHandler(
- ISampleRepository sampleRepository,
- ILogger logger)
- {
- _sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
- _logger = logger ?? throw new ArgumentNullException(nameof(logger));
- }
-
- public async Task Handle(
- DeleteSampleCommand request,
- CancellationToken cancellationToken)
- {
- _logger.LogInformation(
- "Deleting sample {SampleId} / Xóa sample {SampleId}",
- request.SampleId);
-
- // EN: Get existing sample / VI: Lấy sample đã tồn tại
- var sample = await _sampleRepository.GetAsync(request.SampleId);
-
- if (sample is null)
- {
- _logger.LogWarning(
- "Sample {SampleId} not found / Sample {SampleId} không tìm thấy",
- request.SampleId);
- return false;
- }
-
- // EN: Delete sample / VI: Xóa sample
- _sampleRepository.Delete(sample);
-
- // EN: Save changes / VI: Lưu thay đổi
- await _sampleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
-
- _logger.LogInformation(
- "Sample {SampleId} deleted successfully / Sample {SampleId} đã xóa thành công",
- request.SampleId);
-
- return true;
- }
-}
diff --git a/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/UpdateCampaignCommand.cs b/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/UpdateCampaignCommand.cs
new file mode 100644
index 00000000..846302d1
--- /dev/null
+++ b/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/UpdateCampaignCommand.cs
@@ -0,0 +1,14 @@
+using MediatR;
+
+namespace AdsManagerService.API.Application.Commands;
+
+///
+/// EN: Command to update campaign information.
+/// VI: Command cập nhật thông tin chiến dịch.
+///
+public record UpdateCampaignCommand : IRequest
+{
+ public Guid CampaignId { get; init; }
+ public string Name { get; init; } = null!;
+ public string? Description { get; init; }
+}
diff --git a/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/UpdateCampaignCommandHandler.cs b/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/UpdateCampaignCommandHandler.cs
new file mode 100644
index 00000000..e8fb5b68
--- /dev/null
+++ b/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/UpdateCampaignCommandHandler.cs
@@ -0,0 +1,27 @@
+using AdsManagerService.Domain.AggregatesModel.CampaignAggregate;
+using MediatR;
+
+namespace AdsManagerService.API.Application.Commands;
+
+public class UpdateCampaignCommandHandler : IRequestHandler
+{
+ private readonly ICampaignRepository _campaignRepository;
+
+ public UpdateCampaignCommandHandler(ICampaignRepository campaignRepository)
+ {
+ _campaignRepository = campaignRepository ?? throw new ArgumentNullException(nameof(campaignRepository));
+ }
+
+ public async Task Handle(UpdateCampaignCommand request, CancellationToken cancellationToken)
+ {
+ var campaign = await _campaignRepository.GetByIdAsync(request.CampaignId, cancellationToken);
+
+ if (campaign == null)
+ return false;
+
+ campaign.Update(request.Name, request.Description);
+ _campaignRepository.Update(campaign);
+
+ return await _campaignRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
+ }
+}
diff --git a/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/UpdateSampleCommand.cs b/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/UpdateSampleCommand.cs
deleted file mode 100644
index 2d872f98..00000000
--- a/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/UpdateSampleCommand.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-using MediatR;
-
-namespace AdsManagerService.API.Application.Commands;
-
-///
-/// EN: Command to update an existing Sample.
-/// VI: Command để cập nhật một Sample đã tồn tại.
-///
-/// EN: Sample ID to update / VI: ID sample cần cập nhật
-/// EN: New name / VI: Tên mới
-/// EN: New description / VI: Mô tả mới
-public record UpdateSampleCommand(
- Guid SampleId,
- string Name,
- string? Description
-) : IRequest;
diff --git a/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/UpdateSampleCommandHandler.cs b/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/UpdateSampleCommandHandler.cs
deleted file mode 100644
index 73739045..00000000
--- a/services/ads-manager-service-net/src/AdsManagerService.API/Application/Commands/UpdateSampleCommandHandler.cs
+++ /dev/null
@@ -1,54 +0,0 @@
-using MediatR;
-using AdsManagerService.Domain.AggregatesModel.SampleAggregate;
-
-namespace AdsManagerService.API.Application.Commands;
-
-///
-/// EN: Handler for UpdateSampleCommand.
-/// VI: Handler cho UpdateSampleCommand.
-///
-public class UpdateSampleCommandHandler : IRequestHandler
-{
- private readonly ISampleRepository _sampleRepository;
- private readonly ILogger _logger;
-
- public UpdateSampleCommandHandler(
- ISampleRepository sampleRepository,
- ILogger logger)
- {
- _sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
- _logger = logger ?? throw new ArgumentNullException(nameof(logger));
- }
-
- public async Task Handle(
- UpdateSampleCommand request,
- CancellationToken cancellationToken)
- {
- _logger.LogInformation(
- "Updating sample {SampleId} / Cập nhật sample {SampleId}",
- request.SampleId);
-
- // EN: Get existing sample / VI: Lấy sample đã tồn tại
- var sample = await _sampleRepository.GetAsync(request.SampleId);
-
- if (sample is null)
- {
- _logger.LogWarning(
- "Sample {SampleId} not found / Sample {SampleId} không tìm thấy",
- request.SampleId);
- return false;
- }
-
- // EN: Update sample using domain method / VI: Cập nhật sample sử dụng domain method
- sample.Update(request.Name, request.Description);
-
- // EN: Save changes / VI: Lưu thay đổi
- await _sampleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
-
- _logger.LogInformation(
- "Sample {SampleId} updated successfully / Sample {SampleId} đã cập nhật thành công",
- request.SampleId);
-
- return true;
- }
-}
diff --git a/services/ads-manager-service-net/src/AdsManagerService.API/Application/Queries/GetCampaignByIdQuery.cs b/services/ads-manager-service-net/src/AdsManagerService.API/Application/Queries/GetCampaignByIdQuery.cs
new file mode 100644
index 00000000..ae194a13
--- /dev/null
+++ b/services/ads-manager-service-net/src/AdsManagerService.API/Application/Queries/GetCampaignByIdQuery.cs
@@ -0,0 +1,34 @@
+using MediatR;
+
+namespace AdsManagerService.API.Application.Queries;
+
+///
+/// EN: Query to get campaign by ID.
+/// VI: Query lấy chiến dịch theo ID.
+///
+public record GetCampaignByIdQuery : IRequest
+{
+ public Guid CampaignId { get; init; }
+}
+
+///
+/// EN: Campaign DTO for API responses.
+/// VI: Campaign DTO cho API responses.
+///
+public record CampaignDto
+{
+ public Guid Id { get; init; }
+ public Guid AdvertiserId { get; init; }
+ public string Name { get; init; } = null!;
+ public string? Description { get; init; }
+ public string Status { get; init; } = null!;
+ public string Objective { get; init; } = null!;
+ public string BudgetType { get; init; } = null!;
+ public decimal BudgetAmount { get; init; }
+ public string Currency { get; init; } = null!;
+ public decimal TotalSpend { get; init; }
+ public DateTime? StartDate { get; init; }
+ public DateTime? EndDate { get; init; }
+ public DateTime CreatedAt { get; init; }
+ public DateTime? UpdatedAt { get; init; }
+}
diff --git a/services/ads-manager-service-net/src/AdsManagerService.API/Application/Queries/GetCampaignByIdQueryHandler.cs b/services/ads-manager-service-net/src/AdsManagerService.API/Application/Queries/GetCampaignByIdQueryHandler.cs
new file mode 100644
index 00000000..f8a0222e
--- /dev/null
+++ b/services/ads-manager-service-net/src/AdsManagerService.API/Application/Queries/GetCampaignByIdQueryHandler.cs
@@ -0,0 +1,40 @@
+using AdsManagerService.Domain.AggregatesModel.CampaignAggregate;
+using MediatR;
+
+namespace AdsManagerService.API.Application.Queries;
+
+public class GetCampaignByIdQueryHandler : IRequestHandler
+{
+ private readonly ICampaignRepository _campaignRepository;
+
+ public GetCampaignByIdQueryHandler(ICampaignRepository campaignRepository)
+ {
+ _campaignRepository = campaignRepository ?? throw new ArgumentNullException(nameof(campaignRepository));
+ }
+
+ public async Task Handle(GetCampaignByIdQuery request, CancellationToken cancellationToken)
+ {
+ var campaign = await _campaignRepository.GetByIdAsync(request.CampaignId, cancellationToken);
+
+ if (campaign == null)
+ return null;
+
+ return new CampaignDto
+ {
+ Id = campaign.Id,
+ AdvertiserId = campaign.AdvertiserId,
+ Name = campaign.Name,
+ Description = campaign.Description,
+ Status = campaign.Status.Name,
+ Objective = campaign.Objective.Name,
+ BudgetType = campaign.Budget.Type.ToString(),
+ BudgetAmount = campaign.Budget.Amount,
+ Currency = campaign.Budget.Currency,
+ TotalSpend = campaign.TotalSpend,
+ StartDate = campaign.StartDate,
+ EndDate = campaign.EndDate,
+ CreatedAt = campaign.CreatedAt,
+ UpdatedAt = campaign.UpdatedAt
+ };
+ }
+}
diff --git a/services/ads-manager-service-net/src/AdsManagerService.API/Application/Queries/GetSampleQuery.cs b/services/ads-manager-service-net/src/AdsManagerService.API/Application/Queries/GetSampleQuery.cs
deleted file mode 100644
index 3b4e70b9..00000000
--- a/services/ads-manager-service-net/src/AdsManagerService.API/Application/Queries/GetSampleQuery.cs
+++ /dev/null
@@ -1,23 +0,0 @@
-using MediatR;
-
-namespace AdsManagerService.API.Application.Queries;
-
-///
-/// EN: Query to get a Sample by ID.
-/// VI: Query để lấy một Sample theo ID.
-///
-/// EN: Sample ID / VI: ID sample
-public record GetSampleQuery(Guid SampleId) : IRequest;
-
-///
-/// EN: Sample view model for API responses.
-/// VI: Sample view model cho API responses.
-///
-public record SampleViewModel(
- Guid Id,
- string Name,
- string? Description,
- string Status,
- DateTime CreatedAt,
- DateTime? UpdatedAt
-);
diff --git a/services/ads-manager-service-net/src/AdsManagerService.API/Application/Queries/GetSampleQueryHandler.cs b/services/ads-manager-service-net/src/AdsManagerService.API/Application/Queries/GetSampleQueryHandler.cs
deleted file mode 100644
index 8b3c19d0..00000000
--- a/services/ads-manager-service-net/src/AdsManagerService.API/Application/Queries/GetSampleQueryHandler.cs
+++ /dev/null
@@ -1,39 +0,0 @@
-using MediatR;
-using AdsManagerService.Domain.AggregatesModel.SampleAggregate;
-
-namespace AdsManagerService.API.Application.Queries;
-
-///
-/// EN: Handler for GetSampleQuery.
-/// VI: Handler cho GetSampleQuery.
-///
-public class GetSampleQueryHandler : IRequestHandler
-{
- private readonly ISampleRepository _sampleRepository;
-
- public GetSampleQueryHandler(ISampleRepository sampleRepository)
- {
- _sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
- }
-
- public async Task Handle(
- GetSampleQuery request,
- CancellationToken cancellationToken)
- {
- var sample = await _sampleRepository.GetAsync(request.SampleId);
-
- if (sample is null)
- {
- return null;
- }
-
- return new SampleViewModel(
- sample.Id,
- sample.Name,
- sample.Description,
- sample.Status.Name,
- sample.CreatedAt,
- sample.UpdatedAt
- );
- }
-}
diff --git a/services/ads-manager-service-net/src/AdsManagerService.API/Application/Queries/GetSamplesQuery.cs b/services/ads-manager-service-net/src/AdsManagerService.API/Application/Queries/GetSamplesQuery.cs
deleted file mode 100644
index 9e2842e0..00000000
--- a/services/ads-manager-service-net/src/AdsManagerService.API/Application/Queries/GetSamplesQuery.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-using MediatR;
-
-namespace AdsManagerService.API.Application.Queries;
-
-///
-/// EN: Query to get all Samples.
-/// VI: Query để lấy tất cả Samples.
-///
-public record GetSamplesQuery : IRequest>;
diff --git a/services/ads-manager-service-net/src/AdsManagerService.API/Application/Queries/GetSamplesQueryHandler.cs b/services/ads-manager-service-net/src/AdsManagerService.API/Application/Queries/GetSamplesQueryHandler.cs
deleted file mode 100644
index 32ee3f7a..00000000
--- a/services/ads-manager-service-net/src/AdsManagerService.API/Application/Queries/GetSamplesQueryHandler.cs
+++ /dev/null
@@ -1,34 +0,0 @@
-using MediatR;
-using AdsManagerService.Domain.AggregatesModel.SampleAggregate;
-
-namespace AdsManagerService.API.Application.Queries;
-
-///
-/// EN: Handler for GetSamplesQuery.
-/// VI: Handler cho GetSamplesQuery.
-///
-public class GetSamplesQueryHandler : IRequestHandler>
-{
- private readonly ISampleRepository _sampleRepository;
-
- public GetSamplesQueryHandler(ISampleRepository sampleRepository)
- {
- _sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
- }
-
- public async Task> Handle(
- GetSamplesQuery request,
- CancellationToken cancellationToken)
- {
- var samples = await _sampleRepository.GetAllAsync();
-
- return samples.Select(sample => new SampleViewModel(
- sample.Id,
- sample.Name,
- sample.Description,
- sample.Status.Name,
- sample.CreatedAt,
- sample.UpdatedAt
- ));
- }
-}
diff --git a/services/ads-manager-service-net/src/AdsManagerService.API/Application/Validations/CreateSampleCommandValidator.cs b/services/ads-manager-service-net/src/AdsManagerService.API/Application/Validations/CreateSampleCommandValidator.cs
deleted file mode 100644
index 07303245..00000000
--- a/services/ads-manager-service-net/src/AdsManagerService.API/Application/Validations/CreateSampleCommandValidator.cs
+++ /dev/null
@@ -1,25 +0,0 @@
-using FluentValidation;
-using AdsManagerService.API.Application.Commands;
-
-namespace AdsManagerService.API.Application.Validations;
-
-///
-/// EN: Validator for CreateSampleCommand.
-/// VI: Validator cho CreateSampleCommand.
-///
-public class CreateSampleCommandValidator : AbstractValidator
-{
- public CreateSampleCommandValidator()
- {
- RuleFor(x => x.Name)
- .NotEmpty()
- .WithMessage("Name is required / Tên là bắt buộc")
- .MaximumLength(200)
- .WithMessage("Name must be less than 200 characters / Tên phải ít hơn 200 ký tự");
-
- RuleFor(x => x.Description)
- .MaximumLength(1000)
- .WithMessage("Description must be less than 1000 characters / Mô tả phải ít hơn 1000 ký tự")
- .When(x => x.Description != null);
- }
-}
diff --git a/services/ads-manager-service-net/src/AdsManagerService.API/Application/Validations/UpdateSampleCommandValidator.cs b/services/ads-manager-service-net/src/AdsManagerService.API/Application/Validations/UpdateSampleCommandValidator.cs
deleted file mode 100644
index f3e9f8d2..00000000
--- a/services/ads-manager-service-net/src/AdsManagerService.API/Application/Validations/UpdateSampleCommandValidator.cs
+++ /dev/null
@@ -1,29 +0,0 @@
-using FluentValidation;
-using AdsManagerService.API.Application.Commands;
-
-namespace AdsManagerService.API.Application.Validations;
-
-///
-/// EN: Validator for UpdateSampleCommand.
-/// VI: Validator cho UpdateSampleCommand.
-///
-public class UpdateSampleCommandValidator : AbstractValidator
-{
- public UpdateSampleCommandValidator()
- {
- RuleFor(x => x.SampleId)
- .NotEmpty()
- .WithMessage("Sample ID is required / ID sample là bắt buộc");
-
- RuleFor(x => x.Name)
- .NotEmpty()
- .WithMessage("Name is required / Tên là bắt buộc")
- .MaximumLength(200)
- .WithMessage("Name must be less than 200 characters / Tên phải ít hơn 200 ký tự");
-
- RuleFor(x => x.Description)
- .MaximumLength(1000)
- .WithMessage("Description must be less than 1000 characters / Mô tả phải ít hơn 1000 ký tự")
- .When(x => x.Description != null);
- }
-}
diff --git a/services/ads-manager-service-net/src/AdsManagerService.API/Controllers/AdSetsController.cs b/services/ads-manager-service-net/src/AdsManagerService.API/Controllers/AdSetsController.cs
new file mode 100644
index 00000000..cb8267c8
--- /dev/null
+++ b/services/ads-manager-service-net/src/AdsManagerService.API/Controllers/AdSetsController.cs
@@ -0,0 +1,53 @@
+using AdsManagerService.API.Application.Commands;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+
+namespace AdsManagerService.API.Controllers;
+
+///
+/// EN: API Controller for managing ad sets.
+/// VI: API Controller quản lý ad sets.
+///
+[ApiController]
+[Route("api/v1/ads-manager/adsets")]
+[Produces("application/json")]
+public class AdSetsController : ControllerBase
+{
+ private readonly IMediator _mediator;
+ private readonly ILogger _logger;
+
+ public AdSetsController(IMediator mediator, ILogger logger)
+ {
+ _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ ///
+ /// EN: Create a new ad set.
+ /// VI: Tạo ad set mới.
+ ///
+ [HttpPost]
+ [ProducesResponseType(StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ public async Task> CreateAdSet([FromBody] CreateAdSetCommand command)
+ {
+ _logger.LogInformation("Creating ad set for campaign {CampaignId}", command.CampaignId);
+
+ var adSetId = await _mediator.Send(command);
+
+ return CreatedAtAction(nameof(GetAdSetById), new { id = adSetId }, adSetId);
+ }
+
+ ///
+ /// EN: Get ad set by ID (placeholder).
+ /// VI: Lấy ad set theo ID (placeholder).
+ ///
+ [HttpGet("{id}")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task GetAdSetById(Guid id)
+ {
+ // TODO: Implement GetAdSetByIdQuery
+ return Ok(new { id, message = "Ad set details" });
+ }
+}
diff --git a/services/ads-manager-service-net/src/AdsManagerService.API/Controllers/AdsController.cs b/services/ads-manager-service-net/src/AdsManagerService.API/Controllers/AdsController.cs
new file mode 100644
index 00000000..155460aa
--- /dev/null
+++ b/services/ads-manager-service-net/src/AdsManagerService.API/Controllers/AdsController.cs
@@ -0,0 +1,67 @@
+using AdsManagerService.API.Application.Commands;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+
+namespace AdsManagerService.API.Controllers;
+
+///
+/// EN: API Controller for managing individual ads.
+/// VI: API Controller quản lý quảng cáo.
+///
+[ApiController]
+[Route("api/v1/ads-manager/ads")]
+[Produces("application/json")]
+public class AdsController : ControllerBase
+{
+ private readonly IMediator _mediator;
+ private readonly ILogger _logger;
+
+ public AdsController(IMediator mediator, ILogger logger)
+ {
+ _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ ///
+ /// EN: Create a new ad.
+ /// VI: Tạo quảng cáo mới.
+ ///
+ [HttpPost]
+ [ProducesResponseType(StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ public async Task> CreateAd([FromBody] CreateAdCommand command)
+ {
+ _logger.LogInformation("Creating ad for ad set {AdSetId}", command.AdSetId);
+
+ var adId = await _mediator.Send(command);
+
+ return CreatedAtAction(nameof(GetAdById), new { id = adId }, adId);
+ }
+
+ ///
+ /// EN: Get ad by ID (placeholder).
+ /// VI: Lấy quảng cáo theo ID (placeholder).
+ ///
+ [HttpGet("{id}")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task GetAdById(Guid id)
+ {
+ // TODO: Implement GetAdByIdQuery
+ return Ok(new { id, message = "Ad details" });
+ }
+
+ ///
+ /// EN: Submit ad for review.
+ /// VI: Gửi quảng cáo để duyệt.
+ ///
+ [HttpPost("{id}/submit")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task SubmitAdForReview(Guid id)
+ {
+ _logger.LogInformation("Submitting ad {AdId} for review", id);
+ // TODO: Implement SubmitAdForReviewCommand
+ return NoContent();
+ }
+}
diff --git a/services/ads-manager-service-net/src/AdsManagerService.API/Controllers/CampaignsController.cs b/services/ads-manager-service-net/src/AdsManagerService.API/Controllers/CampaignsController.cs
new file mode 100644
index 00000000..f1842ccf
--- /dev/null
+++ b/services/ads-manager-service-net/src/AdsManagerService.API/Controllers/CampaignsController.cs
@@ -0,0 +1,124 @@
+using AdsManagerService.API.Application.Commands;
+using AdsManagerService.API.Application.Queries;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+
+namespace AdsManagerService.API.Controllers;
+
+///
+/// EN: API Controller for managing advertising campaigns.
+/// VI: API Controller quản lý chiến dịch quảng cáo.
+///
+[ApiController]
+[Route("api/v1/ads-manager/campaigns")]
+[Produces("application/json")]
+public class CampaignsController : ControllerBase
+{
+ private readonly IMediator _mediator;
+ private readonly ILogger _logger;
+
+ public CampaignsController(IMediator mediator, ILogger logger)
+ {
+ _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ ///
+ /// EN: Create a new campaign.
+ /// VI: Tạo chiến dịch mới.
+ ///
+ [HttpPost]
+ [ProducesResponseType(StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ public async Task> CreateCampaign([FromBody] CreateCampaignCommand command)
+ {
+ _logger.LogInformation("Creating campaign for advertiser {AdvertiserId}", command.AdvertiserId);
+
+ var campaignId = await _mediator.Send(command);
+
+ return CreatedAtAction(nameof(GetCampaignById), new { id = campaignId }, campaignId);
+ }
+
+ ///
+ /// EN: Get campaign by ID.
+ /// VI: Lấy chiến dịch theo ID.
+ ///
+ [HttpGet("{id}")]
+ [ProducesResponseType(typeof(CampaignDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task> GetCampaignById(Guid id)
+ {
+ var campaign = await _mediator.Send(new GetCampaignByIdQuery { CampaignId = id });
+
+ if (campaign == null)
+ return NotFound();
+
+ return Ok(campaign);
+ }
+
+ ///
+ /// EN: Update campaign information.
+ /// VI: Cập nhật thông tin chiến dịch.
+ ///
+ [HttpPut("{id}")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task UpdateCampaign(Guid id, [FromBody] UpdateCampaignRequest request)
+ {
+ var command = new UpdateCampaignCommand
+ {
+ CampaignId = id,
+ Name = request.Name,
+ Description = request.Description
+ };
+
+ var result = await _mediator.Send(command);
+
+ if (!result)
+ return NotFound();
+
+ return NoContent();
+ }
+
+ ///
+ /// EN: Activate a campaign to start serving ads.
+ /// VI: Kích hoạt chiến dịch để bắt đầu chạy quảng cáo.
+ ///
+ [HttpPost("{id}/activate")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task ActivateCampaign(Guid id)
+ {
+ _logger.LogInformation("Activating campaign {CampaignId}", id);
+
+ var result = await _mediator.Send(new ActivateCampaignCommand { CampaignId = id });
+
+ if (!result)
+ return NotFound();
+
+ return NoContent();
+ }
+
+ ///
+ /// EN: Pause a campaign.
+ /// VI: Tạm dừng chiến dịch.
+ ///
+ [HttpPost("{id}/pause")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task PauseCampaign(Guid id)
+ {
+ // TODO: Implement PauseCampaignCommand
+ return NoContent();
+ }
+}
+
+///
+/// EN: Request model for updating campaign.
+/// VI: Request model cập nhật chiến dịch.
+///
+public record UpdateCampaignRequest
+{
+ public string Name { get; init; } = null!;
+ public string? Description { get; init; }
+}
diff --git a/services/ads-manager-service-net/src/AdsManagerService.API/Controllers/SamplesController.cs b/services/ads-manager-service-net/src/AdsManagerService.API/Controllers/SamplesController.cs
deleted file mode 100644
index 538f22c7..00000000
--- a/services/ads-manager-service-net/src/AdsManagerService.API/Controllers/SamplesController.cs
+++ /dev/null
@@ -1,200 +0,0 @@
-using Asp.Versioning;
-using MediatR;
-using Microsoft.AspNetCore.Mvc;
-using AdsManagerService.API.Application.Commands;
-using AdsManagerService.API.Application.Queries;
-
-namespace AdsManagerService.API.Controllers;
-
-///
-/// EN: Controller for Sample CRUD operations using CQRS pattern.
-/// VI: Controller cho các thao tác CRUD Sample sử dụng pattern CQRS.
-///
-[ApiController]
-[ApiVersion("1.0")]
-[Route("api/v{version:apiVersion}/[controller]")]
-[Produces("application/json")]
-public class SamplesController : ControllerBase
-{
- private readonly IMediator _mediator;
- private readonly ILogger _logger;
-
- public SamplesController(IMediator mediator, ILogger logger)
- {
- _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
- _logger = logger ?? throw new ArgumentNullException(nameof(logger));
- }
-
- ///
- /// EN: Get all samples.
- /// VI: Lấy tất cả samples.
- ///
- /// EN: List of samples / VI: Danh sách samples
- [HttpGet]
- [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)]
- public async Task GetSamples()
- {
- var samples = await _mediator.Send(new GetSamplesQuery());
- return Ok(new { success = true, data = samples });
- }
-
- ///
- /// EN: Get a sample by ID.
- /// VI: Lấy một sample theo ID.
- ///
- /// EN: Sample ID / VI: ID sample
- /// EN: Sample details / VI: Chi tiết sample
- [HttpGet("{id:guid}")]
- [ProducesResponseType(typeof(SampleViewModel), StatusCodes.Status200OK)]
- [ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task GetSample(Guid id)
- {
- var sample = await _mediator.Send(new GetSampleQuery(id));
-
- if (sample is null)
- {
- return NotFound(new
- {
- success = false,
- error = new
- {
- code = "SAMPLE_NOT_FOUND",
- message = $"Sample with ID {id} not found / Sample với ID {id} không tìm thấy"
- }
- });
- }
-
- return Ok(new { success = true, data = sample });
- }
-
- ///
- /// EN: Create a new sample.
- /// VI: Tạo một sample mới.
- ///
- /// EN: Create request / VI: Request tạo
- /// EN: Created sample ID / VI: ID sample đã tạo
- [HttpPost]
- [ProducesResponseType(typeof(CreateSampleCommandResult), StatusCodes.Status201Created)]
- [ProducesResponseType(StatusCodes.Status400BadRequest)]
- public async Task CreateSample([FromBody] CreateSampleRequest request)
- {
- var command = new CreateSampleCommand(request.Name, request.Description);
- var result = await _mediator.Send(command);
-
- return CreatedAtAction(
- nameof(GetSample),
- new { id = result.Id },
- new { success = true, data = result });
- }
-
- ///
- /// EN: Update an existing sample.
- /// VI: Cập nhật một sample đã tồn tại.
- ///
- /// EN: Sample ID / VI: ID sample
- /// EN: Update request / VI: Request cập nhật
- /// EN: Success status / VI: Trạng thái thành công
- [HttpPut("{id:guid}")]
- [ProducesResponseType(StatusCodes.Status200OK)]
- [ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task UpdateSample(Guid id, [FromBody] UpdateSampleRequest request)
- {
- var command = new UpdateSampleCommand(id, request.Name, request.Description);
- var result = await _mediator.Send(command);
-
- if (!result)
- {
- return NotFound(new
- {
- success = false,
- error = new
- {
- code = "SAMPLE_NOT_FOUND",
- message = $"Sample with ID {id} not found / Sample với ID {id} không tìm thấy"
- }
- });
- }
-
- return Ok(new { success = true, message = "Sample updated successfully / Sample đã cập nhật thành công" });
- }
-
- ///
- /// EN: Delete a sample.
- /// VI: Xóa một sample.
- ///
- /// EN: Sample ID / VI: ID sample
- /// EN: Success status / VI: Trạng thái thành công
- [HttpDelete("{id:guid}")]
- [ProducesResponseType(StatusCodes.Status204NoContent)]
- [ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task DeleteSample(Guid id)
- {
- var command = new DeleteSampleCommand(id);
- var result = await _mediator.Send(command);
-
- if (!result)
- {
- return NotFound(new
- {
- success = false,
- error = new
- {
- code = "SAMPLE_NOT_FOUND",
- message = $"Sample with ID {id} not found / Sample với ID {id} không tìm thấy"
- }
- });
- }
-
- return NoContent();
- }
-
- ///
- /// EN: Change sample status.
- /// VI: Thay đổi trạng thái sample.
- ///
- /// EN: Sample ID / VI: ID sample
- /// EN: Status change request / VI: Request thay đổi trạng thái
- /// EN: Success status / VI: Trạng thái thành công
- [HttpPatch("{id:guid}/status")]
- [ProducesResponseType(StatusCodes.Status200OK)]
- [ProducesResponseType(StatusCodes.Status400BadRequest)]
- [ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task ChangeSampleStatus(Guid id, [FromBody] ChangeStatusRequest request)
- {
- var command = new ChangeSampleStatusCommand(id, request.Status);
- var result = await _mediator.Send(command);
-
- if (!result)
- {
- return BadRequest(new
- {
- success = false,
- error = new
- {
- code = "STATUS_CHANGE_FAILED",
- message = "Failed to change sample status / Thay đổi trạng thái sample thất bại"
- }
- });
- }
-
- return Ok(new { success = true, message = "Sample status changed successfully / Trạng thái sample đã thay đổi thành công" });
- }
-}
-
-///
-/// EN: Request model for creating a sample.
-/// VI: Model request để tạo sample.
-///
-public record CreateSampleRequest(string Name, string? Description);
-
-///
-/// EN: Request model for updating a sample.
-/// VI: Model request để cập nhật sample.
-///
-public record UpdateSampleRequest(string Name, string? Description);
-
-///
-/// EN: Request model for changing sample status.
-/// VI: Model request để thay đổi trạng thái sample.
-///
-public record ChangeStatusRequest(string Status);
diff --git a/services/ads-manager-service-net/src/AdsManagerService.Domain/AggregatesModel/AdAggregate/Ad.cs b/services/ads-manager-service-net/src/AdsManagerService.Domain/AggregatesModel/AdAggregate/Ad.cs
new file mode 100644
index 00000000..a02c3dc8
--- /dev/null
+++ b/services/ads-manager-service-net/src/AdsManagerService.Domain/AggregatesModel/AdAggregate/Ad.cs
@@ -0,0 +1,186 @@
+using AdsManagerService.Domain.Exceptions;
+using AdsManagerService.Domain.SeedWork;
+
+#pragma warning disable CS0649 // Field is never assigned to (EF Core managed)
+
+namespace AdsManagerService.Domain.AggregatesModel.AdAggregate;
+
+///
+/// EN: Ad aggregate root - the creative and content for an advertisement.
+/// VI: Ad aggregate root - creative và nội dung cho một quảng cáo.
+///
+public class Ad : Entity, IAggregateRoot
+{
+ private string _name = null!;
+ private Guid _adSetId;
+ private AdFormat _format = null!;
+ private AdStatus _status = null!;
+ private AdReviewStatus _reviewStatus = null!;
+ private string? _headline;
+ private string? _primaryText;
+ private string? _description;
+ private string? _callToAction;
+ private string? _destinationUrl;
+ private string? _creativeUrl;
+ private DateTime _createdAt;
+ private DateTime? _updatedAt;
+
+ public string Name => _name;
+ public Guid AdSetId => _adSetId;
+ public AdFormat Format => _format;
+ public int FormatId { get; private set; }
+ public AdStatus Status => _status;
+ public int StatusId { get; private set; }
+ public AdReviewStatus ReviewStatus => _reviewStatus;
+ public int ReviewStatusId { get; private set; }
+ public string? Headline => _headline;
+ public string? PrimaryText => _primaryText;
+ public string? Description => _description;
+ public string? CallToAction => _callToAction;
+ public string? DestinationUrl => _destinationUrl;
+ public string? CreativeUrl => _creativeUrl;
+ public DateTime CreatedAt => _createdAt;
+ public DateTime? UpdatedAt => _updatedAt;
+
+ protected Ad() { }
+
+ public Ad(
+ Guid adSetId,
+ string name,
+ AdFormat format,
+ string? headline = null,
+ string? primaryText = null,
+ string? callToAction = null,
+ string? destinationUrl = null,
+ string? creativeUrl = null) : this()
+ {
+ if (string.IsNullOrWhiteSpace(name))
+ throw new AdsDomainException("Ad name cannot be empty");
+
+ Id = Guid.NewGuid();
+ _adSetId = adSetId;
+ _name = name;
+ _format = format;
+ FormatId = format.Id;
+ _headline = headline;
+ _primaryText = primaryText;
+ _callToAction = callToAction;
+ _destinationUrl = destinationUrl;
+ _creativeUrl = creativeUrl;
+ _status = AdStatus.Draft;
+ StatusId = AdStatus.Draft.Id;
+ _reviewStatus = AdReviewStatus.NotSubmitted;
+ ReviewStatusId = AdReviewStatus.NotSubmitted.Id;
+ _createdAt = DateTime.UtcNow;
+ }
+
+ public void Update(string name, string? headline, string? primaryText, string? callToAction, string? destinationUrl)
+ {
+ if (string.IsNullOrWhiteSpace(name))
+ throw new AdsDomainException("Ad name cannot be empty");
+
+ _name = name;
+ _headline = headline;
+ _primaryText = primaryText;
+ _callToAction = callToAction;
+ _destinationUrl = destinationUrl;
+ _updatedAt = DateTime.UtcNow;
+
+ // Reset review if content changed
+ if (_reviewStatus == AdReviewStatus.Approved)
+ {
+ _reviewStatus = AdReviewStatus.PendingReview;
+ ReviewStatusId = AdReviewStatus.PendingReview.Id;
+ }
+ }
+
+ public void SetCreative(string creativeUrl)
+ {
+ _creativeUrl = creativeUrl;
+ _updatedAt = DateTime.UtcNow;
+ }
+
+ public void SubmitForReview()
+ {
+ if (_reviewStatus != AdReviewStatus.NotSubmitted && _reviewStatus != AdReviewStatus.Rejected)
+ throw new AdsDomainException("Ad is already submitted or approved");
+
+ _reviewStatus = AdReviewStatus.PendingReview;
+ ReviewStatusId = AdReviewStatus.PendingReview.Id;
+ _updatedAt = DateTime.UtcNow;
+ }
+
+ public void Approve()
+ {
+ if (_reviewStatus != AdReviewStatus.PendingReview)
+ throw new AdsDomainException("Only pending ads can be approved");
+
+ _reviewStatus = AdReviewStatus.Approved;
+ ReviewStatusId = AdReviewStatus.Approved.Id;
+ _updatedAt = DateTime.UtcNow;
+ }
+
+ public void Reject(string reason)
+ {
+ if (_reviewStatus != AdReviewStatus.PendingReview)
+ throw new AdsDomainException("Only pending ads can be rejected");
+
+ _reviewStatus = AdReviewStatus.Rejected;
+ ReviewStatusId = AdReviewStatus.Rejected.Id;
+ _updatedAt = DateTime.UtcNow;
+ }
+
+ public void Activate()
+ {
+ if (_reviewStatus != AdReviewStatus.Approved)
+ throw new AdsDomainException("Only approved ads can be activated");
+
+ _status = AdStatus.Active;
+ StatusId = AdStatus.Active.Id;
+ _updatedAt = DateTime.UtcNow;
+ }
+
+ public void Pause()
+ {
+ if (_status != AdStatus.Active)
+ throw new AdsDomainException("Only active ads can be paused");
+
+ _status = AdStatus.Paused;
+ StatusId = AdStatus.Paused.Id;
+ _updatedAt = DateTime.UtcNow;
+ }
+}
+
+public class AdFormat : Enumeration
+{
+ public static readonly AdFormat SingleImage = new(1, "single_image");
+ public static readonly AdFormat SingleVideo = new(2, "single_video");
+ public static readonly AdFormat Carousel = new(3, "carousel");
+ public static readonly AdFormat Collection = new(4, "collection");
+ public static readonly AdFormat Stories = new(5, "stories");
+
+ public AdFormat(int id, string name) : base(id, name) { }
+ public static IEnumerable List() => new[] { SingleImage, SingleVideo, Carousel, Collection, Stories };
+}
+
+public class AdStatus : Enumeration
+{
+ public static readonly AdStatus Draft = new(1, nameof(Draft).ToLowerInvariant());
+ public static readonly AdStatus Active = new(2, nameof(Active).ToLowerInvariant());
+ public static readonly AdStatus Paused = new(3, nameof(Paused).ToLowerInvariant());
+ public static readonly AdStatus Archived = new(4, nameof(Archived).ToLowerInvariant());
+
+ public AdStatus(int id, string name) : base(id, name) { }
+ public static IEnumerable List() => new[] { Draft, Active, Paused, Archived };
+}
+
+public class AdReviewStatus : Enumeration
+{
+ public static readonly AdReviewStatus NotSubmitted = new(1, "not_submitted");
+ public static readonly AdReviewStatus PendingReview = new(2, "pending_review");
+ public static readonly AdReviewStatus Approved = new(3, nameof(Approved).ToLowerInvariant());
+ public static readonly AdReviewStatus Rejected = new(4, nameof(Rejected).ToLowerInvariant());
+
+ public AdReviewStatus(int id, string name) : base(id, name) { }
+ public static IEnumerable List() => new[] { NotSubmitted, PendingReview, Approved, Rejected };
+}
diff --git a/services/ads-manager-service-net/src/AdsManagerService.Domain/AggregatesModel/AdAggregate/IAdRepository.cs b/services/ads-manager-service-net/src/AdsManagerService.Domain/AggregatesModel/AdAggregate/IAdRepository.cs
new file mode 100644
index 00000000..1e0d141c
--- /dev/null
+++ b/services/ads-manager-service-net/src/AdsManagerService.Domain/AggregatesModel/AdAggregate/IAdRepository.cs
@@ -0,0 +1,14 @@
+using AdsManagerService.Domain.SeedWork;
+
+namespace AdsManagerService.Domain.AggregatesModel.AdAggregate;
+
+///
+/// EN: Repository interface for Ad aggregate.
+/// VI: Interface repository cho Ad aggregate.
+///
+public interface IAdRepository : IRepository
+{
+ Ad Add(Ad ad);
+ void Update(Ad ad);
+ Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
+}
diff --git a/services/ads-manager-service-net/src/AdsManagerService.Domain/AggregatesModel/AdSetAggregate/AdSet.cs b/services/ads-manager-service-net/src/AdsManagerService.Domain/AggregatesModel/AdSetAggregate/AdSet.cs
new file mode 100644
index 00000000..1583417c
--- /dev/null
+++ b/services/ads-manager-service-net/src/AdsManagerService.Domain/AggregatesModel/AdSetAggregate/AdSet.cs
@@ -0,0 +1,150 @@
+using AdsManagerService.Domain.Exceptions;
+using AdsManagerService.Domain.SeedWork;
+
+#pragma warning disable CS0649 // Field is never assigned to (EF Core managed)
+
+namespace AdsManagerService.Domain.AggregatesModel.AdSetAggregate;
+
+///
+/// EN: Ad Set aggregate root - targeting and budget settings for a group of ads.
+/// VI: Ad Set aggregate root - cài đặt targeting và ngân sách cho một nhóm quảng cáo.
+///
+public class AdSet : Entity, IAggregateRoot
+{
+ private string _name = null!;
+ private Guid _campaignId;
+ private AdSetStatus _status = null!;
+ private Targeting _targeting = null!;
+ private BidStrategy _bidStrategy = null!;
+ private decimal _dailyBudget;
+ private DateTime? _startDate;
+ private DateTime? _endDate;
+ private DateTime _createdAt;
+ private DateTime? _updatedAt;
+
+ ///
+ /// EN: Ad Set name.
+ /// VI: Tên Ad Set.
+ ///
+ public string Name => _name;
+
+ ///
+ /// EN: Parent campaign ID.
+ /// VI: ID chiến dịch cha.
+ ///
+ public Guid CampaignId => _campaignId;
+
+ ///
+ /// EN: Current status.
+ /// VI: Trạng thái hiện tại.
+ ///
+ public AdSetStatus Status => _status;
+ public int StatusId { get; private set; }
+
+ ///
+ /// EN: Targeting settings.
+ /// VI: Cài đặt targeting.
+ ///
+ public Targeting Targeting => _targeting;
+
+ ///
+ /// EN: Bid strategy.
+ /// VI: Chiến lược giá thầu.
+ ///
+ public BidStrategy BidStrategy => _bidStrategy;
+
+ ///
+ /// EN: Daily budget.
+ /// VI: Ngân sách hàng ngày.
+ ///
+ public decimal DailyBudget => _dailyBudget;
+
+ public DateTime? StartDate => _startDate;
+ public DateTime? EndDate => _endDate;
+ public DateTime CreatedAt => _createdAt;
+ public DateTime? UpdatedAt => _updatedAt;
+
+ protected AdSet() { }
+
+ public AdSet(
+ Guid campaignId,
+ string name,
+ Targeting targeting,
+ BidStrategy bidStrategy,
+ decimal dailyBudget) : this()
+ {
+ if (string.IsNullOrWhiteSpace(name))
+ throw new AdsDomainException("Ad Set name cannot be empty");
+ if (dailyBudget <= 0)
+ throw new AdsDomainException("Daily budget must be greater than zero");
+
+ Id = Guid.NewGuid();
+ _campaignId = campaignId;
+ _name = name;
+ _targeting = targeting;
+ _bidStrategy = bidStrategy;
+ _dailyBudget = dailyBudget;
+ _status = AdSetStatus.Draft;
+ StatusId = AdSetStatus.Draft.Id;
+ _createdAt = DateTime.UtcNow;
+ }
+
+ public void Update(string name, decimal dailyBudget)
+ {
+ if (string.IsNullOrWhiteSpace(name))
+ throw new AdsDomainException("Ad Set name cannot be empty");
+
+ _name = name;
+ _dailyBudget = dailyBudget;
+ _updatedAt = DateTime.UtcNow;
+ }
+
+ public void SetTargeting(Targeting targeting)
+ {
+ _targeting = targeting ?? throw new AdsDomainException("Targeting cannot be null");
+ _updatedAt = DateTime.UtcNow;
+ }
+
+ public void SetBidStrategy(BidStrategy bidStrategy)
+ {
+ _bidStrategy = bidStrategy ?? throw new AdsDomainException("Bid strategy cannot be null");
+ _updatedAt = DateTime.UtcNow;
+ }
+
+ public void Activate()
+ {
+ if (_status != AdSetStatus.Draft && _status != AdSetStatus.Paused)
+ throw new AdsDomainException("Only draft or paused ad sets can be activated");
+
+ _status = AdSetStatus.Active;
+ StatusId = AdSetStatus.Active.Id;
+ _updatedAt = DateTime.UtcNow;
+ }
+
+ public void Pause()
+ {
+ if (_status != AdSetStatus.Active)
+ throw new AdsDomainException("Only active ad sets can be paused");
+
+ _status = AdSetStatus.Paused;
+ StatusId = AdSetStatus.Paused.Id;
+ _updatedAt = DateTime.UtcNow;
+ }
+}
+
+///
+/// EN: Ad Set status enumeration.
+/// VI: Enum trạng thái Ad Set.
+///
+public class AdSetStatus : Enumeration
+{
+ public static readonly AdSetStatus Draft = new(1, nameof(Draft).ToLowerInvariant());
+ public static readonly AdSetStatus Active = new(2, nameof(Active).ToLowerInvariant());
+ public static readonly AdSetStatus Paused = new(3, nameof(Paused).ToLowerInvariant());
+ public static readonly AdSetStatus Archived = new(4, nameof(Archived).ToLowerInvariant());
+
+ public AdSetStatus(int id, string name) : base(id, name) { }
+
+ public static IEnumerable List() =>
+ new[] { Draft, Active, Paused, Archived };
+}
diff --git a/services/ads-manager-service-net/src/AdsManagerService.Domain/AggregatesModel/AdSetAggregate/BidStrategy.cs b/services/ads-manager-service-net/src/AdsManagerService.Domain/AggregatesModel/AdSetAggregate/BidStrategy.cs
new file mode 100644
index 00000000..61e194f2
--- /dev/null
+++ b/services/ads-manager-service-net/src/AdsManagerService.Domain/AggregatesModel/AdSetAggregate/BidStrategy.cs
@@ -0,0 +1,90 @@
+using AdsManagerService.Domain.SeedWork;
+
+namespace AdsManagerService.Domain.AggregatesModel.AdSetAggregate;
+
+///
+/// EN: Bid strategy for an Ad Set.
+/// VI: Chiến lược giá thầu cho Ad Set.
+///
+public class BidStrategy : ValueObject
+{
+ ///
+ /// EN: Bid type (CPC, CPM, OCPM, Target Cost).
+ /// VI: Loại giá thầu (CPC, CPM, OCPM, Target Cost).
+ ///
+ public BidType Type { get; private set; }
+
+ ///
+ /// EN: Bid amount (for manual bidding).
+ /// VI: Số tiền giá thầu (cho trường hợp thầu thủ công).
+ ///
+ public decimal? BidAmount { get; private set; }
+
+ ///
+ /// EN: Target cost per result (for OCPM/Target Cost).
+ /// VI: Chi phí mục tiêu mỗi kết quả (cho OCPM/Target Cost).
+ ///
+ public decimal? TargetCost { get; private set; }
+
+ protected BidStrategy() { }
+
+ public BidStrategy(BidType type, decimal? bidAmount = null, decimal? targetCost = null)
+ {
+ Type = type;
+ BidAmount = bidAmount;
+ TargetCost = targetCost;
+ }
+
+ ///
+ /// EN: Create CPC (Cost Per Click) strategy.
+ /// VI: Tạo chiến lược CPC (Chi phí mỗi Click).
+ ///
+ public static BidStrategy CPC(decimal bidAmount) => new(BidType.CPC, bidAmount);
+
+ ///
+ /// EN: Create CPM (Cost Per Mille) strategy.
+ /// VI: Tạo chiến lược CPM (Chi phí mỗi 1000 hiển thị).
+ ///
+ public static BidStrategy CPM(decimal bidAmount) => new(BidType.CPM, bidAmount);
+
+ ///
+ /// EN: Create OCPM (Optimized CPM) strategy.
+ /// VI: Tạo chiến lược OCPM (CPM tối ưu).
+ ///
+ public static BidStrategy OCPM(decimal targetCost) => new(BidType.OCPM, null, targetCost);
+
+ ///
+ /// EN: Create automatic bidding strategy.
+ /// VI: Tạo chiến lược thầu tự động.
+ ///
+ public static BidStrategy Automatic() => new(BidType.Automatic);
+
+ protected override IEnumerable