feat: Add functional tests for OrderService and update InventoryService command and idempotency logic.

This commit is contained in:
Ho Ngoc Hai
2026-01-18 00:19:46 +07:00
parent 844e40f818
commit 811ddd1e19
384 changed files with 6939 additions and 2793 deletions

View 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/)

View 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)

View File

@@ -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/)

View 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)

View 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

View 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

View 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

View 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}"

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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" />

View File

@@ -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; }
}

View File

@@ -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);
}
}

View File

@@ -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>;

View File

@@ -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;
}
}

View File

@@ -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; }
}

View File

@@ -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;
}
}

View File

@@ -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; }
}

View File

@@ -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;
}
}

View File

@@ -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; }
}

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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>;

View File

@@ -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;
}
}

View File

@@ -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; }
}

View File

@@ -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);
}
}

View File

@@ -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>;

View File

@@ -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;
}
}

View File

@@ -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; }
}

View File

@@ -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
};
}
}

View File

@@ -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
);

View File

@@ -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
);
}
}

View File

@@ -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>>;

View File

@@ -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
));
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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" });
}
}

View File

@@ -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();
}
}

View File

@@ -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; }
}

View File

@@ -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);

View File

@@ -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 };
}

View File

@@ -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);
}

View File

@@ -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 };
}

View File

@@ -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
}

View File

@@ -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);
}

View File

@@ -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 ?? "";
}
}

View File

@@ -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 };
}

View File

@@ -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;
}
}

View File

@@ -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
}

View File

@@ -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}");
}

View File

@@ -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}");
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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));
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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)
{
}
}

View File

@@ -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)
{
}
}

View File

@@ -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>

View File

@@ -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>();

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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");
}
}

View File

@@ -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
);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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");
}
}

View File

@@ -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

View File

@@ -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