feat: Add functional tests for OrderService and update InventoryService command and idempotency logic.
This commit is contained in:
528
.agent/skills/development-lifecycle/SKILL.md
Normal file
528
.agent/skills/development-lifecycle/SKILL.md
Normal file
@@ -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<br/>Write Features] --> B[② BUILD<br/>Compile & Package]
|
||||
B --> C[③ TEST<br/>Run Tests]
|
||||
C --> D{Pass?}
|
||||
D -->|No| E[④ FIX<br/>Debug & Resolve]
|
||||
E --> B
|
||||
D -->|Yes| F[⑤ DEPLOY<br/>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<CreateOrderCommand, OrderResult>
|
||||
{
|
||||
private readonly IOrderRepository _repository;
|
||||
|
||||
public async Task<OrderResult> 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<IActionResult> 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<IOrderRepository>();
|
||||
var unitOfWork = Substitute.For<IUnitOfWork>();
|
||||
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<Order>(), Arg.Any<CancellationToken>());
|
||||
await unitOfWork.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
|
||||
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
|
||||
<!-- MyService.API.csproj -->
|
||||
<PropertyGroup>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
```
|
||||
|
||||
### 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/)
|
||||
298
.agent/skills/development-lifecycle/references/build-errors.md
Normal file
298
.agent/skills/development-lifecycle/references/build-errors.md
Normal file
@@ -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 <PackageName>
|
||||
|
||||
# 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
|
||||
<!-- Directory.Build.props or .csproj -->
|
||||
<ItemGroup>
|
||||
<!-- Force specific version across all projects -->
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.2" />
|
||||
</ItemGroup>
|
||||
```
|
||||
|
||||
```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<MyContext>(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)
|
||||
@@ -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 <pid>
|
||||
|
||||
# Analyze dump
|
||||
dotnet-dump analyze <dump-file>
|
||||
|
||||
# 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 <pid> --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 <pid>
|
||||
|
||||
# Custom metrics
|
||||
dotnet-counters monitor \
|
||||
-p <pid> \
|
||||
--counters System.Runtime,Microsoft.AspNetCore.Hosting
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database Debugging
|
||||
|
||||
### EF Core Query Logging
|
||||
|
||||
```csharp
|
||||
// Enable sensitive data logging (Development only!)
|
||||
builder.Services.AddDbContext<MyContext>(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 <token>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 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 <pid> -o baseline.dump
|
||||
|
||||
# 2. Exercise application
|
||||
# ... use the app normally ...
|
||||
|
||||
# 3. Take second dump
|
||||
dotnet-dump collect -p <pid> -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<OrderBenchmarks>();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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 <pid>
|
||||
|
||||
# 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/)
|
||||
354
.agent/skills/development-lifecycle/references/test-failures.md
Normal file
354
.agent/skills/development-lifecycle/references/test-failures.md
Normal file
@@ -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<IOrderRepository>();
|
||||
var order = await repository.GetByIdAsync(orderId); // returns null!
|
||||
order.Status; // NullReferenceException
|
||||
|
||||
// ✅ GOOD: Configure mock to return data
|
||||
var repository = Substitute.For<IOrderRepository>();
|
||||
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<IOrderRepository>();
|
||||
var unitOfWork = Substitute.For<IUnitOfWork>();
|
||||
|
||||
// Important: Link repository to unitOfWork
|
||||
repository.UnitOfWork.Returns(unitOfWork);
|
||||
|
||||
// Configure SaveChangesAsync behavior
|
||||
unitOfWork.SaveChangesAsync(Arg.Any<CancellationToken>())
|
||||
.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<CancellationToken>());
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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<int> { 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<OrderContext> _options;
|
||||
private readonly OrderContext _context;
|
||||
|
||||
public OrderRepositoryTests()
|
||||
{
|
||||
_options = new DbContextOptionsBuilder<OrderContext>()
|
||||
.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<Program>
|
||||
{
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// Remove real auth
|
||||
services.RemoveAll<IAuthenticationService>();
|
||||
|
||||
// Add test auth
|
||||
services.AddAuthentication("Test")
|
||||
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
|
||||
"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<Program>()
|
||||
.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<ITimeProvider>();
|
||||
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)
|
||||
120
.agent/skills/development-lifecycle/scripts/check-env.sh
Executable file
120
.agent/skills/development-lifecycle/scripts/check-env.sh
Executable file
@@ -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
|
||||
85
.agent/skills/development-lifecycle/scripts/debug-build.sh
Executable file
85
.agent/skills/development-lifecycle/scripts/debug-build.sh
Executable file
@@ -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 <service-name>"
|
||||
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
|
||||
90
.agent/skills/development-lifecycle/scripts/health-check.sh
Executable file
90
.agent/skills/development-lifecycle/scripts/health-check.sh
Executable file
@@ -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 <service-name>"
|
||||
echo " • Restart service: docker-compose restart <service-name>"
|
||||
exit 1
|
||||
fi
|
||||
58
.agent/skills/development-lifecycle/scripts/quick-build.sh
Executable file
58
.agent/skills/development-lifecycle/scripts/quick-build.sh
Executable file
@@ -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 <service-name>"
|
||||
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}"
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -14,6 +14,10 @@
|
||||
<!-- EN: FluentValidation for request validation / VI: FluentValidation cho validation request -->
|
||||
<PackageReference Include="FluentValidation" Version="11.11.0" />
|
||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
|
||||
<!-- EN: Swagger/OpenAPI / VI: Swagger/OpenAPI -->
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using MediatR;
|
||||
|
||||
namespace AdsManagerService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to activate a campaign.
|
||||
/// VI: Command kích hoạt chiến dịch.
|
||||
/// </summary>
|
||||
public record ActivateCampaignCommand : IRequest<bool>
|
||||
{
|
||||
public Guid CampaignId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using AdsManagerService.Domain.AggregatesModel.CampaignAggregate;
|
||||
using MediatR;
|
||||
|
||||
namespace AdsManagerService.API.Application.Commands;
|
||||
|
||||
public class ActivateCampaignCommandHandler : IRequestHandler<ActivateCampaignCommand, bool>
|
||||
{
|
||||
private readonly ICampaignRepository _campaignRepository;
|
||||
|
||||
public ActivateCampaignCommandHandler(ICampaignRepository campaignRepository)
|
||||
{
|
||||
_campaignRepository = campaignRepository ?? throw new ArgumentNullException(nameof(campaignRepository));
|
||||
}
|
||||
|
||||
public async Task<bool> 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);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
using MediatR;
|
||||
|
||||
namespace AdsManagerService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to change status of a Sample.
|
||||
/// VI: Command để thay đổi trạng thái của Sample.
|
||||
/// </summary>
|
||||
/// <param name="SampleId">EN: Sample ID / VI: ID sample</param>
|
||||
/// <param name="NewStatus">EN: New status (activate, complete, cancel) / VI: Trạng thái mới (activate, complete, cancel)</param>
|
||||
public record ChangeSampleStatusCommand(
|
||||
Guid SampleId,
|
||||
string NewStatus
|
||||
) : IRequest<bool>;
|
||||
@@ -1,70 +0,0 @@
|
||||
using MediatR;
|
||||
using AdsManagerService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace AdsManagerService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for ChangeSampleStatusCommand.
|
||||
/// VI: Handler cho ChangeSampleStatusCommand.
|
||||
/// </summary>
|
||||
public class ChangeSampleStatusCommandHandler : IRequestHandler<ChangeSampleStatusCommand, bool>
|
||||
{
|
||||
private readonly ISampleRepository _sampleRepository;
|
||||
private readonly ILogger<ChangeSampleStatusCommandHandler> _logger;
|
||||
|
||||
public ChangeSampleStatusCommandHandler(
|
||||
ISampleRepository sampleRepository,
|
||||
ILogger<ChangeSampleStatusCommandHandler> logger)
|
||||
{
|
||||
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<bool> Handle(
|
||||
ChangeSampleStatusCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Changing status of sample {SampleId} to {NewStatus} / Thay đổi trạng thái sample {SampleId} thành {NewStatus}",
|
||||
request.SampleId, request.NewStatus);
|
||||
|
||||
// EN: Get existing sample / VI: Lấy sample đã tồn tại
|
||||
var sample = await _sampleRepository.GetAsync(request.SampleId);
|
||||
|
||||
if (sample is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Sample {SampleId} not found / Sample {SampleId} không tìm thấy",
|
||||
request.SampleId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// EN: Change status based on action / VI: Thay đổi trạng thái dựa trên action
|
||||
switch (request.NewStatus.ToLowerInvariant())
|
||||
{
|
||||
case "activate":
|
||||
sample.Activate();
|
||||
break;
|
||||
case "complete":
|
||||
sample.Complete();
|
||||
break;
|
||||
case "cancel":
|
||||
sample.Cancel();
|
||||
break;
|
||||
default:
|
||||
_logger.LogWarning(
|
||||
"Invalid status action: {NewStatus} / Action trạng thái không hợp lệ: {NewStatus}",
|
||||
request.NewStatus);
|
||||
return false;
|
||||
}
|
||||
|
||||
// EN: Save changes / VI: Lưu thay đổi
|
||||
await _sampleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Sample {SampleId} status changed to {NewStatus} / Trạng thái sample {SampleId} đã đổi thành {NewStatus}",
|
||||
request.SampleId, request.NewStatus);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using MediatR;
|
||||
|
||||
namespace AdsManagerService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to create a new ad.
|
||||
/// VI: Command tạo quảng cáo mới.
|
||||
/// </summary>
|
||||
public record CreateAdCommand : IRequest<Guid>
|
||||
{
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using AdsManagerService.Domain.AggregatesModel.AdAggregate;
|
||||
using MediatR;
|
||||
|
||||
namespace AdsManagerService.API.Application.Commands;
|
||||
|
||||
public class CreateAdCommandHandler : IRequestHandler<CreateAdCommand, Guid>
|
||||
{
|
||||
private readonly IAdRepository _adRepository;
|
||||
|
||||
public CreateAdCommandHandler(IAdRepository adRepository)
|
||||
{
|
||||
_adRepository = adRepository ?? throw new ArgumentNullException(nameof(adRepository));
|
||||
}
|
||||
|
||||
public async Task<Guid> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using MediatR;
|
||||
|
||||
namespace AdsManagerService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to create a new ad set.
|
||||
/// VI: Command tạo ad set mới.
|
||||
/// </summary>
|
||||
public record CreateAdSetCommand : IRequest<Guid>
|
||||
{
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using AdsManagerService.Domain.AggregatesModel.AdSetAggregate;
|
||||
using MediatR;
|
||||
|
||||
namespace AdsManagerService.API.Application.Commands;
|
||||
|
||||
public class CreateAdSetCommandHandler : IRequestHandler<CreateAdSetCommand, Guid>
|
||||
{
|
||||
private readonly IAdSetRepository _adSetRepository;
|
||||
|
||||
public CreateAdSetCommandHandler(IAdSetRepository adSetRepository)
|
||||
{
|
||||
_adSetRepository = adSetRepository ?? throw new ArgumentNullException(nameof(adSetRepository));
|
||||
}
|
||||
|
||||
public async Task<Guid> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using MediatR;
|
||||
|
||||
namespace AdsManagerService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to create a new campaign.
|
||||
/// VI: Command tạo chiến dịch mới.
|
||||
/// </summary>
|
||||
public record CreateCampaignCommand : IRequest<Guid>
|
||||
{
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using AdsManagerService.Domain.AggregatesModel.CampaignAggregate;
|
||||
using MediatR;
|
||||
|
||||
namespace AdsManagerService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for CreateCampaignCommand.
|
||||
/// VI: Handler cho CreateCampaignCommand.
|
||||
/// </summary>
|
||||
public class CreateCampaignCommandHandler : IRequestHandler<CreateCampaignCommand, Guid>
|
||||
{
|
||||
private readonly ICampaignRepository _campaignRepository;
|
||||
|
||||
public CreateCampaignCommandHandler(ICampaignRepository campaignRepository)
|
||||
{
|
||||
_campaignRepository = campaignRepository ?? throw new ArgumentNullException(nameof(campaignRepository));
|
||||
}
|
||||
|
||||
public async Task<Guid> 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;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
using MediatR;
|
||||
|
||||
namespace AdsManagerService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to create a new Sample.
|
||||
/// VI: Command để tạo một Sample mới.
|
||||
/// </summary>
|
||||
/// <param name="Name">EN: Sample name / VI: Tên sample</param>
|
||||
/// <param name="Description">EN: Optional description / VI: Mô tả tùy chọn</param>
|
||||
public record CreateSampleCommand(
|
||||
string Name,
|
||||
string? Description
|
||||
) : IRequest<CreateSampleCommandResult>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result of CreateSampleCommand.
|
||||
/// VI: Kết quả của CreateSampleCommand.
|
||||
/// </summary>
|
||||
/// <param name="Id">EN: Created sample ID / VI: ID sample đã tạo</param>
|
||||
public record CreateSampleCommandResult(Guid Id);
|
||||
@@ -1,46 +0,0 @@
|
||||
using MediatR;
|
||||
using AdsManagerService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace AdsManagerService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for CreateSampleCommand.
|
||||
/// VI: Handler cho CreateSampleCommand.
|
||||
/// </summary>
|
||||
public class CreateSampleCommandHandler : IRequestHandler<CreateSampleCommand, CreateSampleCommandResult>
|
||||
{
|
||||
private readonly ISampleRepository _sampleRepository;
|
||||
private readonly ILogger<CreateSampleCommandHandler> _logger;
|
||||
|
||||
public CreateSampleCommandHandler(
|
||||
ISampleRepository sampleRepository,
|
||||
ILogger<CreateSampleCommandHandler> logger)
|
||||
{
|
||||
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<CreateSampleCommandResult> Handle(
|
||||
CreateSampleCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Creating new sample with name: {Name} / Tạo sample mới với tên: {Name}",
|
||||
request.Name);
|
||||
|
||||
// EN: Create domain entity / VI: Tạo domain entity
|
||||
var sample = new Sample(request.Name, request.Description);
|
||||
|
||||
// EN: Add to repository / VI: Thêm vào repository
|
||||
_sampleRepository.Add(sample);
|
||||
|
||||
// EN: Save changes (dispatches domain events) / VI: Lưu thay đổi (dispatch domain events)
|
||||
await _sampleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Sample created successfully with ID: {SampleId} / Sample đã tạo thành công với ID: {SampleId}",
|
||||
sample.Id);
|
||||
|
||||
return new CreateSampleCommandResult(sample.Id);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
using MediatR;
|
||||
|
||||
namespace AdsManagerService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to delete a Sample.
|
||||
/// VI: Command để xóa một Sample.
|
||||
/// </summary>
|
||||
/// <param name="SampleId">EN: Sample ID to delete / VI: ID sample cần xóa</param>
|
||||
public record DeleteSampleCommand(Guid SampleId) : IRequest<bool>;
|
||||
@@ -1,54 +0,0 @@
|
||||
using MediatR;
|
||||
using AdsManagerService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace AdsManagerService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for DeleteSampleCommand.
|
||||
/// VI: Handler cho DeleteSampleCommand.
|
||||
/// </summary>
|
||||
public class DeleteSampleCommandHandler : IRequestHandler<DeleteSampleCommand, bool>
|
||||
{
|
||||
private readonly ISampleRepository _sampleRepository;
|
||||
private readonly ILogger<DeleteSampleCommandHandler> _logger;
|
||||
|
||||
public DeleteSampleCommandHandler(
|
||||
ISampleRepository sampleRepository,
|
||||
ILogger<DeleteSampleCommandHandler> logger)
|
||||
{
|
||||
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<bool> Handle(
|
||||
DeleteSampleCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Deleting sample {SampleId} / Xóa sample {SampleId}",
|
||||
request.SampleId);
|
||||
|
||||
// EN: Get existing sample / VI: Lấy sample đã tồn tại
|
||||
var sample = await _sampleRepository.GetAsync(request.SampleId);
|
||||
|
||||
if (sample is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Sample {SampleId} not found / Sample {SampleId} không tìm thấy",
|
||||
request.SampleId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// EN: Delete sample / VI: Xóa sample
|
||||
_sampleRepository.Delete(sample);
|
||||
|
||||
// EN: Save changes / VI: Lưu thay đổi
|
||||
await _sampleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Sample {SampleId} deleted successfully / Sample {SampleId} đã xóa thành công",
|
||||
request.SampleId);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using MediatR;
|
||||
|
||||
namespace AdsManagerService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to update campaign information.
|
||||
/// VI: Command cập nhật thông tin chiến dịch.
|
||||
/// </summary>
|
||||
public record UpdateCampaignCommand : IRequest<bool>
|
||||
{
|
||||
public Guid CampaignId { get; init; }
|
||||
public string Name { get; init; } = null!;
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using AdsManagerService.Domain.AggregatesModel.CampaignAggregate;
|
||||
using MediatR;
|
||||
|
||||
namespace AdsManagerService.API.Application.Commands;
|
||||
|
||||
public class UpdateCampaignCommandHandler : IRequestHandler<UpdateCampaignCommand, bool>
|
||||
{
|
||||
private readonly ICampaignRepository _campaignRepository;
|
||||
|
||||
public UpdateCampaignCommandHandler(ICampaignRepository campaignRepository)
|
||||
{
|
||||
_campaignRepository = campaignRepository ?? throw new ArgumentNullException(nameof(campaignRepository));
|
||||
}
|
||||
|
||||
public async Task<bool> 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);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
using MediatR;
|
||||
|
||||
namespace AdsManagerService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to update an existing Sample.
|
||||
/// VI: Command để cập nhật một Sample đã tồn tại.
|
||||
/// </summary>
|
||||
/// <param name="SampleId">EN: Sample ID to update / VI: ID sample cần cập nhật</param>
|
||||
/// <param name="Name">EN: New name / VI: Tên mới</param>
|
||||
/// <param name="Description">EN: New description / VI: Mô tả mới</param>
|
||||
public record UpdateSampleCommand(
|
||||
Guid SampleId,
|
||||
string Name,
|
||||
string? Description
|
||||
) : IRequest<bool>;
|
||||
@@ -1,54 +0,0 @@
|
||||
using MediatR;
|
||||
using AdsManagerService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace AdsManagerService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for UpdateSampleCommand.
|
||||
/// VI: Handler cho UpdateSampleCommand.
|
||||
/// </summary>
|
||||
public class UpdateSampleCommandHandler : IRequestHandler<UpdateSampleCommand, bool>
|
||||
{
|
||||
private readonly ISampleRepository _sampleRepository;
|
||||
private readonly ILogger<UpdateSampleCommandHandler> _logger;
|
||||
|
||||
public UpdateSampleCommandHandler(
|
||||
ISampleRepository sampleRepository,
|
||||
ILogger<UpdateSampleCommandHandler> logger)
|
||||
{
|
||||
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<bool> Handle(
|
||||
UpdateSampleCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Updating sample {SampleId} / Cập nhật sample {SampleId}",
|
||||
request.SampleId);
|
||||
|
||||
// EN: Get existing sample / VI: Lấy sample đã tồn tại
|
||||
var sample = await _sampleRepository.GetAsync(request.SampleId);
|
||||
|
||||
if (sample is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Sample {SampleId} not found / Sample {SampleId} không tìm thấy",
|
||||
request.SampleId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// EN: Update sample using domain method / VI: Cập nhật sample sử dụng domain method
|
||||
sample.Update(request.Name, request.Description);
|
||||
|
||||
// EN: Save changes / VI: Lưu thay đổi
|
||||
await _sampleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Sample {SampleId} updated successfully / Sample {SampleId} đã cập nhật thành công",
|
||||
request.SampleId);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using MediatR;
|
||||
|
||||
namespace AdsManagerService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get campaign by ID.
|
||||
/// VI: Query lấy chiến dịch theo ID.
|
||||
/// </summary>
|
||||
public record GetCampaignByIdQuery : IRequest<CampaignDto?>
|
||||
{
|
||||
public Guid CampaignId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Campaign DTO for API responses.
|
||||
/// VI: Campaign DTO cho API responses.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using AdsManagerService.Domain.AggregatesModel.CampaignAggregate;
|
||||
using MediatR;
|
||||
|
||||
namespace AdsManagerService.API.Application.Queries;
|
||||
|
||||
public class GetCampaignByIdQueryHandler : IRequestHandler<GetCampaignByIdQuery, CampaignDto?>
|
||||
{
|
||||
private readonly ICampaignRepository _campaignRepository;
|
||||
|
||||
public GetCampaignByIdQueryHandler(ICampaignRepository campaignRepository)
|
||||
{
|
||||
_campaignRepository = campaignRepository ?? throw new ArgumentNullException(nameof(campaignRepository));
|
||||
}
|
||||
|
||||
public async Task<CampaignDto?> 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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
using MediatR;
|
||||
|
||||
namespace AdsManagerService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get a Sample by ID.
|
||||
/// VI: Query để lấy một Sample theo ID.
|
||||
/// </summary>
|
||||
/// <param name="SampleId">EN: Sample ID / VI: ID sample</param>
|
||||
public record GetSampleQuery(Guid SampleId) : IRequest<SampleViewModel?>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Sample view model for API responses.
|
||||
/// VI: Sample view model cho API responses.
|
||||
/// </summary>
|
||||
public record SampleViewModel(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string? Description,
|
||||
string Status,
|
||||
DateTime CreatedAt,
|
||||
DateTime? UpdatedAt
|
||||
);
|
||||
@@ -1,39 +0,0 @@
|
||||
using MediatR;
|
||||
using AdsManagerService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace AdsManagerService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for GetSampleQuery.
|
||||
/// VI: Handler cho GetSampleQuery.
|
||||
/// </summary>
|
||||
public class GetSampleQueryHandler : IRequestHandler<GetSampleQuery, SampleViewModel?>
|
||||
{
|
||||
private readonly ISampleRepository _sampleRepository;
|
||||
|
||||
public GetSampleQueryHandler(ISampleRepository sampleRepository)
|
||||
{
|
||||
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
|
||||
}
|
||||
|
||||
public async Task<SampleViewModel?> Handle(
|
||||
GetSampleQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sample = await _sampleRepository.GetAsync(request.SampleId);
|
||||
|
||||
if (sample is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SampleViewModel(
|
||||
sample.Id,
|
||||
sample.Name,
|
||||
sample.Description,
|
||||
sample.Status.Name,
|
||||
sample.CreatedAt,
|
||||
sample.UpdatedAt
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
using MediatR;
|
||||
|
||||
namespace AdsManagerService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get all Samples.
|
||||
/// VI: Query để lấy tất cả Samples.
|
||||
/// </summary>
|
||||
public record GetSamplesQuery : IRequest<IEnumerable<SampleViewModel>>;
|
||||
@@ -1,34 +0,0 @@
|
||||
using MediatR;
|
||||
using AdsManagerService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace AdsManagerService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for GetSamplesQuery.
|
||||
/// VI: Handler cho GetSamplesQuery.
|
||||
/// </summary>
|
||||
public class GetSamplesQueryHandler : IRequestHandler<GetSamplesQuery, IEnumerable<SampleViewModel>>
|
||||
{
|
||||
private readonly ISampleRepository _sampleRepository;
|
||||
|
||||
public GetSamplesQueryHandler(ISampleRepository sampleRepository)
|
||||
{
|
||||
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<SampleViewModel>> Handle(
|
||||
GetSamplesQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var samples = await _sampleRepository.GetAllAsync();
|
||||
|
||||
return samples.Select(sample => new SampleViewModel(
|
||||
sample.Id,
|
||||
sample.Name,
|
||||
sample.Description,
|
||||
sample.Status.Name,
|
||||
sample.CreatedAt,
|
||||
sample.UpdatedAt
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
using FluentValidation;
|
||||
using AdsManagerService.API.Application.Commands;
|
||||
|
||||
namespace AdsManagerService.API.Application.Validations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for CreateSampleCommand.
|
||||
/// VI: Validator cho CreateSampleCommand.
|
||||
/// </summary>
|
||||
public class CreateSampleCommandValidator : AbstractValidator<CreateSampleCommand>
|
||||
{
|
||||
public CreateSampleCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Name)
|
||||
.NotEmpty()
|
||||
.WithMessage("Name is required / Tên là bắt buộc")
|
||||
.MaximumLength(200)
|
||||
.WithMessage("Name must be less than 200 characters / Tên phải ít hơn 200 ký tự");
|
||||
|
||||
RuleFor(x => x.Description)
|
||||
.MaximumLength(1000)
|
||||
.WithMessage("Description must be less than 1000 characters / Mô tả phải ít hơn 1000 ký tự")
|
||||
.When(x => x.Description != null);
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
using FluentValidation;
|
||||
using AdsManagerService.API.Application.Commands;
|
||||
|
||||
namespace AdsManagerService.API.Application.Validations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for UpdateSampleCommand.
|
||||
/// VI: Validator cho UpdateSampleCommand.
|
||||
/// </summary>
|
||||
public class UpdateSampleCommandValidator : AbstractValidator<UpdateSampleCommand>
|
||||
{
|
||||
public UpdateSampleCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.SampleId)
|
||||
.NotEmpty()
|
||||
.WithMessage("Sample ID is required / ID sample là bắt buộc");
|
||||
|
||||
RuleFor(x => x.Name)
|
||||
.NotEmpty()
|
||||
.WithMessage("Name is required / Tên là bắt buộc")
|
||||
.MaximumLength(200)
|
||||
.WithMessage("Name must be less than 200 characters / Tên phải ít hơn 200 ký tự");
|
||||
|
||||
RuleFor(x => x.Description)
|
||||
.MaximumLength(1000)
|
||||
.WithMessage("Description must be less than 1000 characters / Mô tả phải ít hơn 1000 ký tự")
|
||||
.When(x => x.Description != null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using AdsManagerService.API.Application.Commands;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace AdsManagerService.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// EN: API Controller for managing ad sets.
|
||||
/// VI: API Controller quản lý ad sets.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/ads-manager/adsets")]
|
||||
[Produces("application/json")]
|
||||
public class AdSetsController : ControllerBase
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private readonly ILogger<AdSetsController> _logger;
|
||||
|
||||
public AdSetsController(IMediator mediator, ILogger<AdSetsController> logger)
|
||||
{
|
||||
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create a new ad set.
|
||||
/// VI: Tạo ad set mới.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<Guid>> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get ad set by ID (placeholder).
|
||||
/// VI: Lấy ad set theo ID (placeholder).
|
||||
/// </summary>
|
||||
[HttpGet("{id}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetAdSetById(Guid id)
|
||||
{
|
||||
// TODO: Implement GetAdSetByIdQuery
|
||||
return Ok(new { id, message = "Ad set details" });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using AdsManagerService.API.Application.Commands;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace AdsManagerService.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// EN: API Controller for managing individual ads.
|
||||
/// VI: API Controller quản lý quảng cáo.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/ads-manager/ads")]
|
||||
[Produces("application/json")]
|
||||
public class AdsController : ControllerBase
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private readonly ILogger<AdsController> _logger;
|
||||
|
||||
public AdsController(IMediator mediator, ILogger<AdsController> logger)
|
||||
{
|
||||
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create a new ad.
|
||||
/// VI: Tạo quảng cáo mới.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<Guid>> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get ad by ID (placeholder).
|
||||
/// VI: Lấy quảng cáo theo ID (placeholder).
|
||||
/// </summary>
|
||||
[HttpGet("{id}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetAdById(Guid id)
|
||||
{
|
||||
// TODO: Implement GetAdByIdQuery
|
||||
return Ok(new { id, message = "Ad details" });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Submit ad for review.
|
||||
/// VI: Gửi quảng cáo để duyệt.
|
||||
/// </summary>
|
||||
[HttpPost("{id}/submit")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> SubmitAdForReview(Guid id)
|
||||
{
|
||||
_logger.LogInformation("Submitting ad {AdId} for review", id);
|
||||
// TODO: Implement SubmitAdForReviewCommand
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using AdsManagerService.API.Application.Commands;
|
||||
using AdsManagerService.API.Application.Queries;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace AdsManagerService.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// EN: API Controller for managing advertising campaigns.
|
||||
/// VI: API Controller quản lý chiến dịch quảng cáo.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/ads-manager/campaigns")]
|
||||
[Produces("application/json")]
|
||||
public class CampaignsController : ControllerBase
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private readonly ILogger<CampaignsController> _logger;
|
||||
|
||||
public CampaignsController(IMediator mediator, ILogger<CampaignsController> logger)
|
||||
{
|
||||
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create a new campaign.
|
||||
/// VI: Tạo chiến dịch mới.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<Guid>> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get campaign by ID.
|
||||
/// VI: Lấy chiến dịch theo ID.
|
||||
/// </summary>
|
||||
[HttpGet("{id}")]
|
||||
[ProducesResponseType(typeof(CampaignDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<CampaignDto>> GetCampaignById(Guid id)
|
||||
{
|
||||
var campaign = await _mediator.Send(new GetCampaignByIdQuery { CampaignId = id });
|
||||
|
||||
if (campaign == null)
|
||||
return NotFound();
|
||||
|
||||
return Ok(campaign);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update campaign information.
|
||||
/// VI: Cập nhật thông tin chiến dịch.
|
||||
/// </summary>
|
||||
[HttpPut("{id}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[HttpPost("{id}/activate")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> ActivateCampaign(Guid id)
|
||||
{
|
||||
_logger.LogInformation("Activating campaign {CampaignId}", id);
|
||||
|
||||
var result = await _mediator.Send(new ActivateCampaignCommand { CampaignId = id });
|
||||
|
||||
if (!result)
|
||||
return NotFound();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Pause a campaign.
|
||||
/// VI: Tạm dừng chiến dịch.
|
||||
/// </summary>
|
||||
[HttpPost("{id}/pause")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> PauseCampaign(Guid id)
|
||||
{
|
||||
// TODO: Implement PauseCampaignCommand
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Request model for updating campaign.
|
||||
/// VI: Request model cập nhật chiến dịch.
|
||||
/// </summary>
|
||||
public record UpdateCampaignRequest
|
||||
{
|
||||
public string Name { get; init; } = null!;
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Controller for Sample CRUD operations using CQRS pattern.
|
||||
/// VI: Controller cho các thao tác CRUD Sample sử dụng pattern CQRS.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[ApiVersion("1.0")]
|
||||
[Route("api/v{version:apiVersion}/[controller]")]
|
||||
[Produces("application/json")]
|
||||
public class SamplesController : ControllerBase
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private readonly ILogger<SamplesController> _logger;
|
||||
|
||||
public SamplesController(IMediator mediator, ILogger<SamplesController> logger)
|
||||
{
|
||||
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get all samples.
|
||||
/// VI: Lấy tất cả samples.
|
||||
/// </summary>
|
||||
/// <returns>EN: List of samples / VI: Danh sách samples</returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(IEnumerable<SampleViewModel>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetSamples()
|
||||
{
|
||||
var samples = await _mediator.Send(new GetSamplesQuery());
|
||||
return Ok(new { success = true, data = samples });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get a sample by ID.
|
||||
/// VI: Lấy một sample theo ID.
|
||||
/// </summary>
|
||||
/// <param name="id">EN: Sample ID / VI: ID sample</param>
|
||||
/// <returns>EN: Sample details / VI: Chi tiết sample</returns>
|
||||
[HttpGet("{id:guid}")]
|
||||
[ProducesResponseType(typeof(SampleViewModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetSample(Guid id)
|
||||
{
|
||||
var sample = await _mediator.Send(new GetSampleQuery(id));
|
||||
|
||||
if (sample is null)
|
||||
{
|
||||
return NotFound(new
|
||||
{
|
||||
success = false,
|
||||
error = new
|
||||
{
|
||||
code = "SAMPLE_NOT_FOUND",
|
||||
message = $"Sample with ID {id} not found / Sample với ID {id} không tìm thấy"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new { success = true, data = sample });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create a new sample.
|
||||
/// VI: Tạo một sample mới.
|
||||
/// </summary>
|
||||
/// <param name="request">EN: Create request / VI: Request tạo</param>
|
||||
/// <returns>EN: Created sample ID / VI: ID sample đã tạo</returns>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(CreateSampleCommandResult), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> CreateSample([FromBody] CreateSampleRequest request)
|
||||
{
|
||||
var command = new CreateSampleCommand(request.Name, request.Description);
|
||||
var result = await _mediator.Send(command);
|
||||
|
||||
return CreatedAtAction(
|
||||
nameof(GetSample),
|
||||
new { id = result.Id },
|
||||
new { success = true, data = result });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update an existing sample.
|
||||
/// VI: Cập nhật một sample đã tồn tại.
|
||||
/// </summary>
|
||||
/// <param name="id">EN: Sample ID / VI: ID sample</param>
|
||||
/// <param name="request">EN: Update request / VI: Request cập nhật</param>
|
||||
/// <returns>EN: Success status / VI: Trạng thái thành công</returns>
|
||||
[HttpPut("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> UpdateSample(Guid id, [FromBody] UpdateSampleRequest request)
|
||||
{
|
||||
var command = new UpdateSampleCommand(id, request.Name, request.Description);
|
||||
var result = await _mediator.Send(command);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
return NotFound(new
|
||||
{
|
||||
success = false,
|
||||
error = new
|
||||
{
|
||||
code = "SAMPLE_NOT_FOUND",
|
||||
message = $"Sample with ID {id} not found / Sample với ID {id} không tìm thấy"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new { success = true, message = "Sample updated successfully / Sample đã cập nhật thành công" });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Delete a sample.
|
||||
/// VI: Xóa một sample.
|
||||
/// </summary>
|
||||
/// <param name="id">EN: Sample ID / VI: ID sample</param>
|
||||
/// <returns>EN: Success status / VI: Trạng thái thành công</returns>
|
||||
[HttpDelete("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DeleteSample(Guid id)
|
||||
{
|
||||
var command = new DeleteSampleCommand(id);
|
||||
var result = await _mediator.Send(command);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
return NotFound(new
|
||||
{
|
||||
success = false,
|
||||
error = new
|
||||
{
|
||||
code = "SAMPLE_NOT_FOUND",
|
||||
message = $"Sample with ID {id} not found / Sample với ID {id} không tìm thấy"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Change sample status.
|
||||
/// VI: Thay đổi trạng thái sample.
|
||||
/// </summary>
|
||||
/// <param name="id">EN: Sample ID / VI: ID sample</param>
|
||||
/// <param name="request">EN: Status change request / VI: Request thay đổi trạng thái</param>
|
||||
/// <returns>EN: Success status / VI: Trạng thái thành công</returns>
|
||||
[HttpPatch("{id:guid}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> ChangeSampleStatus(Guid id, [FromBody] ChangeStatusRequest request)
|
||||
{
|
||||
var command = new ChangeSampleStatusCommand(id, request.Status);
|
||||
var result = await _mediator.Send(command);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
return BadRequest(new
|
||||
{
|
||||
success = false,
|
||||
error = new
|
||||
{
|
||||
code = "STATUS_CHANGE_FAILED",
|
||||
message = "Failed to change sample status / Thay đổi trạng thái sample thất bại"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new { success = true, message = "Sample status changed successfully / Trạng thái sample đã thay đổi thành công" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Request model for creating a sample.
|
||||
/// VI: Model request để tạo sample.
|
||||
/// </summary>
|
||||
public record CreateSampleRequest(string Name, string? Description);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Request model for updating a sample.
|
||||
/// VI: Model request để cập nhật sample.
|
||||
/// </summary>
|
||||
public record UpdateSampleRequest(string Name, string? Description);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Request model for changing sample status.
|
||||
/// VI: Model request để thay đổi trạng thái sample.
|
||||
/// </summary>
|
||||
public record ChangeStatusRequest(string Status);
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<AdFormat> 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<AdStatus> 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<AdReviewStatus> List() => new[] { NotSubmitted, PendingReview, Approved, Rejected };
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using AdsManagerService.Domain.SeedWork;
|
||||
|
||||
namespace AdsManagerService.Domain.AggregatesModel.AdAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Repository interface for Ad aggregate.
|
||||
/// VI: Interface repository cho Ad aggregate.
|
||||
/// </summary>
|
||||
public interface IAdRepository : IRepository<Ad>
|
||||
{
|
||||
Ad Add(Ad ad);
|
||||
void Update(Ad ad);
|
||||
Task<Ad?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Ad Set name.
|
||||
/// VI: Tên Ad Set.
|
||||
/// </summary>
|
||||
public string Name => _name;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Parent campaign ID.
|
||||
/// VI: ID chiến dịch cha.
|
||||
/// </summary>
|
||||
public Guid CampaignId => _campaignId;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Current status.
|
||||
/// VI: Trạng thái hiện tại.
|
||||
/// </summary>
|
||||
public AdSetStatus Status => _status;
|
||||
public int StatusId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Targeting settings.
|
||||
/// VI: Cài đặt targeting.
|
||||
/// </summary>
|
||||
public Targeting Targeting => _targeting;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Bid strategy.
|
||||
/// VI: Chiến lược giá thầu.
|
||||
/// </summary>
|
||||
public BidStrategy BidStrategy => _bidStrategy;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Daily budget.
|
||||
/// VI: Ngân sách hàng ngày.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Ad Set status enumeration.
|
||||
/// VI: Enum trạng thái Ad Set.
|
||||
/// </summary>
|
||||
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<AdSetStatus> List() =>
|
||||
new[] { Draft, Active, Paused, Archived };
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using AdsManagerService.Domain.SeedWork;
|
||||
|
||||
namespace AdsManagerService.Domain.AggregatesModel.AdSetAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Bid strategy for an Ad Set.
|
||||
/// VI: Chiến lược giá thầu cho Ad Set.
|
||||
/// </summary>
|
||||
public class BidStrategy : ValueObject
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Bid type (CPC, CPM, OCPM, Target Cost).
|
||||
/// VI: Loại giá thầu (CPC, CPM, OCPM, Target Cost).
|
||||
/// </summary>
|
||||
public BidType Type { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Bid amount (for manual bidding).
|
||||
/// VI: Số tiền giá thầu (cho trường hợp thầu thủ công).
|
||||
/// </summary>
|
||||
public decimal? BidAmount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public decimal? TargetCost { get; private set; }
|
||||
|
||||
protected BidStrategy() { }
|
||||
|
||||
public BidStrategy(BidType type, decimal? bidAmount = null, decimal? targetCost = null)
|
||||
{
|
||||
Type = type;
|
||||
BidAmount = bidAmount;
|
||||
TargetCost = targetCost;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create CPC (Cost Per Click) strategy.
|
||||
/// VI: Tạo chiến lược CPC (Chi phí mỗi Click).
|
||||
/// </summary>
|
||||
public static BidStrategy CPC(decimal bidAmount) => new(BidType.CPC, bidAmount);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create CPM (Cost Per Mille) strategy.
|
||||
/// VI: Tạo chiến lược CPM (Chi phí mỗi 1000 hiển thị).
|
||||
/// </summary>
|
||||
public static BidStrategy CPM(decimal bidAmount) => new(BidType.CPM, bidAmount);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create OCPM (Optimized CPM) strategy.
|
||||
/// VI: Tạo chiến lược OCPM (CPM tối ưu).
|
||||
/// </summary>
|
||||
public static BidStrategy OCPM(decimal targetCost) => new(BidType.OCPM, null, targetCost);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create automatic bidding strategy.
|
||||
/// VI: Tạo chiến lược thầu tự động.
|
||||
/// </summary>
|
||||
public static BidStrategy Automatic() => new(BidType.Automatic);
|
||||
|
||||
protected override IEnumerable<object> GetEqualityComponents()
|
||||
{
|
||||
yield return Type;
|
||||
yield return BidAmount ?? 0;
|
||||
yield return TargetCost ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Bid type enumeration.
|
||||
/// VI: Enum loại giá thầu.
|
||||
/// </summary>
|
||||
public enum BidType
|
||||
{
|
||||
/// <summary>EN: Cost Per Click / VI: Chi phí mỗi Click</summary>
|
||||
CPC = 1,
|
||||
|
||||
/// <summary>EN: Cost Per Mille (1000 impressions) / VI: Chi phí mỗi 1000 hiển thị</summary>
|
||||
CPM = 2,
|
||||
|
||||
/// <summary>EN: Optimized CPM / VI: CPM tối ưu</summary>
|
||||
OCPM = 3,
|
||||
|
||||
/// <summary>EN: Target Cost per result / VI: Chi phí mục tiêu mỗi kết quả</summary>
|
||||
TargetCost = 4,
|
||||
|
||||
/// <summary>EN: Automatic bidding / VI: Thầu tự động</summary>
|
||||
Automatic = 5
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using AdsManagerService.Domain.SeedWork;
|
||||
|
||||
namespace AdsManagerService.Domain.AggregatesModel.AdSetAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Repository interface for AdSet aggregate.
|
||||
/// VI: Interface repository cho AdSet aggregate.
|
||||
/// </summary>
|
||||
public interface IAdSetRepository : IRepository<AdSet>
|
||||
{
|
||||
AdSet Add(AdSet adSet);
|
||||
void Update(AdSet adSet);
|
||||
Task<AdSet?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<AdSet>> GetByCampaignIdAsync(Guid campaignId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using AdsManagerService.Domain.SeedWork;
|
||||
|
||||
namespace AdsManagerService.Domain.AggregatesModel.AdSetAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Targeting settings for an Ad Set.
|
||||
/// VI: Cài đặt targeting cho Ad Set.
|
||||
/// </summary>
|
||||
public class Targeting : ValueObject
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Minimum age for targeting.
|
||||
/// VI: Tuổi tối thiểu để nhắm mục tiêu.
|
||||
/// </summary>
|
||||
public int? MinAge { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Maximum age for targeting.
|
||||
/// VI: Tuổi tối đa để nhắm mục tiêu.
|
||||
/// </summary>
|
||||
public int? MaxAge { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Target genders (male, female, all).
|
||||
/// VI: Giới tính nhắm mục tiêu (nam, nữ, tất cả).
|
||||
/// </summary>
|
||||
public string? Genders { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Target locations (comma-separated).
|
||||
/// VI: Vị trí nhắm mục tiêu (phân cách bằng dấu phẩy).
|
||||
/// </summary>
|
||||
public string? Locations { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Target interests (comma-separated).
|
||||
/// VI: Sở thích nhắm mục tiêu (phân cách bằng dấu phẩy).
|
||||
/// </summary>
|
||||
public string? Interests { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Custom audience IDs (comma-separated).
|
||||
/// VI: ID đối tượng tùy chỉnh (phân cách bằng dấu phẩy).
|
||||
/// </summary>
|
||||
public string? CustomAudienceIds { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Lookalike audience IDs (comma-separated).
|
||||
/// VI: ID đối tượng tương tự (phân cách bằng dấu phẩy).
|
||||
/// </summary>
|
||||
public string? LookalikeAudienceIds { get; private set; }
|
||||
|
||||
protected Targeting() { }
|
||||
|
||||
public Targeting(
|
||||
int? minAge = null,
|
||||
int? maxAge = null,
|
||||
string? genders = null,
|
||||
string? locations = null,
|
||||
string? interests = null,
|
||||
string? customAudienceIds = null,
|
||||
string? lookalikeAudienceIds = null)
|
||||
{
|
||||
MinAge = minAge;
|
||||
MaxAge = maxAge;
|
||||
Genders = genders;
|
||||
Locations = locations;
|
||||
Interests = interests;
|
||||
CustomAudienceIds = customAudienceIds;
|
||||
LookalikeAudienceIds = lookalikeAudienceIds;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create broad targeting (everyone).
|
||||
/// VI: Tạo targeting rộng (tất cả mọi người).
|
||||
/// </summary>
|
||||
public static Targeting Broad() => new();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create demographic targeting.
|
||||
/// VI: Tạo targeting theo nhân khẩu học.
|
||||
/// </summary>
|
||||
public static Targeting Demographic(int minAge, int maxAge, string? genders = null, string? locations = null) =>
|
||||
new(minAge, maxAge, genders, locations);
|
||||
|
||||
protected override IEnumerable<object> GetEqualityComponents()
|
||||
{
|
||||
yield return MinAge ?? 0;
|
||||
yield return MaxAge ?? 0;
|
||||
yield return Genders ?? "";
|
||||
yield return Locations ?? "";
|
||||
yield return Interests ?? "";
|
||||
yield return CustomAudienceIds ?? "";
|
||||
yield return LookalikeAudienceIds ?? "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using AdsManagerService.Domain.Exceptions;
|
||||
using AdsManagerService.Domain.SeedWork;
|
||||
|
||||
namespace AdsManagerService.Domain.AggregatesModel.AudienceAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Custom Audience aggregate root - uploaded lists or website visitors.
|
||||
/// VI: Custom Audience aggregate root - danh sách upload hoặc khách truy cập website.
|
||||
/// </summary>
|
||||
public class CustomAudience : Entity, IAggregateRoot
|
||||
{
|
||||
private string _name = null!;
|
||||
private Guid _advertiserId;
|
||||
private AudienceSource _source = null!;
|
||||
private int _size;
|
||||
private DateTime _createdAt;
|
||||
private DateTime? _updatedAt;
|
||||
|
||||
public string Name => _name;
|
||||
public Guid AdvertiserId => _advertiserId;
|
||||
public AudienceSource Source => _source;
|
||||
public int SourceId { get; private set; }
|
||||
public int Size => _size;
|
||||
public DateTime CreatedAt => _createdAt;
|
||||
public DateTime? UpdatedAt => _updatedAt;
|
||||
|
||||
protected CustomAudience() { }
|
||||
|
||||
public CustomAudience(
|
||||
Guid advertiserId,
|
||||
string name,
|
||||
AudienceSource source,
|
||||
int size = 0) : this()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new AdsDomainException("Audience name cannot be empty");
|
||||
|
||||
Id = Guid.NewGuid();
|
||||
_advertiserId = advertiserId;
|
||||
_name = name;
|
||||
_source = source;
|
||||
SourceId = source.Id;
|
||||
_size = size;
|
||||
_createdAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public void UpdateSize(int size)
|
||||
{
|
||||
_size = size;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Lookalike Audience - similar users to a source audience.
|
||||
/// VI: Lookalike Audience - người dùng tương tự với audience nguồn.
|
||||
/// </summary>
|
||||
public class LookalikeAudience : Entity, IAggregateRoot
|
||||
{
|
||||
private string _name = null!;
|
||||
private Guid _advertiserId;
|
||||
private Guid _sourceAudienceId;
|
||||
private int _similarityPercentage;
|
||||
private string? _location;
|
||||
private int _size;
|
||||
private DateTime _createdAt;
|
||||
|
||||
public string Name => _name;
|
||||
public Guid AdvertiserId => _advertiserId;
|
||||
public Guid SourceAudienceId => _sourceAudienceId;
|
||||
public int SimilarityPercentage => _similarityPercentage;
|
||||
public string? Location => _location;
|
||||
public int Size => _size;
|
||||
public DateTime CreatedAt => _createdAt;
|
||||
|
||||
protected LookalikeAudience() { }
|
||||
|
||||
public LookalikeAudience(
|
||||
Guid advertiserId,
|
||||
string name,
|
||||
Guid sourceAudienceId,
|
||||
int similarityPercentage,
|
||||
string? location = null) : this()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new AdsDomainException("Lookalike audience name cannot be empty");
|
||||
|
||||
if (similarityPercentage < 1 || similarityPercentage > 10)
|
||||
throw new AdsDomainException("Similarity percentage must be between 1% and 10%");
|
||||
|
||||
Id = Guid.NewGuid();
|
||||
_advertiserId = advertiserId;
|
||||
_name = name;
|
||||
_sourceAudienceId = sourceAudienceId;
|
||||
_similarityPercentage = similarityPercentage;
|
||||
_location = location;
|
||||
_size = 0;
|
||||
_createdAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public void UpdateSize(int size)
|
||||
{
|
||||
_size = size;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Audience source type.
|
||||
/// VI: Loại nguồn audience.
|
||||
/// </summary>
|
||||
public class AudienceSource : Enumeration
|
||||
{
|
||||
public static readonly AudienceSource CustomerList = new(1, "customer_list");
|
||||
public static readonly AudienceSource WebsiteVisitors = new(2, "website_visitors");
|
||||
public static readonly AudienceSource AppUsers = new(3, "app_users");
|
||||
public static readonly AudienceSource EngagementCustomers = new(4, "engagement_customers");
|
||||
|
||||
public AudienceSource(int id, string name) : base(id, name) { }
|
||||
|
||||
public static IEnumerable<AudienceSource> List() =>
|
||||
new[] { CustomerList, WebsiteVisitors, AppUsers, EngagementCustomers };
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
using AdsManagerService.Domain.Events;
|
||||
using AdsManagerService.Domain.Exceptions;
|
||||
using AdsManagerService.Domain.SeedWork;
|
||||
|
||||
namespace AdsManagerService.Domain.AggregatesModel.CampaignAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Campaign aggregate root - the top level of the 3-tier ads structure.
|
||||
/// VI: Campaign aggregate root - cấp trên nhất của cấu trúc ads 3 cấp.
|
||||
/// </summary>
|
||||
public class Campaign : Entity, IAggregateRoot
|
||||
{
|
||||
// Private fields for encapsulation
|
||||
private string _name = null!;
|
||||
private string? _description;
|
||||
private Guid _advertiserId;
|
||||
private CampaignStatus _status = null!;
|
||||
private CampaignObjective _objective = null!;
|
||||
private CampaignBudget _budget = null!;
|
||||
private DateTime? _startDate;
|
||||
private DateTime? _endDate;
|
||||
private DateTime _createdAt;
|
||||
private DateTime? _updatedAt;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Campaign name.
|
||||
/// VI: Tên chiến dịch.
|
||||
/// </summary>
|
||||
public string Name => _name;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Campaign description.
|
||||
/// VI: Mô tả chiến dịch.
|
||||
/// </summary>
|
||||
public string? Description => _description;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Advertiser who owns this campaign.
|
||||
/// VI: Nhà quảng cáo sở hữu chiến dịch này.
|
||||
/// </summary>
|
||||
public Guid AdvertiserId => _advertiserId;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Current campaign status.
|
||||
/// VI: Trạng thái hiện tại của chiến dịch.
|
||||
/// </summary>
|
||||
public CampaignStatus Status => _status;
|
||||
public int StatusId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Campaign objective (Awareness, Traffic, Conversion).
|
||||
/// VI: Mục tiêu chiến dịch (Nhận diện, Traffic, Chuyển đổi).
|
||||
/// </summary>
|
||||
public CampaignObjective Objective => _objective;
|
||||
public int ObjectiveId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Campaign budget settings.
|
||||
/// VI: Cài đặt ngân sách chiến dịch.
|
||||
/// </summary>
|
||||
public CampaignBudget Budget => _budget;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Campaign start date.
|
||||
/// VI: Ngày bắt đầu chiến dịch.
|
||||
/// </summary>
|
||||
public DateTime? StartDate => _startDate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Campaign end date.
|
||||
/// VI: Ngày kết thúc chiến dịch.
|
||||
/// </summary>
|
||||
public DateTime? EndDate => _endDate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Creation timestamp.
|
||||
/// VI: Thời gian tạo.
|
||||
/// </summary>
|
||||
public DateTime CreatedAt => _createdAt;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Last update timestamp.
|
||||
/// VI: Thời gian cập nhật cuối.
|
||||
/// </summary>
|
||||
public DateTime? UpdatedAt => _updatedAt;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Total spend so far.
|
||||
/// VI: Tổng chi tiêu đến hiện tại.
|
||||
/// </summary>
|
||||
public decimal TotalSpend { get; private set; }
|
||||
|
||||
// EF Core constructor
|
||||
protected Campaign() { }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create a new campaign.
|
||||
/// VI: Tạo chiến dịch mới.
|
||||
/// </summary>
|
||||
public Campaign(
|
||||
Guid advertiserId,
|
||||
string name,
|
||||
CampaignObjective objective,
|
||||
CampaignBudget budget,
|
||||
string? description = null) : this()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new AdsDomainException("Campaign name cannot be empty");
|
||||
|
||||
Id = Guid.NewGuid();
|
||||
_advertiserId = advertiserId;
|
||||
_name = name;
|
||||
_description = description;
|
||||
_objective = objective;
|
||||
ObjectiveId = objective.Id;
|
||||
_budget = budget;
|
||||
_status = CampaignStatus.Draft;
|
||||
StatusId = CampaignStatus.Draft.Id;
|
||||
_createdAt = DateTime.UtcNow;
|
||||
TotalSpend = 0;
|
||||
|
||||
AddDomainEvent(new CampaignCreatedDomainEvent(this));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update campaign information.
|
||||
/// VI: Cập nhật thông tin chiến dịch.
|
||||
/// </summary>
|
||||
public void Update(string name, string? description)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new AdsDomainException("Campaign name cannot be empty");
|
||||
|
||||
if (_status == CampaignStatus.Archived)
|
||||
throw new AdsDomainException("Cannot update an archived campaign");
|
||||
|
||||
_name = name;
|
||||
_description = description;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Set campaign schedule.
|
||||
/// VI: Đặt lịch chiến dịch.
|
||||
/// </summary>
|
||||
public void SetSchedule(DateTime startDate, DateTime? endDate = null)
|
||||
{
|
||||
if (endDate.HasValue && endDate.Value <= startDate)
|
||||
throw new AdsDomainException("End date must be after start date");
|
||||
|
||||
_startDate = startDate;
|
||||
_endDate = endDate;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update campaign budget.
|
||||
/// VI: Cập nhật ngân sách chiến dịch.
|
||||
/// </summary>
|
||||
public void SetBudget(CampaignBudget budget)
|
||||
{
|
||||
_budget = budget ?? throw new AdsDomainException("Budget cannot be null");
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Activate the campaign (start running ads).
|
||||
/// VI: Kích hoạt chiến dịch (bắt đầu chạy quảng cáo).
|
||||
/// </summary>
|
||||
public void Activate()
|
||||
{
|
||||
if (_status != CampaignStatus.Draft && _status != CampaignStatus.Paused)
|
||||
throw new AdsDomainException("Only draft or paused campaigns can be activated");
|
||||
|
||||
var previousStatus = _status;
|
||||
_status = CampaignStatus.Active;
|
||||
StatusId = CampaignStatus.Active.Id;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
|
||||
AddDomainEvent(new CampaignActivatedDomainEvent(Id, previousStatus, _status));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Pause the campaign.
|
||||
/// VI: Tạm dừng chiến dịch.
|
||||
/// </summary>
|
||||
public void Pause()
|
||||
{
|
||||
if (_status != CampaignStatus.Active)
|
||||
throw new AdsDomainException("Only active campaigns can be paused");
|
||||
|
||||
var previousStatus = _status;
|
||||
_status = CampaignStatus.Paused;
|
||||
StatusId = CampaignStatus.Paused.Id;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
|
||||
AddDomainEvent(new CampaignStatusChangedDomainEvent(Id, previousStatus, _status));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Complete the campaign.
|
||||
/// VI: Hoàn thành chiến dịch.
|
||||
/// </summary>
|
||||
public void Complete()
|
||||
{
|
||||
if (_status != CampaignStatus.Active)
|
||||
throw new AdsDomainException("Only active campaigns can be completed");
|
||||
|
||||
var previousStatus = _status;
|
||||
_status = CampaignStatus.Completed;
|
||||
StatusId = CampaignStatus.Completed.Id;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
|
||||
AddDomainEvent(new CampaignStatusChangedDomainEvent(Id, previousStatus, _status));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Archive the campaign.
|
||||
/// VI: Lưu trữ chiến dịch.
|
||||
/// </summary>
|
||||
public void Archive()
|
||||
{
|
||||
if (_status == CampaignStatus.Active)
|
||||
throw new AdsDomainException("Cannot archive an active campaign. Pause it first.");
|
||||
|
||||
var previousStatus = _status;
|
||||
_status = CampaignStatus.Archived;
|
||||
StatusId = CampaignStatus.Archived.Id;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
|
||||
AddDomainEvent(new CampaignStatusChangedDomainEvent(Id, previousStatus, _status));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Record spend for this campaign.
|
||||
/// VI: Ghi nhận chi tiêu cho chiến dịch này.
|
||||
/// </summary>
|
||||
public void RecordSpend(decimal amount)
|
||||
{
|
||||
if (amount < 0)
|
||||
throw new AdsDomainException("Spend amount cannot be negative");
|
||||
|
||||
TotalSpend += amount;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using AdsManagerService.Domain.SeedWork;
|
||||
|
||||
namespace AdsManagerService.Domain.AggregatesModel.CampaignAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Campaign budget value object representing daily or lifetime budget.
|
||||
/// VI: Value object ngân sách chiến dịch đại diện cho ngân sách ngày hoặc tổng.
|
||||
/// </summary>
|
||||
public class CampaignBudget : ValueObject
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Budget type (Daily or Lifetime).
|
||||
/// VI: Loại ngân sách (Hàng ngày hoặc Tổng).
|
||||
/// </summary>
|
||||
public BudgetType Type { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Budget amount in the account's currency.
|
||||
/// VI: Số tiền ngân sách theo đơn vị tiền tệ của tài khoản.
|
||||
/// </summary>
|
||||
public decimal Amount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Currency code (e.g., VND, USD).
|
||||
/// VI: Mã tiền tệ (ví dụ: VND, USD).
|
||||
/// </summary>
|
||||
public string Currency { get; private set; } = "VND";
|
||||
|
||||
protected CampaignBudget() { }
|
||||
|
||||
public CampaignBudget(BudgetType type, decimal amount, string currency = "VND")
|
||||
{
|
||||
if (amount <= 0)
|
||||
throw new ArgumentException("Budget amount must be greater than zero", nameof(amount));
|
||||
|
||||
Type = type;
|
||||
Amount = amount;
|
||||
Currency = currency;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create a daily budget.
|
||||
/// VI: Tạo ngân sách hàng ngày.
|
||||
/// </summary>
|
||||
public static CampaignBudget Daily(decimal amount, string currency = "VND") =>
|
||||
new(BudgetType.Daily, amount, currency);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create a lifetime budget.
|
||||
/// VI: Tạo ngân sách tổng.
|
||||
/// </summary>
|
||||
public static CampaignBudget Lifetime(decimal amount, string currency = "VND") =>
|
||||
new(BudgetType.Lifetime, amount, currency);
|
||||
|
||||
protected override IEnumerable<object> GetEqualityComponents()
|
||||
{
|
||||
yield return Type;
|
||||
yield return Amount;
|
||||
yield return Currency;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Budget type enumeration.
|
||||
/// VI: Enum loại ngân sách.
|
||||
/// </summary>
|
||||
public enum BudgetType
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Daily budget - resets each day.
|
||||
/// VI: Ngân sách ngày - reset mỗi ngày.
|
||||
/// </summary>
|
||||
Daily = 1,
|
||||
|
||||
/// <summary>
|
||||
/// EN: Lifetime budget - total for campaign duration.
|
||||
/// VI: Ngân sách tổng - tổng cho toàn bộ thời gian chiến dịch.
|
||||
/// </summary>
|
||||
Lifetime = 2
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using AdsManagerService.Domain.SeedWork;
|
||||
|
||||
namespace AdsManagerService.Domain.AggregatesModel.CampaignAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Campaign objective enumeration (Awareness, Traffic, Conversion).
|
||||
/// VI: Enum mục tiêu chiến dịch (Nhận diện, Traffic, Chuyển đổi).
|
||||
/// </summary>
|
||||
public class CampaignObjective : Enumeration
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Brand awareness objective - maximize reach and impressions.
|
||||
/// VI: Mục tiêu nhận diện thương hiệu - tối đa hóa reach và impressions.
|
||||
/// </summary>
|
||||
public static readonly CampaignObjective Awareness = new(1, nameof(Awareness).ToLowerInvariant());
|
||||
|
||||
/// <summary>
|
||||
/// EN: Traffic objective - drive clicks to website/app.
|
||||
/// VI: Mục tiêu traffic - tăng click vào website/app.
|
||||
/// </summary>
|
||||
public static readonly CampaignObjective Traffic = new(2, nameof(Traffic).ToLowerInvariant());
|
||||
|
||||
/// <summary>
|
||||
/// EN: Conversion objective - optimize for purchases/leads.
|
||||
/// VI: Mục tiêu chuyển đổi - tối ưu cho mua hàng/đăng ký.
|
||||
/// </summary>
|
||||
public static readonly CampaignObjective Conversion = new(3, nameof(Conversion).ToLowerInvariant());
|
||||
|
||||
/// <summary>
|
||||
/// EN: App installs objective.
|
||||
/// VI: Mục tiêu cài đặt app.
|
||||
/// </summary>
|
||||
public static readonly CampaignObjective AppInstalls = new(4, "app_installs");
|
||||
|
||||
/// <summary>
|
||||
/// EN: Video views objective.
|
||||
/// VI: Mục tiêu xem video.
|
||||
/// </summary>
|
||||
public static readonly CampaignObjective VideoViews = new(5, "video_views");
|
||||
|
||||
/// <summary>
|
||||
/// EN: Lead generation objective.
|
||||
/// VI: Mục tiêu thu thập lead.
|
||||
/// </summary>
|
||||
public static readonly CampaignObjective LeadGeneration = new(6, "lead_generation");
|
||||
|
||||
public CampaignObjective(int id, string name) : base(id, name)
|
||||
{
|
||||
}
|
||||
|
||||
public static IEnumerable<CampaignObjective> List() =>
|
||||
new[] { Awareness, Traffic, Conversion, AppInstalls, VideoViews, LeadGeneration };
|
||||
|
||||
public static CampaignObjective FromName(string name) =>
|
||||
List().FirstOrDefault(s => string.Equals(s.Name, name, StringComparison.CurrentCultureIgnoreCase))
|
||||
?? throw new ArgumentException($"Invalid campaign objective: {name}");
|
||||
|
||||
public static CampaignObjective From(int id) =>
|
||||
List().FirstOrDefault(s => s.Id == id)
|
||||
?? throw new ArgumentException($"Invalid campaign objective id: {id}");
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using AdsManagerService.Domain.SeedWork;
|
||||
|
||||
namespace AdsManagerService.Domain.AggregatesModel.CampaignAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Campaign status enumeration using smart enum pattern.
|
||||
/// VI: Enum trạng thái chiến dịch sử dụng smart enum pattern.
|
||||
/// </summary>
|
||||
public class CampaignStatus : Enumeration
|
||||
{
|
||||
public static readonly CampaignStatus Draft = new(1, nameof(Draft).ToLowerInvariant());
|
||||
public static readonly CampaignStatus Active = new(2, nameof(Active).ToLowerInvariant());
|
||||
public static readonly CampaignStatus Paused = new(3, nameof(Paused).ToLowerInvariant());
|
||||
public static readonly CampaignStatus Completed = new(4, nameof(Completed).ToLowerInvariant());
|
||||
public static readonly CampaignStatus Archived = new(5, nameof(Archived).ToLowerInvariant());
|
||||
|
||||
public CampaignStatus(int id, string name) : base(id, name)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get all possible campaign statuses.
|
||||
/// VI: Lấy tất cả trạng thái chiến dịch có thể.
|
||||
/// </summary>
|
||||
public static IEnumerable<CampaignStatus> List() =>
|
||||
new[] { Draft, Active, Paused, Completed, Archived };
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get status by name.
|
||||
/// VI: Lấy trạng thái theo tên.
|
||||
/// </summary>
|
||||
public static CampaignStatus FromName(string name) =>
|
||||
List().FirstOrDefault(s => string.Equals(s.Name, name, StringComparison.CurrentCultureIgnoreCase))
|
||||
?? throw new ArgumentException($"Invalid campaign status: {name}");
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get status by id.
|
||||
/// VI: Lấy trạng thái theo id.
|
||||
/// </summary>
|
||||
public static CampaignStatus From(int id) =>
|
||||
List().FirstOrDefault(s => s.Id == id)
|
||||
?? throw new ArgumentException($"Invalid campaign status id: {id}");
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using AdsManagerService.Domain.SeedWork;
|
||||
|
||||
namespace AdsManagerService.Domain.AggregatesModel.CampaignAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Repository interface for Campaign aggregate.
|
||||
/// VI: Interface repository cho Campaign aggregate.
|
||||
/// </summary>
|
||||
public interface ICampaignRepository : IRepository<Campaign>
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Add a new campaign.
|
||||
/// VI: Thêm chiến dịch mới.
|
||||
/// </summary>
|
||||
Campaign Add(Campaign campaign);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update an existing campaign.
|
||||
/// VI: Cập nhật chiến dịch hiện có.
|
||||
/// </summary>
|
||||
void Update(Campaign campaign);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get campaign by ID.
|
||||
/// VI: Lấy chiến dịch theo ID.
|
||||
/// </summary>
|
||||
Task<Campaign?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get campaigns by advertiser ID.
|
||||
/// VI: Lấy danh sách chiến dịch theo ID nhà quảng cáo.
|
||||
/// </summary>
|
||||
Task<IEnumerable<Campaign>> GetByAdvertiserIdAsync(Guid advertiserId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get active campaigns.
|
||||
/// VI: Lấy danh sách chiến dịch đang hoạt động.
|
||||
/// </summary>
|
||||
Task<IEnumerable<Campaign>> GetActiveAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
using AdsManagerService.Domain.SeedWork;
|
||||
|
||||
namespace AdsManagerService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Repository interface for Sample aggregate.
|
||||
/// VI: Interface repository cho Sample aggregate.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// EN: Following repository pattern, this interface defines the contract
|
||||
/// for data access operations on Sample aggregate.
|
||||
/// VI: Theo pattern repository, interface này định nghĩa contract
|
||||
/// cho các thao tác truy cập dữ liệu trên Sample aggregate.
|
||||
/// </remarks>
|
||||
public interface ISampleRepository : IRepository<Sample>
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Get a sample by its ID.
|
||||
/// VI: Lấy một sample theo ID.
|
||||
/// </summary>
|
||||
/// <param name="sampleId">EN: The sample ID / VI: ID của sample</param>
|
||||
/// <returns>EN: The sample or null if not found / VI: Sample hoặc null nếu không tìm thấy</returns>
|
||||
Task<Sample?> GetAsync(Guid sampleId);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get all samples.
|
||||
/// VI: Lấy tất cả samples.
|
||||
/// </summary>
|
||||
/// <returns>EN: List of samples / VI: Danh sách samples</returns>
|
||||
Task<IEnumerable<Sample>> GetAllAsync();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Add a new sample.
|
||||
/// VI: Thêm một sample mới.
|
||||
/// </summary>
|
||||
/// <param name="sample">EN: The sample to add / VI: Sample cần thêm</param>
|
||||
/// <returns>EN: The added sample / VI: Sample đã thêm</returns>
|
||||
Sample Add(Sample sample);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update an existing sample.
|
||||
/// VI: Cập nhật một sample đã tồn tại.
|
||||
/// </summary>
|
||||
/// <param name="sample">EN: The sample to update / VI: Sample cần cập nhật</param>
|
||||
void Update(Sample sample);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Delete a sample.
|
||||
/// VI: Xóa một sample.
|
||||
/// </summary>
|
||||
/// <param name="sample">EN: The sample to delete / VI: Sample cần xóa</param>
|
||||
void Delete(Sample sample);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get samples by status.
|
||||
/// VI: Lấy samples theo trạng thái.
|
||||
/// </summary>
|
||||
/// <param name="statusId">EN: The status ID / VI: ID trạng thái</param>
|
||||
/// <returns>EN: List of samples with given status / VI: Danh sách samples với trạng thái cho trước</returns>
|
||||
Task<IEnumerable<Sample>> GetByStatusAsync(int statusId);
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
using AdsManagerService.Domain.Events;
|
||||
using AdsManagerService.Domain.Exceptions;
|
||||
using AdsManagerService.Domain.SeedWork;
|
||||
|
||||
namespace AdsManagerService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Sample aggregate root demonstrating DDD patterns.
|
||||
/// VI: Sample aggregate root minh họa các pattern DDD.
|
||||
/// </summary>
|
||||
public class Sample : Entity, IAggregateRoot
|
||||
{
|
||||
// EN: Private fields for encapsulation
|
||||
// VI: Fields private để đóng gói
|
||||
private string _name = null!;
|
||||
private string? _description;
|
||||
private SampleStatus _status = null!;
|
||||
private DateTime _createdAt;
|
||||
private DateTime? _updatedAt;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Sample name (required).
|
||||
/// VI: Tên sample (bắt buộc).
|
||||
/// </summary>
|
||||
public string Name => _name;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Optional description.
|
||||
/// VI: Mô tả tùy chọn.
|
||||
/// </summary>
|
||||
public string? Description => _description;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Current status.
|
||||
/// VI: Trạng thái hiện tại.
|
||||
/// </summary>
|
||||
public SampleStatus Status => _status;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Status ID for EF Core mapping.
|
||||
/// VI: ID trạng thái cho EF Core mapping.
|
||||
/// </summary>
|
||||
public int StatusId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Creation timestamp.
|
||||
/// VI: Thời gian tạo.
|
||||
/// </summary>
|
||||
public DateTime CreatedAt => _createdAt;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Last update timestamp.
|
||||
/// VI: Thời gian cập nhật cuối.
|
||||
/// </summary>
|
||||
public DateTime? UpdatedAt => _updatedAt;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Private constructor for EF Core.
|
||||
/// VI: Constructor private cho EF Core.
|
||||
/// </summary>
|
||||
protected Sample()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create a new Sample with required information.
|
||||
/// VI: Tạo một Sample mới với thông tin bắt buộc.
|
||||
/// </summary>
|
||||
/// <param name="name">EN: Sample name / VI: Tên sample</param>
|
||||
/// <param name="description">EN: Optional description / VI: Mô tả tùy chọn</param>
|
||||
public Sample(string name, string? description = null) : this()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new SampleDomainException("Sample name cannot be empty");
|
||||
|
||||
Id = Guid.NewGuid();
|
||||
_name = name;
|
||||
_description = description;
|
||||
_status = SampleStatus.Draft;
|
||||
StatusId = SampleStatus.Draft.Id;
|
||||
_createdAt = DateTime.UtcNow;
|
||||
|
||||
// EN: Add domain event for creation
|
||||
// VI: Thêm domain event cho việc tạo
|
||||
AddDomainEvent(new SampleCreatedDomainEvent(this));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update sample information.
|
||||
/// VI: Cập nhật thông tin sample.
|
||||
/// </summary>
|
||||
public void Update(string name, string? description)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new SampleDomainException("Sample name cannot be empty");
|
||||
|
||||
if (_status == SampleStatus.Cancelled)
|
||||
throw new SampleDomainException("Cannot update a cancelled sample");
|
||||
|
||||
_name = name;
|
||||
_description = description;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Activate the sample.
|
||||
/// VI: Kích hoạt sample.
|
||||
/// </summary>
|
||||
public void Activate()
|
||||
{
|
||||
if (_status != SampleStatus.Draft)
|
||||
throw new SampleDomainException("Only draft samples can be activated");
|
||||
|
||||
var previousStatus = _status;
|
||||
_status = SampleStatus.Active;
|
||||
StatusId = SampleStatus.Active.Id;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
|
||||
AddDomainEvent(new SampleStatusChangedDomainEvent(Id, previousStatus, _status));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Complete the sample.
|
||||
/// VI: Hoàn thành sample.
|
||||
/// </summary>
|
||||
public void Complete()
|
||||
{
|
||||
if (_status != SampleStatus.Active)
|
||||
throw new SampleDomainException("Only active samples can be completed");
|
||||
|
||||
var previousStatus = _status;
|
||||
_status = SampleStatus.Completed;
|
||||
StatusId = SampleStatus.Completed.Id;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
|
||||
AddDomainEvent(new SampleStatusChangedDomainEvent(Id, previousStatus, _status));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Cancel the sample.
|
||||
/// VI: Hủy sample.
|
||||
/// </summary>
|
||||
public void Cancel()
|
||||
{
|
||||
if (_status == SampleStatus.Completed)
|
||||
throw new SampleDomainException("Cannot cancel a completed sample");
|
||||
|
||||
if (_status == SampleStatus.Cancelled)
|
||||
throw new SampleDomainException("Sample is already cancelled");
|
||||
|
||||
var previousStatus = _status;
|
||||
_status = SampleStatus.Cancelled;
|
||||
StatusId = SampleStatus.Cancelled.Id;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
|
||||
AddDomainEvent(new SampleStatusChangedDomainEvent(Id, previousStatus, _status));
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
using AdsManagerService.Domain.SeedWork;
|
||||
|
||||
namespace AdsManagerService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Sample status enumeration following type-safe enum pattern.
|
||||
/// VI: Enumeration trạng thái Sample theo pattern enum an toàn kiểu.
|
||||
/// </summary>
|
||||
public class SampleStatus : Enumeration
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Draft status - initial state
|
||||
/// VI: Trạng thái nháp - trạng thái ban đầu
|
||||
/// </summary>
|
||||
public static SampleStatus Draft = new(1, nameof(Draft));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Active status - ready for use
|
||||
/// VI: Trạng thái hoạt động - sẵn sàng sử dụng
|
||||
/// </summary>
|
||||
public static SampleStatus Active = new(2, nameof(Active));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Completed status - finished processing
|
||||
/// VI: Trạng thái hoàn thành - đã xử lý xong
|
||||
/// </summary>
|
||||
public static SampleStatus Completed = new(3, nameof(Completed));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Cancelled status - cancelled by user
|
||||
/// VI: Trạng thái đã hủy - bị hủy bởi người dùng
|
||||
/// </summary>
|
||||
public static SampleStatus Cancelled = new(4, nameof(Cancelled));
|
||||
|
||||
public SampleStatus(int id, string name) : base(id, name)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get all available statuses.
|
||||
/// VI: Lấy tất cả các trạng thái có sẵn.
|
||||
/// </summary>
|
||||
public static IEnumerable<SampleStatus> List() => GetAll<SampleStatus>();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Parse status from name.
|
||||
/// VI: Parse trạng thái từ tên.
|
||||
/// </summary>
|
||||
public static SampleStatus FromName(string name)
|
||||
{
|
||||
var status = List().SingleOrDefault(s =>
|
||||
string.Equals(s.Name, name, StringComparison.CurrentCultureIgnoreCase));
|
||||
|
||||
if (status is null)
|
||||
{
|
||||
throw new ArgumentException($"Possible values for SampleStatus: {string.Join(",", List().Select(s => s.Name))}");
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Parse status from ID.
|
||||
/// VI: Parse trạng thái từ ID.
|
||||
/// </summary>
|
||||
public static SampleStatus From(int id)
|
||||
{
|
||||
var status = List().SingleOrDefault(s => s.Id == id);
|
||||
|
||||
if (status is null)
|
||||
{
|
||||
throw new ArgumentException($"Possible values for SampleStatus: {string.Join(",", List().Select(s => s.Name))}");
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using AdsManagerService.Domain.AggregatesModel.CampaignAggregate;
|
||||
using MediatR;
|
||||
|
||||
namespace AdsManagerService.Domain.Events;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Domain event raised when a new campaign is created.
|
||||
/// VI: Domain event phát ra khi chiến dịch mới được tạo.
|
||||
/// </summary>
|
||||
public class CampaignCreatedDomainEvent : INotification
|
||||
{
|
||||
public Campaign Campaign { get; }
|
||||
|
||||
public CampaignCreatedDomainEvent(Campaign campaign)
|
||||
{
|
||||
Campaign = campaign;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Domain event raised when a campaign is activated.
|
||||
/// VI: Domain event phát ra khi chiến dịch được kích hoạt.
|
||||
/// </summary>
|
||||
public class CampaignActivatedDomainEvent : INotification
|
||||
{
|
||||
public Guid CampaignId { get; }
|
||||
public CampaignStatus PreviousStatus { get; }
|
||||
public CampaignStatus NewStatus { get; }
|
||||
|
||||
public CampaignActivatedDomainEvent(Guid campaignId, CampaignStatus previousStatus, CampaignStatus newStatus)
|
||||
{
|
||||
CampaignId = campaignId;
|
||||
PreviousStatus = previousStatus;
|
||||
NewStatus = newStatus;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Domain event raised when campaign status changes.
|
||||
/// VI: Domain event phát ra khi trạng thái chiến dịch thay đổi.
|
||||
/// </summary>
|
||||
public class CampaignStatusChangedDomainEvent : INotification
|
||||
{
|
||||
public Guid CampaignId { get; }
|
||||
public CampaignStatus PreviousStatus { get; }
|
||||
public CampaignStatus NewStatus { get; }
|
||||
|
||||
public CampaignStatusChangedDomainEvent(Guid campaignId, CampaignStatus previousStatus, CampaignStatus newStatus)
|
||||
{
|
||||
CampaignId = campaignId;
|
||||
PreviousStatus = previousStatus;
|
||||
NewStatus = newStatus;
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
using MediatR;
|
||||
using AdsManagerService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace AdsManagerService.Domain.Events;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Domain event raised when a new Sample is created.
|
||||
/// VI: Domain event được phát ra khi một Sample mới được tạo.
|
||||
/// </summary>
|
||||
public class SampleCreatedDomainEvent : INotification
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: The newly created sample.
|
||||
/// VI: Sample mới được tạo.
|
||||
/// </summary>
|
||||
public Sample Sample { get; }
|
||||
|
||||
public SampleCreatedDomainEvent(Sample sample)
|
||||
{
|
||||
Sample = sample;
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
using MediatR;
|
||||
using AdsManagerService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace AdsManagerService.Domain.Events;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Domain event raised when Sample status changes.
|
||||
/// VI: Domain event được phát ra khi trạng thái Sample thay đổi.
|
||||
/// </summary>
|
||||
public class SampleStatusChangedDomainEvent : INotification
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: The sample ID.
|
||||
/// VI: ID của sample.
|
||||
/// </summary>
|
||||
public Guid SampleId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Previous status before the change.
|
||||
/// VI: Trạng thái trước khi thay đổi.
|
||||
/// </summary>
|
||||
public SampleStatus PreviousStatus { get; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: New status after the change.
|
||||
/// VI: Trạng thái mới sau khi thay đổi.
|
||||
/// </summary>
|
||||
public SampleStatus NewStatus { get; }
|
||||
|
||||
public SampleStatusChangedDomainEvent(
|
||||
Guid sampleId,
|
||||
SampleStatus previousStatus,
|
||||
SampleStatus newStatus)
|
||||
{
|
||||
SampleId = sampleId;
|
||||
PreviousStatus = previousStatus;
|
||||
NewStatus = newStatus;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace AdsManagerService.Domain.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Base exception for all Ads domain exceptions.
|
||||
/// VI: Exception cơ sở cho tất cả các exception của Ads domain.
|
||||
/// </summary>
|
||||
public class AdsDomainException : Exception
|
||||
{
|
||||
public AdsDomainException()
|
||||
{
|
||||
}
|
||||
|
||||
public AdsDomainException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public AdsDomainException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
namespace AdsManagerService.Domain.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Exception for Sample aggregate domain errors.
|
||||
/// VI: Exception cho các lỗi domain của Sample aggregate.
|
||||
/// </summary>
|
||||
public class SampleDomainException : DomainException
|
||||
{
|
||||
public SampleDomainException()
|
||||
{
|
||||
}
|
||||
|
||||
public SampleDomainException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public SampleDomainException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using AdsManagerService.Domain.AggregatesModel.SampleAggregate;
|
||||
using AdsManagerService.Domain.AggregatesModel.CampaignAggregate;
|
||||
using AdsManagerService.Domain.AggregatesModel.AdSetAggregate;
|
||||
using AdsManagerService.Domain.AggregatesModel.AdAggregate;
|
||||
using AdsManagerService.Domain.AggregatesModel.AudienceAggregate;
|
||||
using AdsManagerService.Domain.SeedWork;
|
||||
using AdsManagerService.Infrastructure.EntityConfigurations;
|
||||
|
||||
@@ -16,11 +19,11 @@ public class AdsManagerServiceContext : DbContext, IUnitOfWork
|
||||
private readonly IMediator _mediator;
|
||||
private IDbContextTransaction? _currentTransaction;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Samples table.
|
||||
/// VI: Bảng Samples.
|
||||
/// </summary>
|
||||
public DbSet<Sample> Samples => Set<Sample>();
|
||||
public DbSet<Campaign> Campaigns => Set<Campaign>();
|
||||
public DbSet<AdSet> AdSets => Set<AdSet>();
|
||||
public DbSet<Ad> Ads => Set<Ad>();
|
||||
public DbSet<CustomAudience> CustomAudiences => Set<CustomAudience>();
|
||||
public DbSet<LookalikeAudience> LookalikeAudiences => Set<LookalikeAudience>();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Read-only access to current transaction.
|
||||
@@ -48,10 +51,10 @@ public class AdsManagerServiceContext : DbContext, IUnitOfWork
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
// EN: Apply entity configurations
|
||||
// VI: Áp dụng các cấu hình entity
|
||||
modelBuilder.ApplyConfiguration(new SampleEntityTypeConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new SampleStatusEntityTypeConfiguration());
|
||||
// Apply entity configurations for ads entities
|
||||
modelBuilder.ApplyConfiguration(new CampaignEntityTypeConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new AdSetEntityTypeConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new AdEntityTypeConfiguration());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1,7 +1,9 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using AdsManagerService.Domain.AggregatesModel.SampleAggregate;
|
||||
using AdsManagerService.Domain.AggregatesModel.CampaignAggregate;
|
||||
using AdsManagerService.Domain.AggregatesModel.AdSetAggregate;
|
||||
using AdsManagerService.Domain.AggregatesModel.AdAggregate;
|
||||
using AdsManagerService.Infrastructure.Idempotency;
|
||||
using AdsManagerService.Infrastructure.Repositories;
|
||||
|
||||
@@ -47,7 +49,9 @@ public static class DependencyInjection
|
||||
});
|
||||
|
||||
// EN: Register repositories / VI: Đăng ký repositories
|
||||
services.AddScoped<ISampleRepository, SampleRepository>();
|
||||
services.AddScoped<ICampaignRepository, CampaignRepository>();
|
||||
services.AddScoped<IAdSetRepository, AdSetRepository>();
|
||||
services.AddScoped<IAdRepository, AdRepository>();
|
||||
|
||||
// EN: Register idempotency services / VI: Đăng ký idempotency services
|
||||
services.AddScoped<IRequestManager, RequestManager>();
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using AdsManagerService.Domain.AggregatesModel.AdAggregate;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace AdsManagerService.Infrastructure.EntityConfigurations;
|
||||
|
||||
public class AdEntityTypeConfiguration : IEntityTypeConfiguration<Ad>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Ad> builder)
|
||||
{
|
||||
builder.ToTable("ads");
|
||||
|
||||
builder.HasKey(a => a.Id);
|
||||
|
||||
builder.Property(a => a.Id).HasColumnName("id").IsRequired();
|
||||
builder.Property(a => a.AdSetId).HasColumnName("ad_set_id").IsRequired();
|
||||
builder.Property(a => a.Name).HasColumnName("name").HasMaxLength(255).IsRequired();
|
||||
builder.Property(a => a.FormatId).HasColumnName("format_id").IsRequired();
|
||||
builder.Property(a => a.StatusId).HasColumnName("status_id").IsRequired();
|
||||
builder.Property(a => a.ReviewStatusId).HasColumnName("review_status_id").IsRequired();
|
||||
|
||||
builder.Property(a => a.Headline).HasColumnName("headline").HasMaxLength(255);
|
||||
builder.Property(a => a.PrimaryText).HasColumnName("primary_text").HasMaxLength(1000);
|
||||
builder.Property(a => a.Description).HasColumnName("description").HasMaxLength(500);
|
||||
builder.Property(a => a.CallToAction).HasColumnName("call_to_action").HasMaxLength(50);
|
||||
builder.Property(a => a.DestinationUrl).HasColumnName("destination_url").HasMaxLength(2048);
|
||||
builder.Property(a => a.CreativeUrl).HasColumnName("creative_url").HasMaxLength(2048);
|
||||
|
||||
builder.Property(a => a.CreatedAt).HasColumnName("created_at").IsRequired();
|
||||
builder.Property(a => a.UpdatedAt).HasColumnName("updated_at");
|
||||
|
||||
// Indexes
|
||||
builder.HasIndex(a => a.AdSetId).HasDatabaseName("idx_ads_ad_set_id");
|
||||
builder.HasIndex(a => a.StatusId).HasDatabaseName("idx_ads_status_id");
|
||||
builder.HasIndex(a => a.ReviewStatusId).HasDatabaseName("idx_ads_review_status_id");
|
||||
|
||||
builder.Ignore(a => a.DomainEvents);
|
||||
builder.Ignore(a => a.Format);
|
||||
builder.Ignore(a => a.Status);
|
||||
builder.Ignore(a => a.ReviewStatus);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using AdsManagerService.Domain.AggregatesModel.AdSetAggregate;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace AdsManagerService.Infrastructure.EntityConfigurations;
|
||||
|
||||
public class AdSetEntityTypeConfiguration : IEntityTypeConfiguration<AdSet>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<AdSet> builder)
|
||||
{
|
||||
builder.ToTable("ad_sets");
|
||||
|
||||
builder.HasKey(a => a.Id);
|
||||
|
||||
builder.Property(a => a.Id)
|
||||
.HasColumnName("id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(a => a.CampaignId)
|
||||
.HasColumnName("campaign_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(a => a.Name)
|
||||
.HasColumnName("name")
|
||||
.HasMaxLength(255)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(a => a.StatusId)
|
||||
.HasColumnName("status_id")
|
||||
.IsRequired();
|
||||
|
||||
// Targeting as owned type
|
||||
builder.OwnsOne(a => a.Targeting, targeting =>
|
||||
{
|
||||
targeting.Property(t => t.MinAge).HasColumnName("target_min_age");
|
||||
targeting.Property(t => t.MaxAge).HasColumnName("target_max_age");
|
||||
targeting.Property(t => t.Genders).HasColumnName("target_genders").HasMaxLength(50);
|
||||
targeting.Property(t => t.Locations).HasColumnName("target_locations").HasMaxLength(500);
|
||||
targeting.Property(t => t.Interests).HasColumnName("target_interests").HasMaxLength(500);
|
||||
targeting.Property(t => t.CustomAudienceIds).HasColumnName("custom_audience_ids").HasMaxLength(500);
|
||||
targeting.Property(t => t.LookalikeAudienceIds).HasColumnName("lookalike_audience_ids").HasMaxLength(500);
|
||||
});
|
||||
|
||||
// BidStrategy as owned type
|
||||
builder.OwnsOne(a => a.BidStrategy, bidStrategy =>
|
||||
{
|
||||
bidStrategy.Property(b => b.Type).HasColumnName("bid_type").IsRequired();
|
||||
bidStrategy.Property(b => b.BidAmount).HasColumnName("bid_amount").HasColumnType("decimal(18,6)");
|
||||
bidStrategy.Property(b => b.TargetCost).HasColumnName("target_cost").HasColumnType("decimal(18,6)");
|
||||
});
|
||||
|
||||
builder.Property(a => a.DailyBudget)
|
||||
.HasColumnName("daily_budget")
|
||||
.HasColumnType("decimal(18,2)")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(a => a.StartDate).HasColumnName("start_date");
|
||||
builder.Property(a => a.EndDate).HasColumnName("end_date");
|
||||
builder.Property(a => a.CreatedAt).HasColumnName("created_at").IsRequired();
|
||||
builder.Property(a => a.UpdatedAt).HasColumnName("updated_at");
|
||||
|
||||
// Indexes
|
||||
builder.HasIndex(a => a.CampaignId).HasDatabaseName("idx_ad_sets_campaign_id");
|
||||
builder.HasIndex(a => a.StatusId).HasDatabaseName("idx_ad_sets_status_id");
|
||||
|
||||
builder.Ignore(a => a.DomainEvents);
|
||||
builder.Ignore(a => a.Status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using AdsManagerService.Domain.AggregatesModel.CampaignAggregate;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace AdsManagerService.Infrastructure.EntityConfigurations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Entity Framework Core configuration for Campaign entity.
|
||||
/// VI: Cấu hình EF Core cho entity Campaign.
|
||||
/// </summary>
|
||||
public class CampaignEntityTypeConfiguration : IEntityTypeConfiguration<Campaign>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Campaign> builder)
|
||||
{
|
||||
builder.ToTable("campaigns");
|
||||
|
||||
builder.HasKey(c => c.Id);
|
||||
|
||||
builder.Property(c => c.Id)
|
||||
.HasColumnName("id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(c => c.AdvertiserId)
|
||||
.HasColumnName("advertiser_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(c => c.Name)
|
||||
.HasColumnName("name")
|
||||
.HasMaxLength(255)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(c => c.Description)
|
||||
.HasColumnName("description")
|
||||
.HasMaxLength(1000);
|
||||
|
||||
builder.Property(c => c.StatusId)
|
||||
.HasColumnName("status_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(c => c.ObjectiveId)
|
||||
.HasColumnName("objective_id")
|
||||
.IsRequired();
|
||||
|
||||
// Budget as owned type
|
||||
builder.OwnsOne(c => c.Budget, budget =>
|
||||
{
|
||||
budget.Property(b => b.Type)
|
||||
.HasColumnName("budget_type")
|
||||
.IsRequired();
|
||||
|
||||
budget.Property(b => b.Amount)
|
||||
.HasColumnName("budget_amount")
|
||||
.HasColumnType("decimal(18,2)")
|
||||
.IsRequired();
|
||||
|
||||
budget.Property(b => b.Currency)
|
||||
.HasColumnName("currency")
|
||||
.HasMaxLength(10)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
builder.Property(c => c.StartDate)
|
||||
.HasColumnName("start_date");
|
||||
|
||||
builder.Property(c => c.EndDate)
|
||||
.HasColumnName("end_date");
|
||||
|
||||
builder.Property(c => c.TotalSpend)
|
||||
.HasColumnName("total_spend")
|
||||
.HasColumnType("decimal(18,2)")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
builder.Property(c => c.CreatedAt)
|
||||
.HasColumnName("created_at")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(c => c.UpdatedAt)
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
// Indexes
|
||||
builder.HasIndex(c => c.AdvertiserId).HasDatabaseName("idx_campaigns_advertiser_id");
|
||||
builder.HasIndex(c => c.StatusId).HasDatabaseName("idx_campaigns_status_id");
|
||||
builder.HasIndex(c => c.CreatedAt).HasDatabaseName("idx_campaigns_created_at");
|
||||
|
||||
// Ignore navigation properties
|
||||
builder.Ignore(c => c.DomainEvents);
|
||||
builder.Ignore(c => c.Status);
|
||||
builder.Ignore(c => c.Objective);
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using AdsManagerService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace AdsManagerService.Infrastructure.EntityConfigurations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core configuration for Sample entity.
|
||||
/// VI: Cấu hình EF Core cho entity Sample.
|
||||
/// </summary>
|
||||
public class SampleEntityTypeConfiguration : IEntityTypeConfiguration<Sample>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Sample> builder)
|
||||
{
|
||||
// EN: Table name / VI: Tên bảng
|
||||
builder.ToTable("samples");
|
||||
|
||||
// EN: Primary key / VI: Khóa chính
|
||||
builder.HasKey(s => s.Id);
|
||||
|
||||
// EN: Ignore domain events (not persisted)
|
||||
// VI: Bỏ qua domain events (không lưu)
|
||||
builder.Ignore(s => s.DomainEvents);
|
||||
|
||||
// EN: Properties / VI: Các thuộc tính
|
||||
builder.Property(s => s.Id)
|
||||
.HasColumnName("id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<string>("_name")
|
||||
.HasColumnName("name")
|
||||
.HasMaxLength(200)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<string?>("_description")
|
||||
.HasColumnName("description")
|
||||
.HasMaxLength(1000);
|
||||
|
||||
builder.Property<DateTime>("_createdAt")
|
||||
.HasColumnName("created_at")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<DateTime?>("_updatedAt")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
// EN: Status relationship / VI: Quan hệ với Status
|
||||
builder.Property(s => s.StatusId)
|
||||
.HasColumnName("status_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.HasOne(s => s.Status)
|
||||
.WithMany()
|
||||
.HasForeignKey(s => s.StatusId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// EN: Indexes / VI: Các index
|
||||
builder.HasIndex("_name");
|
||||
builder.HasIndex(s => s.StatusId);
|
||||
builder.HasIndex("_createdAt");
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using AdsManagerService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace AdsManagerService.Infrastructure.EntityConfigurations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core configuration for SampleStatus enumeration.
|
||||
/// VI: Cấu hình EF Core cho enumeration SampleStatus.
|
||||
/// </summary>
|
||||
public class SampleStatusEntityTypeConfiguration : IEntityTypeConfiguration<SampleStatus>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SampleStatus> builder)
|
||||
{
|
||||
// EN: Table name / VI: Tên bảng
|
||||
builder.ToTable("sample_statuses");
|
||||
|
||||
// EN: Primary key / VI: Khóa chính
|
||||
builder.HasKey(s => s.Id);
|
||||
|
||||
builder.Property(s => s.Id)
|
||||
.HasColumnName("id")
|
||||
.ValueGeneratedNever()
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(s => s.Name)
|
||||
.HasColumnName("name")
|
||||
.HasMaxLength(50)
|
||||
.IsRequired();
|
||||
|
||||
// EN: Seed initial data / VI: Seed dữ liệu ban đầu
|
||||
builder.HasData(
|
||||
SampleStatus.Draft,
|
||||
SampleStatus.Active,
|
||||
SampleStatus.Completed,
|
||||
SampleStatus.Cancelled
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using AdsManagerService.Domain.AggregatesModel.AdAggregate;
|
||||
using AdsManagerService.Domain.SeedWork;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace AdsManagerService.Infrastructure.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Repository implementation for Ad aggregate.
|
||||
/// VI: Triển khai repository cho Ad aggregate.
|
||||
/// </summary>
|
||||
public class AdRepository : IAdRepository
|
||||
{
|
||||
private readonly AdsManagerServiceContext _context;
|
||||
|
||||
public IUnitOfWork UnitOfWork => _context;
|
||||
|
||||
public AdRepository(AdsManagerServiceContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
public Ad Add(Ad ad)
|
||||
{
|
||||
return _context.Ads.Add(ad).Entity;
|
||||
}
|
||||
|
||||
public void Update(Ad ad)
|
||||
{
|
||||
_context.Entry(ad).State = EntityState.Modified;
|
||||
}
|
||||
|
||||
public async Task<Ad?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Ads
|
||||
.FirstOrDefaultAsync(a => a.Id == id, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using AdsManagerService.Domain.AggregatesModel.AdSetAggregate;
|
||||
using AdsManagerService.Domain.SeedWork;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace AdsManagerService.Infrastructure.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Repository implementation for AdSet aggregate.
|
||||
/// VI: Triển khai repository cho AdSet aggregate.
|
||||
/// </summary>
|
||||
public class AdSetRepository : IAdSetRepository
|
||||
{
|
||||
private readonly AdsManagerServiceContext _context;
|
||||
|
||||
public IUnitOfWork UnitOfWork => _context;
|
||||
|
||||
public AdSetRepository(AdsManagerServiceContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
public AdSet Add(AdSet adSet)
|
||||
{
|
||||
return _context.AdSets.Add(adSet).Entity;
|
||||
}
|
||||
|
||||
public void Update(AdSet adSet)
|
||||
{
|
||||
_context.Entry(adSet).State = EntityState.Modified;
|
||||
}
|
||||
|
||||
public async Task<AdSet?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.AdSets
|
||||
.FirstOrDefaultAsync(a => a.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<AdSet>> GetByCampaignIdAsync(Guid campaignId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.AdSets
|
||||
.Where(a => a.CampaignId == campaignId)
|
||||
.OrderByDescending(a => a.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using AdsManagerService.Domain.AggregatesModel.CampaignAggregate;
|
||||
using AdsManagerService.Domain.SeedWork;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace AdsManagerService.Infrastructure.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Repository implementation for Campaign aggregate.
|
||||
/// VI: Triển khai repository cho Campaign aggregate.
|
||||
/// </summary>
|
||||
public class CampaignRepository : ICampaignRepository
|
||||
{
|
||||
private readonly AdsManagerServiceContext _context;
|
||||
|
||||
public IUnitOfWork UnitOfWork => _context;
|
||||
|
||||
public CampaignRepository(AdsManagerServiceContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
public Campaign Add(Campaign campaign)
|
||||
{
|
||||
return _context.Campaigns.Add(campaign).Entity;
|
||||
}
|
||||
|
||||
public void Update(Campaign campaign)
|
||||
{
|
||||
_context.Entry(campaign).State = EntityState.Modified;
|
||||
}
|
||||
|
||||
public async Task<Campaign?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Campaigns
|
||||
.FirstOrDefaultAsync(c => c.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Campaign>> GetByAdvertiserIdAsync(Guid advertiserId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Campaigns
|
||||
.Where(c => c.AdvertiserId == advertiserId)
|
||||
.OrderByDescending(c => c.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Campaign>> GetActiveAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Campaigns
|
||||
.Where(c => c.StatusId == CampaignStatus.Active.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using AdsManagerService.Domain.AggregatesModel.SampleAggregate;
|
||||
using AdsManagerService.Domain.SeedWork;
|
||||
|
||||
namespace AdsManagerService.Infrastructure.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Repository implementation for Sample aggregate.
|
||||
/// VI: Triển khai repository cho Sample aggregate.
|
||||
/// </summary>
|
||||
public class SampleRepository : ISampleRepository
|
||||
{
|
||||
private readonly AdsManagerServiceContext _context;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Unit of work for transaction management.
|
||||
/// VI: Unit of work cho quản lý transaction.
|
||||
/// </summary>
|
||||
public IUnitOfWork UnitOfWork => _context;
|
||||
|
||||
public SampleRepository(AdsManagerServiceContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<Sample?> GetAsync(Guid sampleId)
|
||||
{
|
||||
var sample = await _context.Samples
|
||||
.Include(s => s.Status)
|
||||
.FirstOrDefaultAsync(s => s.Id == sampleId);
|
||||
|
||||
return sample;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IEnumerable<Sample>> GetAllAsync()
|
||||
{
|
||||
return await _context.Samples
|
||||
.Include(s => s.Status)
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Sample Add(Sample sample)
|
||||
{
|
||||
return _context.Samples.Add(sample).Entity;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Update(Sample sample)
|
||||
{
|
||||
_context.Entry(sample).State = EntityState.Modified;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Delete(Sample sample)
|
||||
{
|
||||
_context.Samples.Remove(sample);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IEnumerable<Sample>> GetByStatusAsync(int statusId)
|
||||
{
|
||||
return await _context.Samples
|
||||
.Include(s => s.Status)
|
||||
.Where(s => s.StatusId == statusId)
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using AdsManagerService.API.Application.Commands;
|
||||
using AdsManagerService.Domain.AggregatesModel.SampleAggregate;
|
||||
using AdsManagerService.Domain.SeedWork;
|
||||
using Xunit;
|
||||
|
||||
namespace AdsManagerService.UnitTests.Application;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Unit tests for CreateSampleCommandHandler.
|
||||
/// VI: Unit tests cho CreateSampleCommandHandler.
|
||||
/// </summary>
|
||||
public class CreateSampleCommandHandlerTests
|
||||
{
|
||||
private readonly Mock<ISampleRepository> _mockRepository;
|
||||
private readonly Mock<ILogger<CreateSampleCommandHandler>> _mockLogger;
|
||||
private readonly CreateSampleCommandHandler _handler;
|
||||
|
||||
public CreateSampleCommandHandlerTests()
|
||||
{
|
||||
_mockRepository = new Mock<ISampleRepository>();
|
||||
_mockLogger = new Mock<ILogger<CreateSampleCommandHandler>>();
|
||||
|
||||
var mockUnitOfWork = new Mock<IUnitOfWork>();
|
||||
mockUnitOfWork.Setup(u => u.SaveEntitiesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
_mockRepository.SetupGet(r => r.UnitOfWork).Returns(mockUnitOfWork.Object);
|
||||
|
||||
_handler = new CreateSampleCommandHandler(_mockRepository.Object, _mockLogger.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithValidCommand_ShouldCreateSampleAndReturnId()
|
||||
{
|
||||
// Arrange
|
||||
var command = new CreateSampleCommand("Test Sample", "Test Description");
|
||||
|
||||
_mockRepository.Setup(r => r.Add(It.IsAny<Sample>()))
|
||||
.Returns((Sample s) => s);
|
||||
|
||||
// Act
|
||||
var result = await _handler.Handle(command, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Id.Should().NotBeEmpty();
|
||||
_mockRepository.Verify(r => r.Add(It.IsAny<Sample>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithValidCommand_ShouldCallSaveEntities()
|
||||
{
|
||||
// Arrange
|
||||
var command = new CreateSampleCommand("Test Sample", null);
|
||||
|
||||
// Act
|
||||
await _handler.Handle(command, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
_mockRepository.Verify(r => r.UnitOfWork.SaveEntitiesAsync(It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
using FluentAssertions;
|
||||
using AdsManagerService.Domain.AggregatesModel.SampleAggregate;
|
||||
using AdsManagerService.Domain.Exceptions;
|
||||
using Xunit;
|
||||
|
||||
namespace AdsManagerService.UnitTests.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Unit tests for Sample aggregate.
|
||||
/// VI: Unit tests cho Sample aggregate.
|
||||
/// </summary>
|
||||
public class SampleAggregateTests
|
||||
{
|
||||
[Fact]
|
||||
public void CreateSample_WithValidName_ShouldCreateWithDraftStatus()
|
||||
{
|
||||
// Arrange
|
||||
var name = "Test Sample";
|
||||
var description = "Test Description";
|
||||
|
||||
// Act
|
||||
var sample = new Sample(name, description);
|
||||
|
||||
// Assert
|
||||
sample.Name.Should().Be(name);
|
||||
sample.Description.Should().Be(description);
|
||||
sample.Status.Should().Be(SampleStatus.Draft);
|
||||
sample.Id.Should().NotBeEmpty();
|
||||
sample.DomainEvents.Should().ContainSingle(); // SampleCreatedDomainEvent
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateSample_WithEmptyName_ShouldThrowException()
|
||||
{
|
||||
// Arrange
|
||||
var name = "";
|
||||
|
||||
// Act
|
||||
var act = () => new Sample(name);
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<SampleDomainException>()
|
||||
.WithMessage("Sample name cannot be empty");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Activate_WhenDraft_ShouldChangeToActive()
|
||||
{
|
||||
// Arrange
|
||||
var sample = new Sample("Test Sample");
|
||||
sample.ClearDomainEvents();
|
||||
|
||||
// Act
|
||||
sample.Activate();
|
||||
|
||||
// Assert
|
||||
sample.Status.Should().Be(SampleStatus.Active);
|
||||
sample.DomainEvents.Should().ContainSingle(); // SampleStatusChangedDomainEvent
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Activate_WhenNotDraft_ShouldThrowException()
|
||||
{
|
||||
// Arrange
|
||||
var sample = new Sample("Test Sample");
|
||||
sample.Activate();
|
||||
|
||||
// Act
|
||||
var act = () => sample.Activate();
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<SampleDomainException>()
|
||||
.WithMessage("Only draft samples can be activated");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Complete_WhenActive_ShouldChangeToCompleted()
|
||||
{
|
||||
// Arrange
|
||||
var sample = new Sample("Test Sample");
|
||||
sample.Activate();
|
||||
sample.ClearDomainEvents();
|
||||
|
||||
// Act
|
||||
sample.Complete();
|
||||
|
||||
// Assert
|
||||
sample.Status.Should().Be(SampleStatus.Completed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cancel_WhenDraftOrActive_ShouldChangeToCancelled()
|
||||
{
|
||||
// Arrange
|
||||
var sample = new Sample("Test Sample");
|
||||
|
||||
// Act
|
||||
sample.Cancel();
|
||||
|
||||
// Assert
|
||||
sample.Status.Should().Be(SampleStatus.Cancelled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cancel_WhenCompleted_ShouldThrowException()
|
||||
{
|
||||
// Arrange
|
||||
var sample = new Sample("Test Sample");
|
||||
sample.Activate();
|
||||
sample.Complete();
|
||||
|
||||
// Act
|
||||
var act = () => sample.Cancel();
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<SampleDomainException>()
|
||||
.WithMessage("Cannot cancel a completed sample");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_WhenNotCancelled_ShouldUpdateNameAndDescription()
|
||||
{
|
||||
// Arrange
|
||||
var sample = new Sample("Original Name", "Original Description");
|
||||
var newName = "Updated Name";
|
||||
var newDescription = "Updated Description";
|
||||
|
||||
// Act
|
||||
sample.Update(newName, newDescription);
|
||||
|
||||
// Assert
|
||||
sample.Name.Should().Be(newName);
|
||||
sample.Description.Should().Be(newDescription);
|
||||
sample.UpdatedAt.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_WhenCancelled_ShouldThrowException()
|
||||
{
|
||||
// Arrange
|
||||
var sample = new Sample("Test Sample");
|
||||
sample.Cancel();
|
||||
|
||||
// Act
|
||||
var act = () => sample.Update("New Name", null);
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<SampleDomainException>()
|
||||
.WithMessage("Cannot update a cancelled sample");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user