diff --git a/.editorconfig b/.editorconfig index f773573..7dc9cbf 100644 --- a/.editorconfig +++ b/.editorconfig @@ -69,6 +69,12 @@ csharp_style_expression_bodied_accessors = true:error csharp_style_expression_bodied_lambdas = true:error csharp_style_expression_bodied_local_functions = true:error +# S1186: Methods should not be empty +dotnet_diagnostic.S1186.severity = warning + +# CA1303: Do not pass literals as localized parameters +dotnet_diagnostic.CA1303.severity = none + [*.{cs,vb}] dotnet_style_prefer_simplified_boolean_expressions = true:suggestion dotnet_style_prefer_simplified_interpolation = true:suggestion diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..bc18f00 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "nuget" + directory: "/" + schedule: + interval: "daily" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..7f55c4d --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,66 @@ +name: Build + +on: + workflow_dispatch: + push: + branches: + - main + + # Pull requests with specific types will trigger the build action + pull_request: + branches: + - main + types: + - opened + - synchronize + - reopened + - ready_for_review + +env: + DOTNET_VERSION: "8.x" + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + dotnet-version: ['8.x'] + os: [ubuntu-latest] + configuration: [Release] + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Cache NuGet packages + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} + restore-keys: | + ${{ runner.os }}-nuget- + + - name: Restore + run: dotnet restore LegalAssistant.AppService.sln + + - name: Build + run: dotnet build LegalAssistant.AppService.sln --configuration ${{ matrix.configuration }} --no-restore + + - name: Test + run: dotnet test LegalAssistant.AppService.sln --configuration ${{ matrix.configuration }} --no-restore --no-build --verbosity normal --collect:"XPlat Code Coverage" + + - name: Code Analysis + run: dotnet build LegalAssistant.AppService.sln --configuration ${{ matrix.configuration }} --verbosity normal /p:TreatWarningsAsErrors=true + + - name: Publish Web API + run: dotnet publish src/Web.Api/Web.Api.csproj --configuration ${{ matrix.configuration }} --no-restore --no-build --output ./publish + + - name: Upload Build Artifacts + uses: actions/upload-artifact@v4 + with: + name: published-app + path: ./publish diff --git a/BUILD_SCRIPTS.md b/BUILD_SCRIPTS.md new file mode 100644 index 0000000..1328a9b --- /dev/null +++ b/BUILD_SCRIPTS.md @@ -0,0 +1,289 @@ +# 🏗️ Build Scripts - Legal Assistant App + +Hướng dẫn build project với SonarAnalyzer để check lỗi code quality như trên CI/CD. + +## 📋 Mục lục +- [Build All Projects (Solution)](#-build-all-projects-solution) +- [Build Individual Projects](#-build-individual-projects) +- [Quick Commands](#-quick-commands) +- [PowerShell Scripts](#-powershell-scripts) +- [Troubleshooting](#-troubleshooting) + +--- + +## 🎯 Build All Projects (Solution) + +### Debug Build (Development) +```bash +# Build nhanh cho development +dotnet build LegalAssistant.AppService.sln --configuration Debug +``` + +### Release Build (Production/CI-like) +```bash +# Build giống CI/CD với SonarAnalyzer enabled +dotnet build LegalAssistant.AppService.sln --configuration Release --verbosity normal /p:TreatWarningsAsErrors=true +``` + +### Full CI/CD Simulation +```bash +# Restore packages +dotnet restore LegalAssistant.AppService.sln + +# Build with strict analysis +dotnet build LegalAssistant.AppService.sln --configuration Release --no-restore --verbosity normal /p:TreatWarningsAsErrors=true + +# Run tests +dotnet test LegalAssistant.AppService.sln --configuration Release --no-restore --no-build --verbosity normal + +# Code Analysis (same as CI/CD) +dotnet build LegalAssistant.AppService.sln --configuration Release --verbosity normal /p:TreatWarningsAsErrors=true +``` + +--- + +## 🔧 Build Individual Projects + +### Domain Layer +```bash +# Check Domain layer issues +dotnet build src/Domain/Domain.csproj --configuration Release /p:TreatWarningsAsErrors=true +``` + +### Application Layer +```bash +# Check Application layer issues +dotnet build src/Application/Application.csproj --configuration Release /p:TreatWarningsAsErrors=true +``` + +### Infrastructure Layer +```bash +# Check Infrastructure layer issues +dotnet build src/Infrastructure/Infrastructure.csproj --configuration Release /p:TreatWarningsAsErrors=true +``` + +### Web API +```bash +# Check Web API issues +dotnet build src/Web.Api/Web.Api.csproj --configuration Release /p:TreatWarningsAsErrors=true +``` + +--- + +## ⚡ Quick Commands + +### Check All SonarAnalyzer Issues +```bash +dotnet build --configuration Release /p:TreatWarningsAsErrors=true +``` + +### Build & Test Pipeline +```bash +dotnet restore && dotnet build --configuration Release /p:TreatWarningsAsErrors=true && dotnet test --no-build --configuration Release +``` + +### Clean & Full Rebuild +```bash +dotnet clean && dotnet restore && dotnet build --configuration Release /p:TreatWarningsAsErrors=true +``` + +--- + +## 📜 PowerShell Scripts + +### Build Script (`build.ps1`) +```powershell +# Save this as build.ps1 +param( + [string]$Configuration = "Release", + [switch]$SkipTests, + [switch]$Verbose +) + +Write-Host "🏗️ Building Legal Assistant App..." -ForegroundColor Green + +# Set verbosity +$verbosity = if ($Verbose) { "normal" } else { "minimal" } + +try { + # Restore packages + Write-Host "📦 Restoring packages..." -ForegroundColor Yellow + dotnet restore LegalAssistant.AppService.sln + + if ($LASTEXITCODE -ne 0) { throw "Restore failed" } + + # Build solution + Write-Host "🔨 Building solution..." -ForegroundColor Yellow + dotnet build LegalAssistant.AppService.sln --configuration $Configuration --no-restore --verbosity $verbosity /p:TreatWarningsAsErrors=true + + if ($LASTEXITCODE -ne 0) { throw "Build failed" } + + # Run tests (optional) + if (-not $SkipTests) { + Write-Host "🧪 Running tests..." -ForegroundColor Yellow + dotnet test LegalAssistant.AppService.sln --configuration $Configuration --no-restore --no-build --verbosity $verbosity + + if ($LASTEXITCODE -ne 0) { throw "Tests failed" } + } + + Write-Host "✅ Build completed successfully!" -ForegroundColor Green +} +catch { + Write-Host "❌ Build failed: $_" -ForegroundColor Red + exit 1 +} +``` + +### Quick Check Script (`check.ps1`) +```powershell +# Save this as check.ps1 +Write-Host "🔍 Quick SonarAnalyzer Check..." -ForegroundColor Cyan + +dotnet build --configuration Release /p:TreatWarningsAsErrors=true + +if ($LASTEXITCODE -eq 0) { + Write-Host "✅ No issues found!" -ForegroundColor Green +} else { + Write-Host "❌ Issues detected. Fix them before CI/CD." -ForegroundColor Red +} +``` + +### Project-specific Script (`build-project.ps1`) +```powershell +# Save this as build-project.ps1 +param( + [Parameter(Mandatory=$true)] + [ValidateSet("Domain", "Application", "Infrastructure", "Web.Api")] + [string]$Project +) + +$projectPath = "src/$Project/$Project.csproj" +Write-Host "🏗️ Building $Project..." -ForegroundColor Green + +dotnet build $projectPath --configuration Release /p:TreatWarningsAsErrors=true + +if ($LASTEXITCODE -eq 0) { + Write-Host "✅ $Project build successful!" -ForegroundColor Green +} else { + Write-Host "❌ $Project build failed!" -ForegroundColor Red +} +``` + +--- + +## 🐛 Troubleshooting + +### Common SonarAnalyzer Issues + +#### S1186: Empty Methods +```csharp +// ❌ BAD +public void ProcessData() { } + +// ✅ GOOD +public void ProcessData() +{ + // Intentionally empty - placeholder for future implementation +} +``` + +#### S1481: Unused Variables +```csharp +// ❌ BAD +var result = GetData(); + +// ✅ GOOD +var result = GetData(); +Console.WriteLine($"Result: {result}"); +``` + +#### S3400: Methods Returning Constants +```csharp +// ❌ BAD +private bool IsEnabled() => false; + +// ✅ GOOD +private const bool DefaultEnabledStatus = false; +private bool IsEnabled() => DefaultEnabledStatus; +``` + +### Disable Specific Rules Temporarily +```csharp +#pragma warning disable S1186 // Empty methods +public void PlaceholderMethod() { } +#pragma warning restore S1186 +``` + +### Suppress Rules in .editorconfig +```ini +# Add to .editorconfig for project-wide suppression +dotnet_diagnostic.S1186.severity = none +dotnet_diagnostic.S1481.severity = none +``` + +--- + +## 📊 CI/CD Integration + +### GitHub Actions Equivalent +```yaml +# This is what runs on CI/CD +- name: Build + run: dotnet build LegalAssistant.AppService.sln --configuration Release --no-restore + +- name: Code Analysis + run: dotnet build LegalAssistant.AppService.sln --configuration Release --verbosity normal /p:TreatWarningsAsErrors=true +``` + +### Local Testing Before Push +```bash +# Run this before committing to ensure CI/CD will pass +dotnet restore LegalAssistant.AppService.sln +dotnet build LegalAssistant.AppService.sln --configuration Release --verbosity normal /p:TreatWarningsAsErrors=true +dotnet test LegalAssistant.AppService.sln --configuration Release --no-restore --no-build +``` + +--- + +## 🎯 Usage Examples + +### Daily Development +```bash +# Quick check during development +./check.ps1 + +# Or using dotnet directly +dotnet build --configuration Release /p:TreatWarningsAsErrors=true +``` + +### Before Committing +```bash +# Full validation +./build.ps1 -Verbose + +# Or manual steps +dotnet clean +dotnet restore +dotnet build --configuration Release /p:TreatWarningsAsErrors=true +dotnet test --configuration Release --no-build +``` + +### Project-specific Issues +```bash +# Check specific layer +./build-project.ps1 -Project Domain +./build-project.ps1 -Project Application +./build-project.ps1 -Project Infrastructure +./build-project.ps1 -Project Web.Api +``` + +--- + +## 📝 Notes + +- **Release Configuration**: Bắt buộc để enable tất cả SonarAnalyzer rules như CI/CD +- **TreatWarningsAsErrors**: Biến warnings thành errors để match CI/CD behavior +- **Verbosity**: Dùng `normal` để thấy chi tiết lỗi, `minimal` cho output ngắn gọn +- **No-restore/No-build**: Tối ưu performance khi chạy nhiều commands liên tiếp + +💡 **Tip**: Bookmark file này và chạy `check.ps1` thường xuyên để tránh surprise trên CI/CD! diff --git a/Directory.Build.props b/Directory.Build.props index f588c79..7a4ddf0 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -14,10 +14,10 @@ true true - + \ No newline at end of file diff --git a/EXCEPTION_GUIDELINES.md b/EXCEPTION_GUIDELINES.md new file mode 100644 index 0000000..e7cf098 --- /dev/null +++ b/EXCEPTION_GUIDELINES.md @@ -0,0 +1,644 @@ +# 🚨 Exception Guidelines - Legal Assistant App + +Hướng dẫn sử dụng Exception trong Clean Architecture với các tình huống cụ thể. + +## 📋 Mục lục +- [Exception Hierarchy](#-exception-hierarchy) +- [Built-in .NET Exceptions](#-built-in-net-exceptions) +- [Custom Exceptions](#-custom-exceptions) +- [Usage by Layer](#-usage-by-layer) +- [Best Practices](#-best-practices) +- [Examples](#-examples) + +--- + +## 🏗️ Exception Hierarchy + +``` +Exception +├── SystemException +│ ├── ArgumentException +│ │ ├── ArgumentNullException +│ │ └── ArgumentOutOfRangeException +│ ├── InvalidOperationException +│ ├── NotSupportedException +│ └── NotImplementedException +└── ApplicationException (Custom base) + ├── ValidationException + ├── NotFoundException + ├── UnauthorizedException + ├── BusinessRuleViolationException + ├── RequestProcessingException + └── DomainEventDispatchException +``` + +--- + +## 🔧 Built-in .NET Exceptions + +### **ArgumentException Family** +```csharp +// ArgumentNullException - Parameter is null +public void CreateUser(string email) +{ + if (email is null) + throw new ArgumentNullException(nameof(email)); +} + +// ArgumentException - Invalid parameter value +public void SetAge(int age) +{ + if (age < 0) + throw new ArgumentException("Age cannot be negative", nameof(age)); +} + +// ArgumentOutOfRangeException - Parameter out of valid range +public void SetPage(int page) +{ + if (page < 1 || page > 1000) + throw new ArgumentOutOfRangeException(nameof(page), "Page must be between 1 and 1000"); +} +``` + +### **InvalidOperationException** +```csharp +// Object state issues +public void StartGame() +{ + if (IsGameRunning) + throw new InvalidOperationException("Game is already running"); +} + +// Method call sequence issues +public void SendMessage() +{ + if (!IsConnected) + throw new InvalidOperationException("Must connect before sending messages"); +} + +// Collection state issues +public T Dequeue() +{ + if (Count == 0) + throw new InvalidOperationException("Queue is empty"); +} +``` + +### **NotSupportedException** +```csharp +// Feature not supported +public void ReadOnlyOperation() +{ + throw new NotSupportedException("This collection is read-only"); +} +``` + +### **NotImplementedException** +```csharp +// Placeholder for future implementation +public void FutureFeature() +{ + throw new NotImplementedException("Feature will be implemented in v2.0"); +} +``` + +--- + +## 🎯 Custom Exceptions + +### **Domain Layer Exceptions** + +#### **BusinessRuleViolationException** +```csharp +namespace Domain.Exceptions; + +public sealed class BusinessRuleViolationException : Exception +{ + public string RuleName { get; } + + public BusinessRuleViolationException(string ruleName, string message) + : base(message) + { + RuleName = ruleName; + } +} + +// Usage +if (user.Age < 18) + throw new BusinessRuleViolationException("MinimumAge", "User must be at least 18 years old"); +``` + +### **Application Layer Exceptions** + +#### **ValidationException** +```csharp +namespace Application.Common.Exceptions; + +public sealed class ValidationException : Exception +{ + public Dictionary Errors { get; } + + public ValidationException(string message) : base(message) + { + Errors = new Dictionary(); + } + + public ValidationException(Dictionary errors) + : base("One or more validation failures occurred") + { + Errors = errors; + } +} + +// Usage +var errors = new Dictionary +{ + ["Email"] = new[] { "Email is required", "Email format is invalid" }, + ["Password"] = new[] { "Password must be at least 8 characters" } +}; +throw new ValidationException(errors); +``` + +#### **NotFoundException** +```csharp +namespace Application.Common.Exceptions; + +public sealed class NotFoundException : Exception +{ + public NotFoundException(string entityName, object key) + : base($"{entityName} with key '{key}' was not found") + { + } + + public NotFoundException(string message) : base(message) + { + } +} + +// Usage +if (user == null) + throw new NotFoundException("User", userId); +``` + +#### **UnauthorizedException** +```csharp +namespace Application.Common.Exceptions; + +public sealed class UnauthorizedException : Exception +{ + public string? Permission { get; } + + public UnauthorizedException(string message) : base(message) + { + } + + public UnauthorizedException(string message, string permission) : base(message) + { + Permission = permission; + } +} + +// Usage +if (!user.HasRole("Admin")) + throw new UnauthorizedException("Access denied", "Admin"); +``` + +#### **RequestProcessingException** +```csharp +namespace Application.Common.Exceptions; + +public sealed class RequestProcessingException : Exception +{ + public RequestProcessingException(string message) : base(message) + { + } + + public RequestProcessingException(string message, Exception innerException) + : base(message, innerException) + { + } +} + +// Usage in MediatR behaviors +catch (Exception ex) +{ + throw new RequestProcessingException($"Request {requestName} failed after {duration}ms", ex); +} +``` + +### **Infrastructure Layer Exceptions** + +#### **DomainEventDispatchException** +```csharp +namespace Infrastructure.Exceptions; + +public sealed class DomainEventDispatchException : Exception +{ + public DomainEventDispatchException(string message) : base(message) + { + } + + public DomainEventDispatchException(string message, Exception innerException) + : base(message, innerException) + { + } +} + +// Usage +catch (Exception ex) +{ + throw new DomainEventDispatchException($"Failed to dispatch {eventType}", ex); +} +``` + +#### **DataPersistenceException** +```csharp +namespace Infrastructure.Exceptions; + +public sealed class DataPersistenceException : Exception +{ + public DataPersistenceException(string message) : base(message) + { + } + + public DataPersistenceException(string message, Exception innerException) + : base(message, innerException) + { + } +} + +// Usage +try +{ + await context.SaveChangesAsync(); +} +catch (DbUpdateException ex) +{ + throw new DataPersistenceException("Failed to save changes to database", ex); +} +``` + +--- + +## 🏛️ Usage by Layer + +### **Domain Layer** +```csharp +// ✅ Use for business rules +throw new BusinessRuleViolationException("UserAge", "User must be 18+"); +throw new InvalidOperationException("Cannot deactivate user with active subscriptions"); + +// ✅ Use for invariant violations +if (string.IsNullOrEmpty(Email)) + throw new ArgumentException("Email cannot be empty", nameof(Email)); + +// ❌ Don't use infrastructure exceptions +// throw new SqlException(...); // WRONG LAYER +``` + +### **Application Layer** +```csharp +// ✅ Use for validation +throw new ValidationException("Invalid input data"); + +// ✅ Use for authorization +throw new UnauthorizedException("Insufficient permissions"); + +// ✅ Use for not found +throw new NotFoundException("User", userId); + +// ✅ Use for request processing +throw new RequestProcessingException("Command processing failed", ex); + +// ❌ Don't use low-level exceptions +// throw new SqlException(...); // TOO LOW LEVEL +``` + +### **Infrastructure Layer** +```csharp +// ✅ Use for data persistence +throw new DataPersistenceException("Database save failed", ex); + +// ✅ Use for event dispatching +throw new DomainEventDispatchException("Event dispatch failed", ex); + +// ✅ Wrap low-level exceptions +try { /* database call */ } +catch (SqlException ex) +{ + throw new DataPersistenceException("SQL operation failed", ex); +} + +// ❌ Don't leak infrastructure details +// throw new SqlException(...); // DON'T LEAK TO UPPER LAYERS +``` + +### **Web API Layer** +```csharp +// ✅ Catch and convert to HTTP responses +try +{ + await mediator.Send(command); +} +catch (ValidationException ex) +{ + return BadRequest(ex.Errors); // 400 +} +catch (NotFoundException ex) +{ + return NotFound(ex.Message); // 404 +} +catch (UnauthorizedException ex) +{ + return Unauthorized(ex.Message); // 401 +} +catch (Exception ex) +{ + return StatusCode(500, "Internal server error"); // 500 +} +``` + +--- + +## 🎯 Best Practices + +### **1. Exception Naming** +```csharp +// ✅ GOOD - Descriptive and specific +ValidationException +NotFoundException +UserNotFoundexception +InvalidEmailFormatException + +// ❌ BAD - Generic and vague +Exception +ErrorException +BadDataException +``` + +### **2. Exception Messages** +```csharp +// ✅ GOOD - Specific and actionable +"User with ID '12345' was not found" +"Email format is invalid. Expected format: user@domain.com" +"Password must contain at least one uppercase letter" + +// ❌ BAD - Vague and unhelpful +"Error occurred" +"Invalid data" +"Something went wrong" +``` + +### **3. Inner Exceptions** +```csharp +// ✅ GOOD - Preserve original exception +try +{ + await database.SaveAsync(); +} +catch (SqlException ex) +{ + throw new DataPersistenceException("Failed to save user data", ex); +} + +// ❌ BAD - Lose original exception information +catch (SqlException ex) +{ + throw new DataPersistenceException("Failed to save user data"); +} +``` + +### **4. Exception Properties** +```csharp +// ✅ GOOD - Include relevant context +public class ValidationException : Exception +{ + public Dictionary Errors { get; } + public string FieldName { get; } + // ... constructors +} + +// ❌ BAD - No additional context +public class ValidationException : Exception +{ + // Only message, no additional info +} +``` + +--- + +## 💡 Examples + +### **User Registration Flow** +```csharp +// Domain Layer +public static User Create(string email, string password) +{ + // Argument validation + if (string.IsNullOrEmpty(email)) + throw new ArgumentException("Email cannot be empty", nameof(email)); + + // Business rule validation + if (!IsValidEmailFormat(email)) + throw new BusinessRuleViolationException("EmailFormat", "Email format is invalid"); + + if (password.Length < 8) + throw new BusinessRuleViolationException("PasswordLength", "Password must be at least 8 characters"); + + return new User(email, password); +} + +// Application Layer +public async Task CreateUserAsync(CreateUserCommand command) +{ + // Check if user already exists + var existingUser = await userRepository.GetByEmailAsync(command.Email); + if (existingUser != null) + throw new ValidationException("User with this email already exists"); + + // Authorization check + if (!currentUser.HasRole("Admin")) + throw new UnauthorizedException("Only admins can create users"); + + try + { + var user = User.Create(command.Email, command.Password); + await userRepository.AddAsync(user); + return user; + } + catch (BusinessRuleViolationException) + { + throw; // Re-throw domain exceptions as-is + } + catch (Exception ex) + { + throw new RequestProcessingException("Failed to create user", ex); + } +} + +// Infrastructure Layer +public async Task AddAsync(User user) +{ + try + { + context.Users.Add(user); + await context.SaveChangesAsync(); + } + catch (DbUpdateException ex) when (ex.InnerException is SqlException sqlEx && sqlEx.Number == 2627) + { + throw new DataPersistenceException("User with this email already exists", ex); + } + catch (DbUpdateException ex) + { + throw new DataPersistenceException("Failed to save user to database", ex); + } +} + +// Web API Layer +[HttpPost] +public async Task CreateUser(CreateUserCommand command) +{ + try + { + var user = await mediator.Send(command); + return CreatedAtAction(nameof(GetUser), new { id = user.Id }, user); + } + catch (ValidationException ex) + { + return BadRequest(new { message = ex.Message }); + } + catch (UnauthorizedException ex) + { + return Unauthorized(new { message = ex.Message }); + } + catch (BusinessRuleViolationException ex) + { + return BadRequest(new { rule = ex.RuleName, message = ex.Message }); + } + catch (RequestProcessingException ex) + { + logger.LogError(ex, "Failed to process create user request"); + return StatusCode(500, new { message = "Internal server error" }); + } +} +``` + +### **Global Exception Handler** +```csharp +public class GlobalExceptionMiddleware +{ + public async Task InvokeAsync(HttpContext context) + { + try + { + await next(context); + } + catch (Exception ex) + { + await HandleExceptionAsync(context, ex); + } + } + + private static async Task HandleExceptionAsync(HttpContext context, Exception exception) + { + var response = exception switch + { + ValidationException validationEx => new ApiResponse + { + StatusCode = 400, + Message = "Validation failed", + Errors = validationEx.Errors + }, + NotFoundException notFoundEx => new ApiResponse + { + StatusCode = 404, + Message = notFoundEx.Message + }, + UnauthorizedException unauthorizedEx => new ApiResponse + { + StatusCode = 401, + Message = unauthorizedEx.Message + }, + BusinessRuleViolationException businessEx => new ApiResponse + { + StatusCode = 400, + Message = businessEx.Message, + Details = new { Rule = businessEx.RuleName } + }, + RequestProcessingException requestEx => new ApiResponse + { + StatusCode = 500, + Message = "Request processing failed" + }, + _ => new ApiResponse + { + StatusCode = 500, + Message = "An unexpected error occurred" + } + }; + + context.Response.StatusCode = response.StatusCode; + await context.Response.WriteAsync(JsonSerializer.Serialize(response)); + } +} +``` + +--- + +## 📊 Exception Decision Tree + +``` +Exception occurred? +├── Is it invalid input parameter? +│ ├── Null → ArgumentNullException +│ ├── Wrong format → ArgumentException +│ └── Out of range → ArgumentOutOfRangeException +├── Is it object state issue? +│ └── Wrong state/sequence → InvalidOperationException +├── Is it business rule violation? +│ └── Domain rule broken → BusinessRuleViolationException +├── Is it validation failure? +│ └── Input validation → ValidationException +├── Is it authorization failure? +│ └── Permission denied → UnauthorizedException +├── Is it data not found? +│ └── Entity missing → NotFoundException +├── Is it infrastructure failure? +│ ├── Database → DataPersistenceException +│ ├── Event dispatch → DomainEventDispatchException +│ └── External service → ExternalServiceException +└── Is it request processing failure? + └── Pipeline error → RequestProcessingException +``` + +--- + +## 🚀 Quick Reference + +| **Scenario** | **Exception Type** | **Layer** | +|:-------------|:-------------------|:----------| +| Null parameter | `ArgumentNullException` | Any | +| Invalid parameter | `ArgumentException` | Any | +| Wrong object state | `InvalidOperationException` | Domain/Application | +| Business rule violation | `BusinessRuleViolationException` | Domain | +| Input validation failure | `ValidationException` | Application | +| Authorization failure | `UnauthorizedException` | Application | +| Entity not found | `NotFoundException` | Application | +| Database operation failure | `DataPersistenceException` | Infrastructure | +| Event dispatch failure | `DomainEventDispatchException` | Infrastructure | +| Request processing failure | `RequestProcessingException` | Application | + +--- + +## 💡 Pro Tips + +1. **🎯 Be Specific**: Use the most specific exception type available +2. **📝 Include Context**: Add relevant properties and detailed messages +3. **🔗 Preserve Stack Trace**: Always include inner exceptions +4. **🏗️ Layer Appropriate**: Use exceptions appropriate for each layer +5. **📊 Centralize Handling**: Use global exception handlers in Web API +6. **🔍 Log Strategically**: Log at the appropriate level (Error/Warning/Info) +7. **🚫 Don't Swallow**: Never catch and ignore exceptions without good reason +8. **🎭 Custom When Needed**: Create custom exceptions for domain-specific scenarios + +**Remember: Exceptions should be exceptional! Use them for error conditions, not control flow.** 🎯 diff --git a/docs/AGGREGATE_EXPLAINED.md b/docs/AGGREGATE_EXPLAINED.md new file mode 100644 index 0000000..0246e6d --- /dev/null +++ b/docs/AGGREGATE_EXPLAINED.md @@ -0,0 +1,296 @@ +# 🏗️ Aggregate - Giải thích từ A-Z + +## 🤔 **AGGREGATE LÀ GÌ?** + +**Aggregate = "Nhóm các entities liên quan + business logic quản lý chúng"** + +### **🏠 VÍ DỤ ĐỜI THƯỜNG:** + +Tưởng tượng **Aggregate** như một **ngôi nhà**: + +``` +🏠 Ngôi nhà (UserAggregate) +├── 👤 Chủ nhà (User entity) ← Aggregate Root +├── 📱 Số điện thoại (Phone entity) +├── 📧 Email (Email entity) +├── 🏠 Địa chỉ (Address entity) +└── 🔑 Quy tắc nhà (Business rules) +``` + +**Quy tắc quan trọng:** +- ✅ **Chỉ được giao tiếp với chủ nhà** (Aggregate Root) +- ❌ **Không được nói chuyện trực tiếp với phone/email** (Child entities) +- 🔒 **Chủ nhà quyết định mọi thay đổi** trong nhà + +## 🎯 **TẠI SAO CẦN AGGREGATE?** + +### **❌ Không có Aggregate - Chaos:** +```csharp +// BAD: Ai cũng có thể sửa trực tiếp entities +user.Email = "new@email.com"; // ← Không validate +user.Phone = "invalid"; // ← Không check format +user.Address.Street = ""; // ← Không check business rules +// Result: Data inconsistent, broken business rules +``` + +### **✅ Có Aggregate - Controlled:** +```csharp +// GOOD: Chỉ được thay đổi qua Aggregate +userAggregate.UpdateContactInfo("new@email.com", "0123456789"); +// ← Aggregate validates everything, ensures consistency +``` + +## 🏗️ **AGGREGATE TRONG CODE** + +### **📊 So sánh Entity vs Aggregate:** + +#### **🏛️ User Entity (Dumb Data)** +```csharp +// Domain/Entities/User.cs +public sealed class User : BaseEntity +{ + public required string Email { get; set; } + public required string FullName { get; set; } + public required string PasswordHash { get; set; } + public UserRole[] Roles { get; set; } = []; + + // Chỉ có basic methods + public bool HasRole(UserRole role) => Roles.Contains(role); + public bool IsActive => !IsDeleted; +} +``` + +#### **🏗️ User Aggregate (Smart Orchestrator)** +```csharp +// Domain/Aggregates/User/UserAggregate.cs +public sealed class UserAggregate : BaseAggregateRoot +{ + private readonly User _user; // ← Wraps entity + + // ✅ CONTROLLED CREATION + public static UserAggregate Create(string email, string fullName, string password) + { + // 🛡️ Business validation + if (string.IsNullOrWhiteSpace(email)) + throw new ArgumentException("Email required"); + + if (!IsValidEmail(email)) + throw new ArgumentException("Invalid email format"); + + if (password.Length < 8) + throw new ArgumentException("Password too short"); + + // 🏗️ Create entity + var user = new User + { + Email = email.ToLowerInvariant(), // ← Normalize + FullName = fullName.Trim(), // ← Clean + PasswordHash = HashPassword(password), // ← Hash + Roles = [UserRole.User] // ← Default + }; + + var aggregate = new UserAggregate(user); + + // 📢 Raise domain event + aggregate.AddDomainEvent(new UserCreatedEvent(user.Id, user.Email)); + + return aggregate; + } + + // ✅ CONTROLLED UPDATES + public void UpdateContactInfo(string newEmail, string newPhone) + { + // 🛡️ Business rules + if (_user.IsDeleted) + throw new InvalidOperationException("Cannot update deleted user"); + + if (!IsValidEmail(newEmail)) + throw new ArgumentException("Invalid email"); + + // 🔄 Update entity + _user.Email = newEmail.ToLowerInvariant(); + _user.PhoneNumber = newPhone; + _user.UpdatedAt = DateTime.UtcNow; + + // 📢 Raise event + AddDomainEvent(new UserContactUpdatedEvent(_user.Id, newEmail, newPhone)); + } + + // ✅ CONTROLLED DEACTIVATION + public void Deactivate(string reason) + { + // 🛡️ Business rules + if (HasActiveSubscriptions()) + throw new InvalidOperationException("Cannot deactivate user with active subscriptions"); + + if (_user.Roles.Contains(UserRole.Admin) && IsLastAdmin()) + throw new InvalidOperationException("Cannot deactivate last admin"); + + // 🔄 Soft delete + _user.IsDeleted = true; + _user.DeletedAt = DateTime.UtcNow; + + // 📢 Raise event + AddDomainEvent(new UserDeactivatedEvent(_user.Id, reason)); + } +} +``` + +## 🎯 **AGGREGATE BOUNDARIES** + +### **🔍 Câu hỏi quan trọng: "Entities nào thuộc cùng 1 Aggregate?"** + +#### **✅ GOOD: Tight Cohesion** +``` +UserAggregate: +├── User (root) ← Chính +├── UserProfile ← Belongs to User +├── UserSettings ← Belongs to User +└── UserPreferences ← Belongs to User + +ConversationAggregate: +├── Conversation (root) ← Chính +├── Message ← Belongs to Conversation +├── Attachment ← Belongs to Message +└── MessageReaction ← Belongs to Message +``` + +#### **❌ BAD: Too Big** +``` +GiantAggregate: (DON'T DO THIS!) +├── User +├── Conversation +├── Message +├── Order +├── Payment +├── Notification +└── Everything... ← TOO MUCH! +``` + +### **🎯 Quy tắc vàng:** +- **1 transaction = 1 aggregate** +- **Thay đổi cùng lúc = cùng aggregate** +- **Independent lifecycle = separate aggregates** + +## 🔄 **AGGREGATE LIFECYCLE** + +### **📍 1. Creation** +```csharp +// ❌ DON'T: Direct entity creation +var user = new User { Email = "test@test.com" }; // No validation! + +// ✅ DO: Through aggregate +var userAggregate = UserAggregate.Create("test@test.com", "John Doe", "password123"); +// ↑ Validates, normalizes, applies business rules, raises events +``` + +### **📍 2. Retrieval** +```csharp +// Repository returns entity, wrap in aggregate for business operations +var user = await _userRepository.GetByIdAsync(userId); +var userAggregate = new UserAggregate(user); +``` + +### **📍 3. Modification** +```csharp +// ❌ DON'T: Direct property changes +user.Email = "new@email.com"; // No validation! + +// ✅ DO: Through aggregate methods +userAggregate.UpdateContactInfo("new@email.com", "0123456789"); +// ↑ Validates, applies rules, raises events +``` + +### **📍 4. Persistence** +```csharp +// Save entity (not aggregate) +await _userRepository.UpdateAsync(userAggregate.GetUser()); +// Events automatically dispatched by DomainEventBehavior +``` + +## 💡 **VÍ DỤ THỰC TẾ: CONVERSATION AGGREGATE** + +```csharp +public sealed class ConversationAggregate : BaseAggregateRoot +{ + private readonly Conversation _conversation; + private readonly List _messages = []; + + // ✅ Business rule: Only owner or participants can add messages + public void AddMessage(Guid senderId, string content, MessageType type = MessageType.Text) + { + // 🛡️ Validation + if (_conversation.Status != ConversationStatus.Active) + throw new InvalidOperationException("Cannot add messages to inactive conversations"); + + if (!CanUserSendMessage(senderId)) + throw new UnauthorizedException("User not allowed to send messages"); + + if (string.IsNullOrWhiteSpace(content)) + throw new ArgumentException("Message content required"); + + // 🏗️ Create message + var message = new Message + { + ConversationId = _conversation.Id, + SenderId = senderId, + Content = content.Trim(), + Type = type, + CreatedAt = DateTime.UtcNow + }; + + _messages.Add(message); + + // 📊 Update conversation stats + _conversation.LastMessageAt = DateTime.UtcNow; + _conversation.MessageCount++; + + // 📢 Raise events + AddDomainEvent(new MessageAddedEvent(_conversation.Id, message.Id, senderId)); + + // 🔔 Notify if mention + if (content.Contains("@")) + { + var mentions = ExtractMentions(content); + AddDomainEvent(new UsersMentionedEvent(_conversation.Id, message.Id, mentions)); + } + } +} +``` + +## 🎯 **TÓM TẮT: AGGREGATE** + +### **📊 Entity vs Aggregate** + +| **Aspect** | **Entity** | **Aggregate** | +|------------|------------|---------------| +| **Purpose** | 🏛️ Data container | 🏗️ Business orchestrator | +| **Methods** | Simple getters/setters | Complex business operations | +| **Validation** | Basic format checks | Full business rules | +| **Events** | None | Domain events | +| **Usage** | Repository, mapping | Business operations | + +### **🔑 Key Points:** + +1. **Aggregate Root** = Entry point, only way to access child entities +2. **Consistency Boundary** = All changes in 1 transaction +3. **Business Logic** = Complex operations, validation, rules +4. **Domain Events** = Communication with other parts of system +5. **Encapsulation** = Hide complexity, provide clean interface + +### **🎯 When to use Aggregates:** + +✅ **Use Aggregates for:** +- Complex business operations +- Multi-entity transactions +- Business rule enforcement +- State changes that need events + +❌ **Don't use Aggregates for:** +- Simple CRUD operations +- Read-only queries +- Cross-aggregate operations + +--- + +**🎯 Think of Aggregate as "Smart Controller" for a group of related entities!** diff --git a/docs/BEHAVIORS_GUIDE.md b/docs/BEHAVIORS_GUIDE.md new file mode 100644 index 0000000..4a62eda --- /dev/null +++ b/docs/BEHAVIORS_GUIDE.md @@ -0,0 +1,237 @@ +# 🔄 MediatR Behaviors - Cross-Cutting Concerns + +**Behaviors là "middleware" cho MediatR pipeline - xử lý các concerns chung cho tất cả requests.** + +## 🎯 **TỔNG QUAN** + +### **Behaviors = Middleware cho Commands/Queries** +``` +Request → ValidationBehavior → AuthorizationBehavior → LoggingBehavior → Handler → Response +``` + +**Mỗi behavior là 1 layer xử lý cross-cutting concerns trước/sau khi handler chạy.** + +## 📋 **DANH SÁCH BEHAVIORS** + +### **1. 🛡️ ValidationBehavior** +- **Mục đích**: Validate input data trước khi xử lý +- **Khi nào chạy**: Trước handler +- **Input**: FluentValidation validators +- **Output**: Throw ValidationException nếu invalid + +```csharp +// Example: CreateUserCommand validation +public class CreateUserCommandValidator : AbstractValidator +{ + public CreateUserCommandValidator() + { + RuleFor(x => x.Email).NotEmpty().EmailAddress(); + RuleFor(x => x.FullName).NotEmpty().MinimumLength(2); + RuleFor(x => x.Password).NotEmpty().MinimumLength(8); + } +} + +// ValidationBehavior tự động chạy validator này +// Nếu fail → throw ValidationException +``` + +### **2. 🔐 AuthorizationBehavior** +- **Mục đích**: Kiểm tra quyền truy cập +- **Khi nào chạy**: Sau validation, trước handler +- **Input**: IAuthorizedRequest interface +- **Output**: Throw UnauthorizedException/ForbiddenException + +```csharp +// Command yêu cầu authorization +public record CreateUserCommand : ICommand>, + IAuthorizedRequest +{ + public AuthorizationRequirement AuthorizationRequirement => new() + { + Roles = ["Admin"], // Cần role Admin + Permissions = ["users.create"], // Cần permission users.create + RequireAuthentication = true + }; +} + +// AuthorizationBehavior sẽ check: +// - User có authenticated không? +// - User có role Admin không? +// - User có permission users.create không? +``` + +### **3. 📊 LoggingBehavior** +- **Mục đích**: Log tất cả requests/responses +- **Khi nào chạy**: Bao quanh handler (before + after) +- **Input**: Request name +- **Output**: Logs với timing + +```csharp +// Logs: +// "Starting request CreateUserCommand" +// "Completed request CreateUserCommand in 150ms" +// "Request CreateUserCommand failed after 200ms" (nếu exception) +``` + +### **4. ⚡ PerformanceBehavior** +- **Mục đích**: Monitor performance, cảnh báo slow requests +- **Khi nào chạy**: Bao quanh handler +- **Threshold**: 5 seconds (configurable) +- **Output**: Warning log cho slow requests + +```csharp +// Nếu request > 5 giây: +// "Slow request detected: CreateUserCommand took 7500ms" +``` + +### **5. 💾 CachingBehavior** +- **Mục đích**: Cache kết quả queries để tăng performance +- **Khi nào chạy**: Chỉ cho queries implement ICacheableQuery +- **Storage**: MemoryCache +- **TTL**: Configurable per query + +```csharp +// Query với caching +public record GetUserByIdQuery(Guid UserId) : IQuery, + ICacheableQuery +{ + public string CacheKey => $"user-{UserId}"; + public TimeSpan? CacheExpiration => TimeSpan.FromMinutes(10); +} + +// Flow: +// 1. Check cache → nếu có → return cached result +// 2. Nếu không → execute handler → cache result → return +``` + +### **6. 🔄 TransactionBehavior** +- **Mục đích**: Wrap commands trong database transaction +- **Khi nào chạy**: Chỉ cho commands implement ITransactionalCommand +- **Rollback**: Automatic nếu có exception + +```csharp +// Command cần transaction +public record CreateUserCommand : ICommand>, + ITransactionalCommand +{ + // Properties... +} + +// TransactionBehavior sẽ: +// 1. Begin transaction +// 2. Execute handler +// 3. Commit nếu success / Rollback nếu exception +``` + +### **7. 📢 DomainEventBehavior** +- **Mục đích**: Dispatch domain events sau khi command thành công +- **Khi nào chạy**: Sau handler (chỉ cho commands) +- **Process**: Lấy events từ aggregates → dispatch qua MediatR + +```csharp +// Flow: +// 1. Handler tạo User → UserAggregate raises UserCreatedEvent +// 2. DomainEventBehavior lấy events từ aggregates +// 3. Dispatch events → UserCreatedEventHandler.Handle() +``` + +## 🔄 **PIPELINE EXECUTION ORDER** + +``` +📥 Request comes in + ↓ +🛡️ ValidationBehavior (validate input) + ↓ +🔐 AuthorizationBehavior (check permissions) + ↓ +📊 LoggingBehavior (start logging) + ↓ +⚡ PerformanceBehavior (start timing) + ↓ +💾 CachingBehavior (check cache) + ↓ +🔄 TransactionBehavior (begin transaction) + ↓ +🎯 HANDLER EXECUTION + ↓ +🔄 TransactionBehavior (commit/rollback) + ↓ +💾 CachingBehavior (save to cache) + ↓ +⚡ PerformanceBehavior (check if slow) + ↓ +📊 LoggingBehavior (log completion) + ↓ +📢 DomainEventBehavior (dispatch events) + ↓ +📤 Response returned +``` + +## 🎯 **CÁCH SỬ DỤNG** + +### **1. Validation** +```csharp +// Tạo validator +public class CreateUserCommandValidator : AbstractValidator +{ + public CreateUserCommandValidator() + { + RuleFor(x => x.Email).NotEmpty().EmailAddress(); + } +} + +// ValidationBehavior tự động chạy +``` + +### **2. Authorization** +```csharp +// Implement IAuthorizedRequest +public record DeleteUserCommand : ICommand, IAuthorizedRequest +{ + public AuthorizationRequirement AuthorizationRequirement => new() + { + Roles = ["Admin"], + RequireAuthentication = true + }; +} +``` + +### **3. Caching** +```csharp +// Implement ICacheableQuery +public record GetUsersQuery : IQuery>, ICacheableQuery +{ + public string CacheKey => "all-users"; + public TimeSpan? CacheExpiration => TimeSpan.FromMinutes(5); +} +``` + +### **4. Transaction** +```csharp +// Implement ITransactionalCommand +public record CreateUserCommand : ICommand, ITransactionalCommand +{ + // Command được wrap trong transaction tự động +} +``` + +## ✅ **LỢI ÍCH** + +| **Không có Behaviors** | **Có Behaviors** | +|------------------------|-------------------| +| ❌ Code lặp lại validation | ✅ Validation tự động | +| ❌ Authorization scattered | ✅ Centralized authorization | +| ❌ Khó debug performance | ✅ Automatic performance monitoring | +| ❌ Manual transaction management | ✅ Automatic transactions | +| ❌ Inconsistent logging | ✅ Consistent logging | + +## 🚫 **LƯU Ý** + +- **Order matters**: Behaviors chạy theo thứ tự đăng ký +- **Performance**: Behaviors thêm overhead, nhưng benefits > costs +- **Testing**: Có thể bypass behaviors trong unit tests +- **Conditional**: Behaviors chỉ chạy khi request implement interface tương ứng + +--- + +**🎯 Behaviors = "Aspect-Oriented Programming" cho Clean Architecture!** diff --git a/docs/CI_CD_EXPLAINED.md b/docs/CI_CD_EXPLAINED.md new file mode 100644 index 0000000..74f66b1 --- /dev/null +++ b/docs/CI_CD_EXPLAINED.md @@ -0,0 +1,497 @@ +# 🚀 CI/CD Pipeline Explained + +## 📖 **Mục đích tài liệu** +Giải thích chi tiết CI/CD pipeline của Legal Assistant project, giúp team hiểu rõ quy trình tự động hóa build, test và deploy. + +--- + +## 🔄 **CI/CD là gì?** + +### **🔧 CI (Continuous Integration)** +- **Tự động build** và **test** code mỗi khi có thay đổi +- **Phát hiện lỗi sớm** trước khi merge vào main branch +- **Đảm bảo code quality** thông qua automated checks + +### **🚀 CD (Continuous Deployment)** +- **Tự động deploy** code sau khi pass tất cả tests +- **Giảm thời gian** từ development đến production +- **Đảm bảo consistency** giữa các environments + +--- + +## 📋 **Pipeline Overview** + +```mermaid +graph LR + A[Code Push] --> B[Trigger CI/CD] + B --> C[Restore Dependencies] + C --> D[Build Solution] + D --> E[Run Tests] + E --> F[Code Analysis] + F --> G[Publish Artifacts] + G --> H[Upload to GitHub] +``` + +--- + +## 🎯 **Triggers - Khi nào pipeline chạy?** + +### **1. 📝 Push to main branch** +```yaml +push: + branches: + - main +``` +- **Mục đích**: Đảm bảo main branch luôn stable +- **Khi nào**: Mỗi khi merge PR hoặc push trực tiếp + +### **2. 🔀 Pull Requests** +```yaml +pull_request: + branches: + - main + types: + - opened # Khi tạo PR mới + - synchronize # Khi push thêm commits + - reopened # Khi reopen PR + - ready_for_review # Khi mark ready từ draft +``` +- **Mục đích**: Kiểm tra code trước khi merge +- **Lợi ích**: Ngăn broken code vào main branch + +### **3. 🔧 Manual Trigger** +```yaml +workflow_dispatch: +``` +- **Mục đích**: Chạy pipeline thủ công khi cần +- **Sử dụng**: Testing, debugging, emergency builds + +--- + +## 🏗️ **Build Matrix Strategy** + +```yaml +strategy: + matrix: + dotnet-version: ['8.x'] + os: [ubuntu-latest] + configuration: [Release] +``` + +### **🎯 Lợi ích:** +- **Flexibility**: Dễ dàng test multiple versions/OS +- **Scalability**: Có thể mở rộng test trên Windows, macOS +- **Future-proof**: Sẵn sàng cho .NET 9, 10... + +### **💡 Ví dụ mở rộng:** +```yaml +matrix: + dotnet-version: ['8.x', '9.x'] + os: [ubuntu-latest, windows-latest, macos-latest] + configuration: [Debug, Release] +``` + +--- + +## ⚙️ **Chi tiết các Steps** + +### **1. 📦 Checkout Code** +```yaml +- uses: actions/checkout@v4 +``` +- **Mục đích**: Download source code về runner +- **Version**: v4 (latest, secure) + +### **2. 🛠️ Setup .NET** +```yaml +- name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} +``` +- **Mục đích**: Cài đặt .NET 8 SDK +- **Environment**: `DOTNET_VERSION: "8.x"` + +### **3. 💾 Cache NuGet Packages** +```yaml +- name: Cache NuGet packages + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} + restore-keys: | + ${{ runner.os }}-nuget- +``` + +#### **🚀 Lợi ích của Caching:** +- **Tăng tốc build**: Không cần download packages đã có +- **Tiết kiệm bandwidth**: Giảm tải cho NuGet servers +- **Cache invalidation**: Tự động refresh khi .csproj thay đổi + +#### **📊 Performance Impact:** +- **Không cache**: ~2-3 phút restore +- **Có cache**: ~10-30 giây restore + +### **4. 📥 Restore Dependencies** +```yaml +- name: Restore + run: dotnet restore LegalAssistant.AppService.sln +``` +- **Mục đích**: Download NuGet packages +- **Input**: Solution file (tất cả projects) + +### **5. 🔨 Build Solution** +```yaml +- name: Build + run: dotnet build LegalAssistant.AppService.sln --configuration ${{ matrix.configuration }} --no-restore +``` + +#### **🎯 Build Parameters:** +- `--configuration Release`: Optimized build +- `--no-restore`: Skip restore (đã làm ở step trước) + +#### **📦 Build Order (Dependencies):** +``` +1. Domain.dll (no dependencies) +2. Application.dll (depends on Domain) +3. Infrastructure.dll (depends on Domain + Application) +4. Web.Api.dll (depends on all above) +``` + +### **6. 🧪 Run Tests** +```yaml +- name: Test + run: dotnet test LegalAssistant.AppService.sln --configuration ${{ matrix.configuration }} --no-restore --no-build --verbosity normal --collect:"XPlat Code Coverage" +``` + +#### **🔍 Test Parameters:** +- `--no-restore`: Skip restore +- `--no-build`: Use existing build +- `--verbosity normal`: Detailed output +- `--collect:"XPlat Code Coverage"`: Generate coverage report + +#### **📊 Coverage Benefits:** +- **Track code quality**: Phần trăm code được test +- **Identify gaps**: Code nào chưa có test +- **Trend analysis**: Coverage tăng/giảm theo thời gian + +### **7. 🔍 Code Analysis** +```yaml +- name: Code Analysis + run: dotnet build LegalAssistant.AppService.sln --configuration ${{ matrix.configuration }} --verbosity normal /p:TreatWarningsAsErrors=true +``` + +#### **🛡️ Quality Gates:** +- `TreatWarningsAsErrors=true`: Warning = build failure +- **Static analysis**: Detect potential issues +- **Code style**: Enforce consistent formatting + +#### **📋 Checks thực hiện:** +- **Security**: CA5387 (password iterations) +- **Performance**: CA1851 (multiple enumerations) +- **Style**: IDE0008 (explicit vs var) +- **Maintainability**: Complexity warnings + +### **8. 📦 Publish Application** +```yaml +- name: Publish Web API + run: dotnet publish src/Web.Api/Web.Api.csproj --configuration ${{ matrix.configuration }} --no-restore --no-build --output ./publish +``` + +#### **🎯 Publish Benefits:** +- **Self-contained**: Tất cả dependencies included +- **Optimized**: Trimmed, compressed +- **Deployment-ready**: Có thể run trực tiếp + +#### **📁 Output Structure:** +``` +./publish/ +├── Web.Api.dll +├── Web.Api.exe (Windows) +├── appsettings.json +├── wwwroot/ +└── runtimes/ (dependencies) +``` + +### **9. ☁️ Upload Artifacts** +```yaml +- name: Upload Build Artifacts + uses: actions/upload-artifact@v4 + with: + name: published-app + path: ./publish +``` + +#### **💾 Artifact Benefits:** +- **Deployment**: Download và deploy lên server +- **Rollback**: Giữ lại previous versions +- **Testing**: QA có thể test exact build +- **Distribution**: Share build với team + +--- + +## 🔄 **Workflow Execution Flow** + +### **📝 Scenario 1: Developer tạo Pull Request** + +``` +1. 👨‍💻 Developer: git push origin feature/new-auth +2. 🌐 GitHub: Tạo PR từ feature/new-auth → main +3. 🤖 CI/CD: Trigger pipeline automatically +4. ⚙️ Runner: Checkout → Setup → Cache → Restore → Build → Test → Analyze +5. ✅ Status: All checks passed ✅ +6. 👨‍💼 Reviewer: Approve và merge PR +7. 🚀 Main branch: Trigger deployment pipeline +``` + +### **❌ Scenario 2: Build failure** + +``` +1. 👨‍💻 Developer: Push code với compilation error +2. 🤖 CI/CD: Build step fails +3. 🔴 Status: ❌ Build failed - compilation error +4. 📧 Notification: Developer được notify qua email/Slack +5. 🔧 Developer: Fix lỗi và push lại +6. 🔄 CI/CD: Re-run pipeline +7. ✅ Status: All checks passed ✅ +``` + +### **🧪 Scenario 3: Test failure** + +``` +1. 👨‍💻 Developer: Push code break existing test +2. ✅ Build: Success +3. ❌ Test: 5/10 tests failed +4. 🔴 Status: ❌ Tests failed +5. 📊 Report: Detailed test results available +6. 🔧 Developer: Analyze failures, fix code +7. 🔄 Repeat: Until all tests pass +``` + +--- + +## 📊 **Performance Metrics** + +### **⏱️ Typical Pipeline Duration:** + +| Step | First Run | Cached Run | +|------|-----------|------------| +| Checkout | 10s | 10s | +| Setup .NET | 30s | 5s | +| Cache/Restore | 2m | 20s | +| Build | 1m | 45s | +| Test | 30s | 30s | +| Analysis | 20s | 15s | +| Publish | 45s | 30s | +| Upload | 15s | 10s | +| **Total** | **~5m** | **~2m45s** | + +### **💾 Storage Usage:** +- **Artifacts**: ~50MB per build +- **Cache**: ~200MB NuGet packages +- **Retention**: 90 days (configurable) + +--- + +## 🛡️ **Security Considerations** + +### **🔐 Secrets Management:** +```yaml +# ❌ NEVER do this: +- name: Deploy + run: echo "ConnectionString=Server=prod;Password=123456" + +# ✅ Correct way: +- name: Deploy + env: + CONNECTION_STRING: ${{ secrets.PROD_CONNECTION_STRING }} + run: echo "Using secure connection" +``` + +### **🛡️ Security Features đã áp dụng:** +- **No hardcoded secrets**: Sử dụng GitHub Secrets +- **Least privilege**: Runner chỉ có quyền cần thiết +- **Dependency scanning**: Automated security updates +- **Code analysis**: Detect security vulnerabilities + +--- + +## 🔧 **Customization Options** + +### **🌍 Multi-Environment Support:** + +```yaml +strategy: + matrix: + environment: [dev, staging, prod] + include: + - environment: dev + configuration: Debug + deploy_target: dev-server + - environment: staging + configuration: Release + deploy_target: staging-server + - environment: prod + configuration: Release + deploy_target: prod-server +``` + +### **🐳 Docker Integration:** + +```yaml +- name: Build Docker Image + run: | + docker build -t legal-assistant:${{ github.sha }} . + docker tag legal-assistant:${{ github.sha }} legal-assistant:latest + +- name: Push to Registry + run: | + docker push legal-assistant:${{ github.sha }} + docker push legal-assistant:latest +``` + +### **📱 Notification Setup:** + +```yaml +- name: Notify Slack on Success + if: success() + uses: rtCamp/action-slack-notify@v2 + env: + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} + SLACK_MESSAGE: '✅ Build ${{ github.sha }} deployed successfully!' + +- name: Notify Slack on Failure + if: failure() + uses: rtCamp/action-slack-notify@v2 + env: + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} + SLACK_MESSAGE: '❌ Build ${{ github.sha }} failed!' +``` + +--- + +## 📈 **Monitoring & Insights** + +### **📊 GitHub Actions Dashboard:** +- **Build success rate**: Target 95%+ +- **Average build time**: Monitor trends +- **Resource usage**: Optimize costs +- **Cache hit rate**: Optimize performance + +### **🔍 Debugging Failed Builds:** + +1. **📋 Check logs**: Click vào failed step +2. **🔄 Re-run job**: Temporary issues +3. **🐛 Local reproduction**: + ```bash + dotnet restore + dotnet build --configuration Release + dotnet test --configuration Release + ``` +4. **💬 Team discussion**: Complex issues + +--- + +## 🚀 **Best Practices** + +### **✅ DO:** +- **Small, frequent commits**: Easier to debug failures +- **Descriptive commit messages**: Help understand context +- **Keep pipeline fast**: < 5 minutes target +- **Monitor trends**: Build time, success rate +- **Use caching**: Significant speed improvement +- **Version pinning**: `@v4` instead of `@latest` + +### **❌ DON'T:** +- **Skip tests**: Always run full test suite +- **Hardcode secrets**: Use GitHub Secrets +- **Ignore warnings**: Fix them early +- **Run on every branch**: Only main + PRs +- **Complex inline scripts**: Use separate files + +--- + +## 🔮 **Future Enhancements** + +### **🎯 Roadmap:** + +1. **📊 Code Coverage Reports** + - Integration với SonarCloud + - Coverage trends tracking + - Quality gates enforcement + +2. **🐳 Container Registry** + - Docker image building + - Multi-arch support (ARM64, x86) + - Security scanning + +3. **🌍 Multi-Environment Deployment** + - Automated dev deployment + - Manual staging approval + - Blue-green production deployment + +4. **🔍 Advanced Testing** + - Integration tests với database + - End-to-end UI testing + - Performance benchmarking + +5. **📱 Enhanced Notifications** + - Slack/Teams integration + - Email reports + - Dashboard widgets + +--- + +## 🆘 **Troubleshooting Guide** + +### **❌ Common Issues:** + +#### **1. Build Timeout** +``` +Error: The job running on runner GitHub Actions X has exceeded the maximum execution time of 6 hours. +``` +**Solution:** +- Check for infinite loops +- Optimize restore/build steps +- Use more caching + +#### **2. Test Failures** +``` +Error: Test run failed with 3 failed tests +``` +**Steps:** +1. Download test results artifact +2. Analyze failed test details +3. Run tests locally with same configuration +4. Fix failing tests + +#### **3. Package Restore Failure** +``` +Error: Unable to restore package 'Microsoft.AspNetCore.App' +``` +**Solution:** +- Check NuGet.org status +- Clear cache and retry +- Verify package versions in .csproj + +#### **4. Disk Space Issues** +``` +Error: No space left on device +``` +**Solution:** +- Clean up old artifacts +- Optimize Docker layers +- Use smaller base images + +--- + +## 📚 **Additional Resources** + +- **🔗 [GitHub Actions Documentation](https://docs.github.com/en/actions)** +- **🔗 [.NET CLI Reference](https://docs.microsoft.com/en-us/dotnet/core/tools/)** +- **🔗 [ASP.NET Core Deployment](https://docs.microsoft.com/en-us/aspnet/core/host-and-deploy/)** +- **🔗 [Security Best Practices](https://docs.github.com/en/actions/security-guides)** + +--- + +**🎯 Mục tiêu: Đảm bảo code quality cao và deployment tự động, an toàn cho Legal Assistant project!** diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md new file mode 100644 index 0000000..3c7a8ed --- /dev/null +++ b/docs/COMMANDS.md @@ -0,0 +1,447 @@ +# 🛠️ Common Commands - Legal Assistant Project + +Danh sách các commands thường dùng để develop, build, test và deploy Legal Assistant application. + +--- + +## 🏗️ **Build Commands** + +### **Build toàn bộ solution** +```bash +# Build entire solution +dotnet build LegalAssistant.AppService.sln + +# Build specific configuration +dotnet build LegalAssistant.AppService.sln --configuration Release +dotnet build LegalAssistant.AppService.sln --configuration Debug +``` + +### **Build từng project riêng** +```bash +# Build Domain layer +dotnet build src/Domain/Domain.csproj + +# Build Application layer +dotnet build src/Application/Application.csproj + +# Build Infrastructure layer +dotnet build src/Infrastructure/Infrastructure.csproj + +# Build Web API +dotnet build src/Web.Api/Web.Api.csproj +``` + +### **Clean và Rebuild** +```bash +# Clean solution +dotnet clean LegalAssistant.AppService.sln + +# Clean và build lại +dotnet clean && dotnet build LegalAssistant.AppService.sln +``` + +--- + +## 🚀 **Run Commands** + +### **Chạy Web API** +```bash +# Run Web API (Production mode) +dotnet run --project src/Web.Api/Web.Api.csproj + +# Run với environment cụ thể +dotnet run --project src/Web.Api/Web.Api.csproj --environment Development +dotnet run --project src/Web.Api/Web.Api.csproj --environment Staging +dotnet run --project src/Web.Api/Web.Api.csproj --environment Production +``` + +### **Watch mode (auto-reload khi code thay đổi)** +```bash +# Auto-reload khi save file +dotnet watch run --project src/Web.Api/Web.Api.csproj + +# Watch với hot reload (C# 6+) +dotnet watch --project src/Web.Api/Web.Api.csproj +``` + +### **Chạy với URLs cụ thể** +```bash +# Chạy trên port khác +dotnet run --project src/Web.Api/Web.Api.csproj --urls "https://localhost:5001;http://localhost:5000" + +# Chạy trên tất cả interfaces +dotnet run --project src/Web.Api/Web.Api.csproj --urls "https://*:5001;http://*:5000" +``` + +--- + +## 🧪 **Test Commands** + +### **Chạy tests** +```bash +# Run tất cả tests +dotnet test LegalAssistant.AppService.sln + +# Run tests với detailed output +dotnet test LegalAssistant.AppService.sln --verbosity normal + +# Run tests với code coverage +dotnet test LegalAssistant.AppService.sln --collect:"XPlat Code Coverage" +``` + +### **Test từng project** +```bash +# Khi có test projects (future) +# dotnet test tests/Application.Tests/Application.Tests.csproj +# dotnet test tests/Infrastructure.Tests/Infrastructure.Tests.csproj +# dotnet test tests/Web.Api.Tests/Web.Api.Tests.csproj +``` + +### **Test với filters** +```bash +# Run tests theo category +dotnet test --filter Category=Unit +dotnet test --filter Category=Integration + +# Run tests theo class name +dotnet test --filter ClassName=UserServiceTests + +# Run tests theo method name +dotnet test --filter MethodName=CreateUser_ShouldReturnSuccess +``` + +--- + +## 📦 **Package Management** + +### **Restore packages** +```bash +# Restore tất cả packages +dotnet restore LegalAssistant.AppService.sln + +# Restore cho project cụ thể +dotnet restore src/Web.Api/Web.Api.csproj +``` + +### **Add/Remove packages** +```bash +# Add package vào project cụ thể +dotnet add src/Web.Api/Web.Api.csproj package Microsoft.EntityFrameworkCore +dotnet add src/Infrastructure/Infrastructure.csproj package BCrypt.Net-Next + +# Remove package +dotnet remove src/Web.Api/Web.Api.csproj package OldPackageName + +# Update package +dotnet add src/Web.Api/Web.Api.csproj package Microsoft.EntityFrameworkCore --version 8.0.0 +``` + +### **List packages** +```bash +# List packages trong project +dotnet list src/Web.Api/Web.Api.csproj package + +# List outdated packages +dotnet list package --outdated + +# List vulnerable packages +dotnet list package --vulnerable +``` + +--- + +## 🎨 **Code Formatting** + +### **Check format theo .editorconfig** +```bash +# Check format cho toàn bộ solution (sẽ fail nếu có file không đúng format) +dotnet format --verify-no-changes LegalAssistant.AppService.sln + +# Check format cho project cụ thể +dotnet format --verify-no-changes src/Web.Api/Web.Api.csproj +``` + +### **Fix formatting issues** +```bash +# Auto-fix format issues +dotnet format LegalAssistant.AppService.sln + +# Fix format cho project cụ thể +dotnet format src/Web.Api/Web.Api.csproj + +# Fix với options +dotnet format LegalAssistant.AppService.sln --include-generated +``` + +### **Style analysis** +```bash +# Run code analysis +dotnet build LegalAssistant.AppService.sln /p:TreatWarningsAsErrors=true + +# Run với specific analyzers +dotnet build --verbosity normal /p:RunAnalyzersDuringBuild=true +``` + +--- + +## 🗄️ **Database Commands** + +### **Entity Framework migrations** +```bash +# Add migration (khi có DbContext) +dotnet ef migrations add InitialCreate --project src/Infrastructure --startup-project src/Web.Api + +# Update database +dotnet ef database update --project src/Infrastructure --startup-project src/Web.Api + +# Drop database +dotnet ef database drop --project src/Infrastructure --startup-project src/Web.Api + +# Generate SQL script +dotnet ef migrations script --project src/Infrastructure --startup-project src/Web.Api +``` + +### **Database tools** +```bash +# Install EF tools globally (one time) +dotnet tool install --global dotnet-ef + +# Update EF tools +dotnet tool update --global dotnet-ef + +# Check EF tools version +dotnet ef --version +``` + +--- + +## 📦 **Publish & Deploy** + +### **Publish application** +```bash +# Publish Web API +dotnet publish src/Web.Api/Web.Api.csproj --configuration Release --output ./publish + +# Publish self-contained +dotnet publish src/Web.Api/Web.Api.csproj -c Release -r win-x64 --self-contained true + +# Publish for Linux +dotnet publish src/Web.Api/Web.Api.csproj -c Release -r linux-x64 --self-contained true +``` + +### **Docker commands** +```bash +# Build Docker image (nếu có Dockerfile) +docker build -t legal-assistant:latest . + +# Run Docker container +docker run -p 5000:8080 legal-assistant:latest + +# Run với environment variables +docker run -p 5000:8080 -e ASPNETCORE_ENVIRONMENT=Production legal-assistant:latest +``` + +--- + +## 🔍 **Development Tools** + +### **Code generation** +```bash +# Create new controller +dotnet new controller -n ConversationsController -o src/Web.Api/Controllers/V1 + +# Create new class +dotnet new class -n UserService -o src/Application/Services +``` + +### **Security scanning** +```bash +# Scan for vulnerable packages +dotnet list package --vulnerable --include-transitive + +# Audit packages +dotnet restore --use-lock-file +``` + +### **Performance profiling** +```bash +# Run with diagnostic tools +dotnet run --project src/Web.Api/Web.Api.csproj --configuration Release + +# Memory usage analysis +dotnet-counters monitor --process-id [PID] +``` + +--- + +## 🔧 **Debugging Commands** + +### **Logging & Diagnostics** +```bash +# Run với specific log level +dotnet run --project src/Web.Api/Web.Api.csproj -- --Logging:LogLevel:Default=Debug + +# Enable detailed errors +dotnet run --project src/Web.Api/Web.Api.csproj --environment Development +``` + +### **Development certificates** +```bash +# Trust development certificate +dotnet dev-certs https --trust + +# Clean và regenerate certificates +dotnet dev-certs https --clean +dotnet dev-certs https --trust +``` + +--- + +## 🌍 **Environment Management** + +### **Environment variables** +```bash +# Windows +set ASPNETCORE_ENVIRONMENT=Development +dotnet run --project src/Web.Api/Web.Api.csproj + +# Linux/Mac +export ASPNETCORE_ENVIRONMENT=Development +dotnet run --project src/Web.Api/Web.Api.csproj + +# PowerShell +$env:ASPNETCORE_ENVIRONMENT="Development" +dotnet run --project src/Web.Api/Web.Api.csproj +``` + +### **Configuration management** +```bash +# Override appsettings +dotnet run --project src/Web.Api/Web.Api.csproj -- --ConnectionStrings:DefaultConnection="YourConnectionString" + +# Use specific settings file +dotnet run --project src/Web.Api/Web.Api.csproj --environment Staging +# Will use appsettings.Staging.json +``` + +--- + +## 📊 **Monitoring & Health Checks** + +### **Health checks** +```bash +# Kiểm tra health endpoint +curl https://localhost:5001/health + +# Với detailed information +curl https://localhost:5001/health?detailed=true +``` + +### **Application URLs** +```bash +# Swagger API documentation +# https://localhost:5001/swagger + +# Health checks +# https://localhost:5001/health + +# API endpoints +# https://localhost:5001/api/v1/auth/login +# https://localhost:5001/api/v1/users +``` + +--- + +## 🚀 **Quick Start Commands** + +### **Development setup (first time)** +```bash +# 1. Clone repository +git clone [repository-url] +cd LegalAssistant.AppService + +# 2. Restore packages +dotnet restore + +# 3. Trust development certificates +dotnet dev-certs https --trust + +# 4. Run application +dotnet run --project src/Web.Api/Web.Api.csproj + +# 5. Open browser +# Navigate to: https://localhost:5001/swagger +``` + +### **Daily development workflow** +```bash +# 1. Pull latest changes +git pull origin main + +# 2. Restore any new packages +dotnet restore + +# 3. Build to check for errors +dotnet build + +# 4. Run with auto-reload +dotnet watch run --project src/Web.Api/Web.Api.csproj + +# 5. Run tests before committing +dotnet test + +# 6. Format code +dotnet format + +# 7. Commit changes +git add . +git commit -m "Your commit message" +git push +``` + +--- + +## 🆘 **Troubleshooting Commands** + +### **Common issues** +```bash +# Port already in use +netstat -ano | findstr :5001 +# Kill process: taskkill /PID [process_id] /F + +# Clear NuGet cache +dotnet nuget locals all --clear + +# Reset tool path +dotnet tool uninstall -g dotnet-ef +dotnet tool install -g dotnet-ef + +# Check .NET versions +dotnet --list-sdks +dotnet --list-runtimes + +# Verbose build output +dotnet build --verbosity diagnostic +``` + +### **Clean everything** +```bash +# Nuclear option: clean everything và rebuild +dotnet clean +rm -rf bin obj # hoặc rmdir /s bin obj trên Windows +dotnet restore +dotnet build +``` + +--- + +## 📚 **Useful References** + +- **🔗 [.NET CLI Reference](https://docs.microsoft.com/en-us/dotnet/core/tools/)** +- **🔗 [Entity Framework Core CLI](https://docs.microsoft.com/en-us/ef/core/cli/dotnet)** +- **🔗 [ASP.NET Core Environment](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/environments)** +- **🔗 [Docker with .NET](https://docs.microsoft.com/en-us/dotnet/architecture/microservices/docker-application-development-process/)** + +--- + +**💡 Tip: Bookmark trang này và sử dụng Ctrl+F để tìm command cần thiết nhanh chóng!** diff --git a/docs/DI_BEHAVIORS_EXAMPLE.md b/docs/DI_BEHAVIORS_EXAMPLE.md new file mode 100644 index 0000000..34d320c --- /dev/null +++ b/docs/DI_BEHAVIORS_EXAMPLE.md @@ -0,0 +1,177 @@ +# 🔗 Dependency Injection cho MediatR Behaviors + +## 🎯 **REGISTRATION PROCESS** + +### **Step 1: Register Generic Behaviors** +```csharp +// Trong ApplicationServiceExtensions.cs +services.AddScoped(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); +``` + +**Open Generic Type Registration:** +- `IPipelineBehavior` = Interface +- `ValidationBehavior` = Implementation + +### **Step 2: MediatR Request đến** +```csharp +// User gửi request: +var command = new CreateUserCommand +{ + Email = "user@example.com", + FullName = "John Doe", + Password = "password123" +}; + +await _mediator.Send(command); // ← MediatR xử lý request này +``` + +### **Step 3: MediatR tự động resolve Generic Types** +```csharp +// MediatR phân tích: +// TRequest = CreateUserCommand +// TResponse = Result + +// Resolve thành: +IPipelineBehavior> + ↓ +ValidationBehavior> +``` + +### **Step 4: Constructor Injection** +```csharp +// MediatR tạo instance: +var validators = serviceProvider.GetServices>(); +var validationBehavior = new ValidationBehavior>(validators); +``` + +### **Step 5: Pipeline Execution** +```csharp +// Chạy pipeline: +public async Task> Handle( + CreateUserCommand request, + RequestHandlerDelegate> next, + CancellationToken cancellationToken) +{ + // Validation logic... + + var context = new ValidationContext(request); + var validationResults = await Task.WhenAll( + validators.Select(v => v.ValidateAsync(context, cancellationToken))); + + // Nếu có lỗi → throw ValidationException + // Nếu OK → await next() (chuyển sang behavior tiếp theo) +} +``` + +## 🔄 **FLOW CHI TIẾT** + +### **1. User gửi CreateUserCommand** +``` +POST /api/users +{ + "email": "user@example.com", + "fullName": "John Doe", + "password": "password123" +} +``` + +### **2. MediatR nhận request** +```csharp +await _mediator.Send(createUserCommand); +``` + +### **3. MediatR build Pipeline từ registered Behaviors** +```csharp +// Từ registration order: +1. LoggingBehavior> +2. PerformanceBehavior> +3. AuthorizationBehavior> +4. ValidationBehavior> ← ĐÚNG CHỖ NÀY! +5. CachingBehavior> +6. TransactionBehavior> +7. DomainEventBehavior> +8. CreateUserCommandHandler.Handle() +``` + +### **4. ValidationBehavior được execute** +```csharp +public async Task> Handle( + CreateUserCommand request, // ← Đây là concrete type + RequestHandlerDelegate> next, + CancellationToken cancellationToken) +{ + // DI container đã inject validators: + // IEnumerable> validators + + if (!validators.Any()) + { + return await next(); // Không có validator → skip + } + + // Chạy tất cả validators cho CreateUserCommand + var context = new ValidationContext(request); + var validationResults = await Task.WhenAll( + validators.Select(v => v.ValidateAsync(context, cancellationToken))); + + var failures = validationResults + .Where(r => r.Errors.Count != 0) + .SelectMany(r => r.Errors) + .ToList(); + + if (failures.Count != 0) + { + throw new ValidationException(failures); // ← Lỗi validation + } + + return await next(); // ← OK → chuyển sang behavior tiếp theo +} +``` + +## 🎯 **TẠI SAO DÙNG OPEN GENERIC TYPES?** + +### **❌ Nếu đăng ký từng type cụ thể:** +```csharp +// Phải đăng ký cho từng command riêng: +services.AddScoped>, + ValidationBehavior>>(); + +services.AddScoped>, + ValidationBehavior>>(); + +services.AddScoped, + ValidationBehavior>(); + +// ... và hàng trăm commands khác +``` + +### **✅ Với Open Generic Types:** +```csharp +// 1 dòng duy nhất cho TẤT CẢ commands: +services.AddScoped(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); + +// MediatR tự động resolve cho: +// - CreateUserCommand, UpdateUserCommand, DeleteUserCommand +// - GetUserQuery, GetUsersQuery, SearchUsersQuery +// - Và tất cả requests khác! +``` + +## 💡 **MAGIC HAPPENS Ở ĐÂY:** + +1. **Registration time**: Đăng ký generic types +2. **Runtime**: MediatR substitute concrete types +3. **Execution**: ValidationBehavior nhận đúng validators cho request type + +```csharp +// Registration: +IPipelineBehavior<,> → ValidationBehavior<,> + +// Runtime cho CreateUserCommand: +IPipelineBehavior> + → ValidationBehavior> + +// Runtime cho GetUserQuery: +IPipelineBehavior + → ValidationBehavior +``` + +**🎯 Đây chính là sức mạnh của Generic Dependency Injection!** diff --git a/docs/NAMING_CONVENTIONS.md b/docs/NAMING_CONVENTIONS.md new file mode 100644 index 0000000..ff0ab33 --- /dev/null +++ b/docs/NAMING_CONVENTIONS.md @@ -0,0 +1,247 @@ +# 📝 Domain Layer Naming Conventions + +## 🎯 **TẠI SAO CẦN NAMING CONVENTIONS?** + +**Trước khi fix:** +``` +❌ Domain/Entities/User.cs ← Entity +❌ Domain/Aggregates/User/User.cs ← Aggregate (CONFUSING!) +``` + +**Sau khi fix:** +``` +✅ Domain/Entities/User.cs ← Entity +✅ Domain/Aggregates/User/UserAggregate.cs ← Aggregate (CLEAR!) +``` + +## 📋 **NAMING RULES** + +### **1. 🏛️ Entities - Pure Data Objects** +``` +📁 Domain/Entities/ +├── User.cs ← class User : BaseEntity +├── Conversation.cs ← class Conversation : BaseEntity +├── Message.cs ← class Message : BaseEntity +├── Attachment.cs ← class Attachment : BaseEntity +└── LegalDocument.cs ← class LegalDocument : BaseEntity +``` + +**Convention:** +- **Class name**: Singular noun (User, Conversation, Message) +- **Namespace**: `Domain.Entities` +- **Purpose**: Data + basic behavior +- **Inheritance**: `BaseEntity` + +### **2. 🏗️ Aggregates - Business Logic Orchestrators** +``` +📁 Domain/Aggregates/ +├── User/ +│ └── UserAggregate.cs ← class UserAggregate : BaseAggregateRoot +├── Conversation/ +│ └── ConversationAggregate.cs ← class ConversationAggregate : BaseAggregateRoot +└── LegalCase/ + └── LegalCaseAggregate.cs ← class LegalCaseAggregate : BaseAggregateRoot +``` + +**Convention:** +- **Class name**: `[Entity]Aggregate` (UserAggregate, ConversationAggregate) +- **File name**: `[Entity]Aggregate.cs` +- **Folder**: `[Entity]/` (singular) +- **Namespace**: `Domain.Aggregates.[Entity]` +- **Purpose**: Complex business operations +- **Inheritance**: `BaseAggregateRoot` + +### **3. 📢 Events - Past Tense Actions** +``` +📁 Domain/Events/ +├── User/ +│ ├── UserCreatedEvent.cs ← class UserCreatedEvent : IDomainEvent +│ ├── UserUpdatedEvent.cs ← class UserUpdatedEvent : IDomainEvent +│ └── UserDeactivatedEvent.cs ← class UserDeactivatedEvent : IDomainEvent +└── Conversation/ + ├── ConversationCreatedEvent.cs + ├── MessageAddedEvent.cs + └── ConversationClosedEvent.cs +``` + +**Convention:** +- **Class name**: `[Entity][Action]Event` (UserCreatedEvent) +- **Tense**: Past tense (Created, Updated, Deleted) +- **Namespace**: `Domain.Events.[Entity]` + +### **4. 💎 Value Objects - Immutable Values** +``` +📁 Domain/ValueObjects/ +├── Email.cs ← class Email : ValueObject +├── Money.cs ← class Money : ValueObject +├── Address.cs ← class Address : ValueObject +└── PhoneNumber.cs ← class PhoneNumber : ValueObject +``` + +**Convention:** +- **Class name**: Singular noun (Email, Money) +- **Inheritance**: `ValueObject` +- **Purpose**: Immutable value with validation + +### **5. 🔍 Specifications - Query Logic** +``` +📁 Domain/Specifications/ +├── UserSpecifications.cs ← static class UserSpecifications +├── ConversationSpecifications.cs +└── MessageSpecifications.cs +``` + +**Convention:** +- **Class name**: `[Entity]Specifications` (plural) +- **Type**: Static class with static methods +- **Methods**: `ActiveUsers()`, `RecentConversations()` + +### **6. 🛠️ Domain Services - Cross-Entity Logic** +``` +📁 Domain/Services/ +├── UserDomainService.cs ← class UserDomainService +├── ConversationDomainService.cs +└── LegalAdviceDomainService.cs +``` + +**Convention:** +- **Class name**: `[Domain]DomainService` +- **Interface**: `I[Domain]DomainService` +- **Purpose**: Logic that doesn't belong to single entity + +## 🎯 **COMPARISON: ENTITY vs AGGREGATE** + +### **📊 Side-by-Side Example** + +#### **🏛️ User Entity (Domain/Entities/User.cs)** +```csharp +namespace Domain.Entities; + +public sealed class User : BaseEntity +{ + public required string Email { get; set; } + public required string FullName { get; set; } + public UserRole[] Roles { get; set; } = []; + + // Simple methods - basic behavior + public bool HasRole(UserRole role) => Roles.Contains(role); + public bool IsActive => !IsDeleted; +} +``` + +#### **🏗️ User Aggregate (Domain/Aggregates/User/UserAggregate.cs)** +```csharp +namespace Domain.Aggregates.User; + +public sealed class UserAggregate : BaseAggregateRoot +{ + private readonly Entities.User _user; // ← Wraps entity + + // Complex business operations + public static UserAggregate Create(string email, string fullName, ...) + { + // ✅ Business validation + // ✅ Domain events + // ✅ Complex logic + } + + public void Deactivate() + { + // ✅ Business rules + // ✅ Domain events + // ✅ Cross-entity coordination + } +} +``` + +## 🔄 **USAGE PATTERNS** + +### **In Application Layer:** +```csharp +// ❌ DON'T use entities directly +public class CreateUserCommandHandler +{ + public async Task Handle(CreateUserCommand command) + { + var user = new User { Email = command.Email }; // ❌ Direct entity creation + await _repository.SaveAsync(user); + } +} + +// ✅ DO use aggregates +public class CreateUserCommandHandler +{ + public async Task Handle(CreateUserCommand command) + { + var userAggregate = UserAggregate.Create(command.Email, command.FullName); // ✅ Aggregate + await _repository.SaveAsync(userAggregate.GetUser()); + } +} +``` + +### **In Infrastructure Layer:** +```csharp +// Repository works with entities +public class UserRepository : IUserRepository +{ + public async Task GetByIdAsync(Guid id) // ← Entity + { + return await _context.Users.FindAsync(id); + } + + public async Task SaveAsync(User user) // ← Entity + { + _context.Users.Add(user); + await _context.SaveChangesAsync(); + } +} +``` + +## 📁 **FOLDER STRUCTURE SUMMARY** + +``` +Domain/ +├── Entities/ # 🏛️ Pure data objects +│ ├── User.cs +│ ├── Conversation.cs +│ └── Message.cs +│ +├── Aggregates/ # 🏗️ Business logic orchestrators +│ ├── User/ +│ │ └── UserAggregate.cs +│ └── Conversation/ +│ └── ConversationAggregate.cs +│ +├── Events/ # 📢 Domain events (past tense) +│ ├── User/ +│ │ ├── UserCreatedEvent.cs +│ │ └── UserUpdatedEvent.cs +│ └── Conversation/ +│ └── ConversationCreatedEvent.cs +│ +├── ValueObjects/ # 💎 Immutable values +│ ├── Email.cs +│ └── Money.cs +│ +├── Specifications/ # 🔍 Query logic +│ ├── UserSpecifications.cs +│ └── ConversationSpecifications.cs +│ +└── Services/ # 🛠️ Domain services + ├── UserDomainService.cs + └── ConversationDomainService.cs +``` + +## ✅ **BENEFITS OF CLEAR NAMING** + +| **Aspect** | **Before** | **After** | +|------------|------------|-----------| +| **Clarity** | ❌ User vs User? | ✅ User vs UserAggregate | +| **IDE Navigation** | ❌ Confusing autocomplete | ✅ Clear suggestions | +| **Code Reviews** | ❌ Which User class? | ✅ Obviously different | +| **Refactoring** | ❌ Risk of wrong changes | ✅ Safe refactoring | +| **Onboarding** | ❌ Developers confused | ✅ Self-documenting | + +--- + +**🎯 Remember: Good naming is documentation that never lies!** diff --git a/docs/README.md b/docs/README.md index 1f8ab2e..4db4754 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,20 +22,54 @@ - Multiple Event Handlers - Side effects và decoupling +### 🔄 **3. BEHAVIORS_GUIDE.md** +- **🎯 Mục đích**: Hiểu MediatR Pipeline Behaviors (Cross-cutting concerns) +- **👥 Đối tượng**: Developer cần hiểu Validation, Authorization, Caching, etc. +- **📋 Nội dung**: + - 7 loại Behaviors: Validation, Authorization, Logging, Performance, Caching, Transaction, DomainEvent + - Pipeline execution order + - Cách implement và sử dụng từng loại + +### 🚀 **4. CI_CD_EXPLAINED.md** +- **🎯 Mục đích**: Hiểu CI/CD pipeline và automation workflow +- **👥 Đối tượng**: DevOps, Developer, Tech Lead +- **📋 Nội dung**: + - CI/CD concepts và benefits + - Pipeline triggers và execution flow + - Build matrix strategy và caching + - Security considerations và best practices + - Troubleshooting guide và performance metrics + +### 🛠️ **5. COMMANDS.md** +- **🎯 Mục đích**: Danh sách commands thường dùng cho development +- **👥 Đối tượng**: Tất cả developers +- **📋 Nội dung**: + - Build, run, test commands + - Package management và formatting + - Database migrations và publishing + - Troubleshooting và quick start guide + ## 🚀 **Cách sử dụng tài liệu** ### **🆕 Người mới:** 1. 📖 Đọc **ARCHITECTURE_OVERVIEW.md** trước -2. 🔍 Trace 1 luồng CreateUser từ đầu đến cuối -3. 📚 Đọc **EVENT_HANDLER_FLOW.md** để hiểu Events +2. 🛠️ Đọc **COMMANDS.md** để setup development environment +3. 🔍 Trace 1 luồng CreateUser từ đầu đến cuối +4. 📚 Đọc **EVENT_HANDLER_FLOW.md** để hiểu Events +5. 🔄 Đọc **BEHAVIORS_GUIDE.md** để hiểu Cross-cutting concerns +6. 🚀 Đọc **CI_CD_EXPLAINED.md** để hiểu automation workflow ### **🔧 Developer có kinh nghiệm:** 1. 🏗️ Review **ARCHITECTURE_OVERVIEW.md** để hiểu patterns -2. 🔄 Đọc **EVENT_HANDLER_FLOW.md** để implement Events +2. 🛠️ Bookmark **COMMANDS.md** để reference nhanh +3. 🔄 Đọc **EVENT_HANDLER_FLOW.md** để implement Events +4. 🔄 Đọc **BEHAVIORS_GUIDE.md** để implement Pipeline Behaviors +5. 🚀 Đọc **CI_CD_EXPLAINED.md** để setup automation ### **👨‍💼 Tech Lead/Architect:** -1. 📊 Review cả 2 docs để hiểu design decisions +1. 📊 Review cả 5 docs để hiểu design decisions 2. 🎯 Sử dụng làm onboarding material cho team +3. 🛠️ Share **COMMANDS.md** với team cho consistency ## 🧭 **Navigation Map** @@ -43,13 +77,17 @@ 📁 docs/ ├── 📖 README.md ← Bạn đang ở đây ├── 🏗️ ARCHITECTURE_OVERVIEW.md ← Bắt đầu từ đây -└── 🔄 EVENT_HANDLER_FLOW.md ← Sau khi hiểu kiến trúc +├── 🛠️ COMMANDS.md ← Commands reference +├── 🔄 EVENT_HANDLER_FLOW.md ← Hiểu Domain Events +├── 🔄 BEHAVIORS_GUIDE.md ← Hiểu Pipeline Behaviors +└── 🚀 CI_CD_EXPLAINED.md ← Hiểu Automation ``` ## 📞 **Hỗ trợ** - **❓ Có thắc mắc về architecture**: Xem ARCHITECTURE_OVERVIEW.md -- **🔄 Không hiểu Events**: Xem EVENT_HANDLER_FLOW.md +- **🔄 Không hiểu Events**: Xem EVENT_HANDLER_FLOW.md +- **🔄 Không hiểu Behaviors**: Xem BEHAVIORS_GUIDE.md - **🆘 Vẫn chưa rõ**: Hỏi team hoặc tạo issue --- diff --git a/src/Application/Common/Behaviors/AuthorizationBehavior.cs b/src/Application/Common/Behaviors/AuthorizationBehavior.cs index 90ce6f0..bf5a539 100644 --- a/src/Application/Common/Behaviors/AuthorizationBehavior.cs +++ b/src/Application/Common/Behaviors/AuthorizationBehavior.cs @@ -12,7 +12,7 @@ public sealed record AuthorizationRequirement /// /// Required roles (user must have at least one) /// - public string[] Roles { get; init; } = []; + public List Roles { get; init; } = []; /// /// Required permissions (user must have all) @@ -54,12 +54,12 @@ public interface ICurrentUser /// /// User roles /// - string[] Roles { get; } + List Roles { get; } /// /// User permissions /// - string[] Permissions { get; } + List Permissions { get; } } /// @@ -90,7 +90,7 @@ public async Task Handle(TRequest request, RequestHandlerDelegate 0) + if (requirement.Roles.Count > 0) { var hasRequiredRole = requirement.Roles.Any(role => currentUser.Roles.Contains(role, StringComparer.OrdinalIgnoreCase)); @@ -106,12 +106,12 @@ public async Task Handle(TRequest request, RequestHandlerDelegate 0) { - var hasAllPermissions = requirement.Permissions.All(permission => + var hasAllPermissions = requirement.Permissions.ToList().TrueForAll(permission => currentUser.Permissions.Contains(permission, StringComparer.OrdinalIgnoreCase)); if (!hasAllPermissions) { - var missingPermissions = requirement.Permissions.Except(currentUser.Permissions).ToArray(); + var missingPermissions = requirement.Permissions.Except(currentUser.Permissions).ToList(); logger.LogWarning("Access denied for user {UserId} to {RequestName}. Missing permissions: {MissingPermissions}", currentUser.UserId, typeof(TRequest).Name, missingPermissions); throw new ForbiddenException($"Missing permissions: {string.Join(", ", missingPermissions)}"); diff --git a/src/Application/Common/Behaviors/LoggingBehavior.cs b/src/Application/Common/Behaviors/LoggingBehavior.cs index 8f8f3fa..a564004 100644 --- a/src/Application/Common/Behaviors/LoggingBehavior.cs +++ b/src/Application/Common/Behaviors/LoggingBehavior.cs @@ -1,3 +1,4 @@ +using Application.Common.Exceptions; using MediatR; using Microsoft.Extensions.Logging; using System.Diagnostics; @@ -35,7 +36,9 @@ public async Task Handle(TRequest request, RequestHandlerDelegate Handle(TRequest request, RequestHandlerDelegate +/// Exception thrown when request processing fails in MediatR pipeline +/// +public sealed class RequestProcessingException : Exception +{ + public RequestProcessingException(string message) : base(message) + { + } + + public RequestProcessingException(string message, Exception innerException) : base(message, innerException) + { + } +} diff --git a/src/Application/Features/Auth/GetProfile/GetProfileQuery.cs b/src/Application/Features/Auth/GetProfile/GetProfileQuery.cs index 3c3ab21..3551462 100644 --- a/src/Application/Features/Auth/GetProfile/GetProfileQuery.cs +++ b/src/Application/Features/Auth/GetProfile/GetProfileQuery.cs @@ -47,7 +47,7 @@ public sealed record GetProfileResponse /// /// User roles /// - public required string[] Roles { get; init; } + public required List Roles { get; init; } /// /// Created date diff --git a/src/Application/Features/Auth/Login/LoginCommand.cs b/src/Application/Features/Auth/Login/LoginCommand.cs index 0af1b7c..cf53392 100644 --- a/src/Application/Features/Auth/Login/LoginCommand.cs +++ b/src/Application/Features/Auth/Login/LoginCommand.cs @@ -73,5 +73,5 @@ public sealed record UserInfo /// /// User roles /// - public required string[] Roles { get; init; } + public required List Roles { get; init; } } diff --git a/src/Application/Features/Auth/Login/LoginCommandHandler.cs b/src/Application/Features/Auth/Login/LoginCommandHandler.cs index 831711d..9674c77 100644 --- a/src/Application/Features/Auth/Login/LoginCommandHandler.cs +++ b/src/Application/Features/Auth/Login/LoginCommandHandler.cs @@ -37,7 +37,7 @@ public async Task> Handle(LoginCommand request, Cancellati Id = user.Id, Email = user.Email, FullName = user.FullName, - Roles = user.Roles + Roles = [.. user.Roles.Select(r => r.ToString())] }; var accessToken = tokenService.GenerateAccessToken(userInfo); diff --git a/src/Application/Features/User/CreateUser/CreateUserCommand.cs b/src/Application/Features/User/CreateUser/CreateUserCommand.cs index c479804..1713cac 100644 --- a/src/Application/Features/User/CreateUser/CreateUserCommand.cs +++ b/src/Application/Features/User/CreateUser/CreateUserCommand.cs @@ -33,7 +33,7 @@ public sealed record CreateUserCommand : ICommand>, /// /// User roles /// - public string[] Roles { get; init; } = ["User"]; + public List Roles { get; init; } = ["User"]; // IAuthorizedRequest implementation public AuthorizationRequirement AuthorizationRequirement => new() diff --git a/src/Application/Features/User/CreateUser/CreateUserCommandHandler.cs b/src/Application/Features/User/CreateUser/CreateUserCommandHandler.cs index d6166b8..15da024 100644 --- a/src/Application/Features/User/CreateUser/CreateUserCommandHandler.cs +++ b/src/Application/Features/User/CreateUser/CreateUserCommandHandler.cs @@ -23,7 +23,7 @@ public async Task> Handle(CreateUserCommand request, } // Create new user - var user = Domain.Aggregates.User.User.Create( + var user = UserAggregate.Create( request.Email, request.FullName, passwordHasher.HashPassword(request.Password), @@ -31,7 +31,7 @@ public async Task> Handle(CreateUserCommand request, try { - var createdUser = await userRepository.CreateAsync(user, cancellationToken); + var createdUser = await userRepository.CreateAsync(user.GetUser(), cancellationToken); var response = new CreateUserResponse { diff --git a/src/Application/Features/User/CreateUser/CreateUserCommandValidator.cs b/src/Application/Features/User/CreateUser/CreateUserCommandValidator.cs index c1f9a9b..e2aa742 100644 --- a/src/Application/Features/User/CreateUser/CreateUserCommandValidator.cs +++ b/src/Application/Features/User/CreateUser/CreateUserCommandValidator.cs @@ -39,7 +39,7 @@ public CreateUserCommandValidator() RuleFor(x => x.Roles) .NotEmpty() .WithMessage("At least one role is required") - .Must(roles => roles.All(role => !string.IsNullOrWhiteSpace(role))) + .Must(roles => roles.ToList().TrueForAll(role => !string.IsNullOrWhiteSpace(role))) .WithMessage("Role names cannot be empty"); } } diff --git a/src/Application/Features/User/GetUsers/GetUsersQuery.cs b/src/Application/Features/User/GetUsers/GetUsersQuery.cs index d8aafec..178bf1a 100644 --- a/src/Application/Features/User/GetUsers/GetUsersQuery.cs +++ b/src/Application/Features/User/GetUsers/GetUsersQuery.cs @@ -61,7 +61,7 @@ public sealed record UserSummary : AuditableEntity /// /// User roles /// - public required string[] Roles { get; init; } + public required List Roles { get; init; } /// /// Is user active diff --git a/src/Application/Interfaces/IUserRepository.cs b/src/Application/Interfaces/IUserRepository.cs index c233c15..c3ab403 100644 --- a/src/Application/Interfaces/IUserRepository.cs +++ b/src/Application/Interfaces/IUserRepository.cs @@ -1,4 +1,4 @@ -using Domain.Aggregates.User; +using Domain.Entities; namespace Application.Interfaces; diff --git a/src/Domain/Aggregates/Conversation/Conversation.cs b/src/Domain/Aggregates/Conversation/ConversationAggregate.cs similarity index 93% rename from src/Domain/Aggregates/Conversation/Conversation.cs rename to src/Domain/Aggregates/Conversation/ConversationAggregate.cs index 7da105b..6706088 100644 --- a/src/Domain/Aggregates/Conversation/Conversation.cs +++ b/src/Domain/Aggregates/Conversation/ConversationAggregate.cs @@ -1,7 +1,6 @@ using Domain.Common; using Domain.Entities; using Domain.Events.Conversation; -using Domain.Enums; namespace Domain.Aggregates.Conversation; @@ -73,7 +72,7 @@ public static ConversationAggregate Create(Guid ownerId, string title, bool isPr /// /// Add message to conversation /// - public void AddMessage(Guid senderId, string content, MessageType type = MessageType.Text, bool isFromBot = false) + public void AddMessage(Guid senderId, string content, Entities.MessageType type = Entities.MessageType.Text, bool isFromBot = false) { // Business rules validation if (_conversation.Status != ConversationStatus.Active) @@ -140,10 +139,10 @@ public void UpdateTitle(string newTitle) /// /// Change privacy settings /// - public void SetPrivacy(bool isPrivate, UserRole userRole) + public void SetPrivacy(bool isPrivate, string userRole) { // Business rule: Only owner or admins can change privacy - if (userRole != UserRole.Admin && _conversation.OwnerId != Id) + if (userRole != Constants.UserRoles.Admin && _conversation.OwnerId != Id) { throw new UnauthorizedAccessException("Only owner or admin can change privacy settings"); } @@ -183,9 +182,9 @@ public void AddTags(params string[] newTags) /// /// Check if user can access this conversation /// - public bool CanBeAccessedBy(Guid userId, UserRole[] userRoles) + public bool CanBeAccessedBy(Guid userId, string[] userRoles) { - return _conversation.IsAccessibleBy(userId, userRoles.Select(r => r.ToString()).ToArray()); + return _conversation.IsAccessibleBy(userId, userRoles); } // Private helper methods diff --git a/src/Domain/Aggregates/User/User.cs b/src/Domain/Aggregates/User/UserAggregate.cs similarity index 80% rename from src/Domain/Aggregates/User/User.cs rename to src/Domain/Aggregates/User/UserAggregate.cs index a660dd5..50b5361 100644 --- a/src/Domain/Aggregates/User/User.cs +++ b/src/Domain/Aggregates/User/UserAggregate.cs @@ -1,7 +1,7 @@ using Domain.Common; +using Domain.Constants; using Domain.Entities; using Domain.Events.User; -using Domain.Enums; using Domain.ValueObjects; namespace Domain.Aggregates.User; @@ -24,7 +24,7 @@ public UserAggregate(Entities.User user) public string FullName => _user.FullName; public string PasswordHash => _user.PasswordHash; public string? PhoneNumber => _user.PhoneNumber; - public UserRole[] Roles => _user.Roles; + public List Roles => _user.Roles; public bool IsActive => _user.IsActive; // Get the underlying entity @@ -33,7 +33,7 @@ public UserAggregate(Entities.User user) /// /// Create a new user aggregate /// - public static UserAggregate Create(string email, string fullName, string passwordHash, UserRole[]? roles = null) + public static UserAggregate Create(string email, string fullName, string passwordHash, List? roles = null) { // Validate business rules if (string.IsNullOrWhiteSpace(email)) @@ -57,13 +57,13 @@ public static UserAggregate Create(string email, string fullName, string passwor Email = email, FullName = fullName, PasswordHash = passwordHash, - Roles = roles ?? [UserRole.User] + Roles = roles?.ToList() ?? [UserRoles.User] }; var aggregate = new UserAggregate(user); // Raise domain event - aggregate.AddDomainEvent(new UserCreatedEvent(user.Id, user.Email, user.FullName, user.Roles.Select(r => r.ToString()).ToArray())); + aggregate.AddDomainEvent(new UserCreatedEvent(user.Id, user.Email, user.FullName, user.Roles)); return aggregate; } @@ -109,16 +109,14 @@ public void UpdateInfo(string fullName, string? phoneNumber = null) /// /// Add role to user /// - public void AddRole(UserRole role) + public void AddRole(string role) { - if (_user.Roles.Contains(role)) + if (string.IsNullOrWhiteSpace(role)) { - return; + throw new ArgumentException("Role cannot be empty", nameof(role)); } - var rolesList = _user.Roles.ToList(); - rolesList.Add(role); - _user.Roles = rolesList.ToArray(); + _user.AddRole(role); _user.UpdatedAt = DateTime.UtcNow; UpdatedAt = DateTime.UtcNow; } @@ -126,20 +124,20 @@ public void AddRole(UserRole role) /// /// Remove role from user /// - public void RemoveRole(UserRole role) + public void RemoveRole(string role) { - if (!_user.Roles.Contains(role)) + if (string.IsNullOrWhiteSpace(role)) { - return; + throw new ArgumentException("Role cannot be empty", nameof(role)); } // Business rule: User must have at least one role - if (_user.Roles.Length <= 1) + if (_user.Roles.Count <= 1) { throw new InvalidOperationException("User must have at least one role"); } - _user.Roles = _user.Roles.Where(r => r != role).ToArray(); + _user.RemoveRole(role); _user.UpdatedAt = DateTime.UtcNow; UpdatedAt = DateTime.UtcNow; } @@ -154,17 +152,19 @@ public bool CanPerformAction(string action) { "CreateConversation" => IsActive, "EditProfile" => IsActive, - "ManageUsers" => _user.HasRole(UserRole.Admin), - "AccessLegalDatabase" => _user.HasAnyRole(UserRole.Admin, UserRole.LegalExpert), + "ManageUsers" => _user.HasRole(UserRoles.Admin), + "AccessLegalDatabase" => _user.HasAnyRole(UserRoles.Admin, UserRoles.LegalExpert), _ => false }; } // Private helper methods + private const bool DefaultActiveConversationStatus = false; + private bool HasActiveConversations() { // This would typically be checked via a domain service - // For now, assume false - return false; + // For now, return constant as an example + return DefaultActiveConversationStatus; } } diff --git a/src/Domain/Constants/UserRoles.cs b/src/Domain/Constants/UserRoles.cs new file mode 100644 index 0000000..dd2451f --- /dev/null +++ b/src/Domain/Constants/UserRoles.cs @@ -0,0 +1,71 @@ +namespace Domain.Constants; + +/// +/// User role constants - type-safe role strings +/// +public static class UserRoles +{ + /// + /// Regular user with basic access + /// + public const string User = "User"; + + /// + /// Premium user with extended features + /// + public const string Premium = "Premium"; + + /// + /// Legal expert with consultation capabilities + /// + public const string LegalExpert = "LegalExpert"; + + /// + /// Manager with team oversight capabilities + /// + public const string Manager = "Manager"; + + /// + /// Administrator with full system access + /// + public const string Admin = "Admin"; + + /// + /// System user for automated processes + /// + public const string System = "System"; + + /// + /// All available roles + /// + public static readonly string[] All = + [ + User, + Premium, + LegalExpert, + Manager, + Admin, + System + ]; + + /// + /// Elevated privilege roles + /// + public static readonly string[] Elevated = + [ + LegalExpert, + Manager, + Admin, + System + ]; + + /// + /// Check if role is valid + /// + public static bool IsValid(string role) => All.Contains(role, StringComparer.OrdinalIgnoreCase); + + /// + /// Check if role has elevated privileges + /// + public static bool IsElevated(string role) => Elevated.Contains(role, StringComparer.OrdinalIgnoreCase); +} diff --git a/src/Domain/Entities/User.cs b/src/Domain/Entities/User.cs index 6273d73..525aa87 100644 --- a/src/Domain/Entities/User.cs +++ b/src/Domain/Entities/User.cs @@ -1,5 +1,4 @@ using Domain.Common; -using Domain.Enums; namespace Domain.Entities; @@ -12,7 +11,7 @@ public sealed class User : BaseEntity public required string FullName { get; set; } public required string PasswordHash { get; set; } public string? PhoneNumber { get; set; } - public UserRole[] Roles { get; set; } = []; + public List Roles { get; set; } = []; // Navigation properties for related entities public ICollection OwnedConversations { get; set; } = []; @@ -21,12 +20,36 @@ public sealed class User : BaseEntity /// /// Check if user has specific role /// - public bool HasRole(UserRole role) => Roles.Contains(role); + public bool HasRole(string role) => Roles.Contains(role, StringComparer.OrdinalIgnoreCase); /// /// Check if user has any of the specified roles /// - public bool HasAnyRole(params UserRole[] roles) => roles.Any(role => Roles.Contains(role)); + public bool HasAnyRole(params string[] roles) => roles.Any(role => Roles.Contains(role, StringComparer.OrdinalIgnoreCase)); + + /// + /// Add role to user + /// + public void AddRole(string role) + { + if (!HasRole(role)) + { + var rolesList = Roles.ToList(); + rolesList.Add(role); + Roles = rolesList.ToList(); + } + } + + /// + /// Remove role from user + /// + public void RemoveRole(string role) + { + if (HasRole(role)) + { + Roles = Roles.Where(r => !string.Equals(r, role, StringComparison.OrdinalIgnoreCase)).ToList(); + } + } /// /// Validate email format (basic validation in entity) diff --git a/src/Domain/Enums/UserRole.cs b/src/Domain/Enums/UserRole.cs deleted file mode 100644 index 5553aec..0000000 --- a/src/Domain/Enums/UserRole.cs +++ /dev/null @@ -1,62 +0,0 @@ -namespace Domain.Enums; - -/// -/// User roles enumeration -/// -public enum UserRole -{ - /// - /// Regular user - /// - User = 0, - - /// - /// Administrator - /// - Admin = 1, - - /// - /// Manager - /// - Manager = 2, - - /// - /// Legal expert - /// - LegalExpert = 3, - - /// - /// Premium user with extended features - /// - Premium = 4, - - /// - /// System user (for automated processes) - /// - System = 5 -} - -/// -/// Extension methods for UserRole enum -/// -public static class UserRoleExtensions -{ - /// - /// Get display name for role - /// - public static string GetDisplayName(this UserRole role) => role switch - { - UserRole.User => "User", - UserRole.Admin => "Administrator", - UserRole.Manager => "Manager", - UserRole.LegalExpert => "Legal Expert", - UserRole.Premium => "Premium User", - UserRole.System => "System", - _ => "Unknown" - }; - - /// - /// Check if role has elevated privileges - /// - public static bool IsElevated(this UserRole role) => role is UserRole.LegalExpert or UserRole.Manager or UserRole.Admin or UserRole.System; -} diff --git a/src/Domain/Events/Examples/DomainEventsUsageExamples.cs b/src/Domain/Events/Examples/DomainEventsUsageExamples.cs index 20b4482..79a59cf 100644 --- a/src/Domain/Events/Examples/DomainEventsUsageExamples.cs +++ b/src/Domain/Events/Examples/DomainEventsUsageExamples.cs @@ -16,7 +16,10 @@ public static class DomainEventsUsageExamples public static void UserRegistrationExample() { // 1. Tạo user aggregate - var user = Domain.Aggregates.User.User.Create("john@example.com", "John Doe", "hashedPassword", ["User"]); + var userAggregate = UserAggregate.Create("john@example.com", "John Doe", "hashedPassword", ["User"]); + + // Use the aggregate for demonstration + Console.WriteLine($"User created: {userAggregate.Email}"); // 2. Domain event sẽ được raise automatically trong User.Create() // UserCreatedEvent sẽ trigger: @@ -121,6 +124,9 @@ public static void BestPracticesExample() roles: ["User"] ); + // Use the event for demonstration + Console.WriteLine($"Event created: {userCreated.GetType().Name}"); + // ❌ BAD: Mutable, confusing name // var createUserEvent = new CreateUserEvent { ... }; } @@ -139,7 +145,10 @@ public static class LegalAssistantDomainEvents /// - UserSubscriptionChangedEvent /// - UserTierUpgradedEvent /// - public static void UserEvents() { } + public static void UserEvents() + { + // Example method for documentation purposes - lists available user domain events + } /// /// Conversation Domain Events: @@ -149,7 +158,10 @@ public static void UserEvents() { } /// - ExpertAssignedEvent /// - ConversationRatedEvent /// - public static void ConversationEvents() { } + public static void ConversationEvents() + { + // Example method for documentation purposes - lists available conversation domain events + } /// /// Legal Service Domain Events: @@ -159,7 +171,10 @@ public static void ConversationEvents() { } /// - LegalAdviceProvidedEvent /// - CaseFileCreatedEvent /// - public static void LegalServiceEvents() { } + public static void LegalServiceEvents() + { + // Example method for documentation purposes - lists available legal service domain events + } /// /// Payment Domain Events: @@ -169,5 +184,8 @@ public static void LegalServiceEvents() { } /// - RefundProcessedEvent /// - InvoiceGeneratedEvent /// - public static void PaymentEvents() { } + public static void PaymentEvents() + { + // Example method for documentation purposes - lists available payment domain events + } } diff --git a/src/Domain/Events/User/UserCreatedEvent.cs b/src/Domain/Events/User/UserCreatedEvent.cs index cca2511..014ed81 100644 --- a/src/Domain/Events/User/UserCreatedEvent.cs +++ b/src/Domain/Events/User/UserCreatedEvent.cs @@ -10,10 +10,10 @@ public sealed record UserCreatedEvent : IDomainEvent public Guid UserId { get; } public string Email { get; } public string FullName { get; } - public string[] Roles { get; } + public List Roles { get; } public DateTime OccurredOn { get; } - public UserCreatedEvent(Guid userId, string email, string fullName, string[] roles) + public UserCreatedEvent(Guid userId, string email, string fullName, List roles) { UserId = userId; Email = email; diff --git a/src/Domain/GlobalSuppressions.cs b/src/Domain/GlobalSuppressions.cs new file mode 100644 index 0000000..2e88af7 --- /dev/null +++ b/src/Domain/GlobalSuppressions.cs @@ -0,0 +1,8 @@ +// This file is used by Code Analysis to maintain SuppressMessage +// attributes that are applied to this project. +// Project-level suppressions either have no target or are given +// a specific target and scoped to a namespace, type, member, etc. + +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage("Major Code Smell", "S2589:Boolean expressions should not be gratuitous", Justification = "", Scope = "member", Target = "~M:Domain.Specifications.Examples.SpecificationUsageExamples.DynamicQueryBuilding")] diff --git a/src/Domain/Services/UserDomainService.cs b/src/Domain/Services/UserDomainService.cs index 5c3edfb..0a9e174 100644 --- a/src/Domain/Services/UserDomainService.cs +++ b/src/Domain/Services/UserDomainService.cs @@ -8,10 +8,10 @@ public sealed class UserDomainService /// /// Check if user can be assigned to specific role /// - public bool CanAssignRole(Domain.Aggregates.User.User user, string newRole) + public bool CanAssignRole(Domain.Aggregates.User.UserAggregate userAggregate, string newRole) { // Business rule: Admin can't be demoted if they're the last admin - if (user.Roles.Contains("Admin") && newRole != "Admin") + if (userAggregate.Roles.Contains("Admin") && newRole != "Admin") { // This would require checking other admins - complex business logic // that doesn't belong to User entity alone diff --git a/src/Domain/Specifications/Examples/SpecificationUsageExamples.cs b/src/Domain/Specifications/Examples/SpecificationUsageExamples.cs index 6f8f820..88ba17c 100644 --- a/src/Domain/Specifications/Examples/SpecificationUsageExamples.cs +++ b/src/Domain/Specifications/Examples/SpecificationUsageExamples.cs @@ -1,4 +1,5 @@ using Domain.Aggregates.User; +using Domain.Entities; namespace Domain.Specifications.Examples; @@ -16,9 +17,11 @@ public static void SimpleUsage() // Find active users var activeUsers = users.Where(UserSpecs.Active.ToExpression().Compile()); + Console.WriteLine($"Found {activeUsers.Count()} active users"); // Find admin users var adminUsers = users.Where(UserSpecs.Admin.ToExpression().Compile()); + Console.WriteLine($"Found {adminUsers.Count()} admin users"); } /// @@ -31,14 +34,17 @@ public static void CombinedSpecifications() // Active AND Admin users var activeAdmins = UserSpecs.Active & UserSpecs.Admin; var result1 = users.Where(activeAdmins.ToExpression().Compile()); + Console.WriteLine($"Found {result1.Count()} active admins"); // Users with legal access OR admin role var legalOrAdmin = UserSpecs.LegalAccess | UserSpecs.Admin; var result2 = users.Where(legalOrAdmin.ToExpression().Compile()); + Console.WriteLine($"Found {result2.Count()} legal/admin users"); // NOT deleted users (same as active) var notDeleted = !new UserHasRoleSpecification("Deleted"); var result3 = users.Where(notDeleted.ToExpression().Compile()); + Console.WriteLine($"Found {result3.Count()} non-deleted users"); } /// @@ -54,6 +60,7 @@ public static void ComplexBusinessRules() UserSpecs.CreatedAfter(DateTime.UtcNow.AddMonths(-6)); var result = users.Where(recentActiveLegalExperts.ToExpression().Compile()); + Console.WriteLine($"Found {result.Count()} recent legal experts"); // Business rule: "Company users excluding admins" var companyNonAdmins = UserSpecs.EmailDomain("company.com") & @@ -61,6 +68,7 @@ public static void ComplexBusinessRules() !UserSpecs.Admin; var result2 = users.Where(companyNonAdmins.ToExpression().Compile()); + Console.WriteLine($"Found {result2.Count()} company non-admins"); } /// @@ -98,12 +106,16 @@ public static void DynamicQueryBuilding() if (requireLegalAccess) { querySpec = (ActiveUserSpecification)(querySpec & UserSpecs.LegalAccess); + Console.WriteLine("Added legal access requirement"); } +#pragma warning disable S2583 // Conditionally executed code should be reachable if (excludeAdmins) { querySpec = (ActiveUserSpecification)(querySpec & !UserSpecs.Admin); + Console.WriteLine("Excluded admin users"); } +#pragma warning restore S2583 // Conditionally executed code should be reachable if (!string.IsNullOrEmpty(emailDomain)) { @@ -111,6 +123,7 @@ public static void DynamicQueryBuilding() } var result = users.Where(querySpec.ToExpression().Compile()); + Console.WriteLine($"Dynamic query returned {result.Count()} users"); } // Helper methods diff --git a/src/Domain/Specifications/UserSpecifications.cs b/src/Domain/Specifications/UserSpecifications.cs index 8f0d924..130aab8 100644 --- a/src/Domain/Specifications/UserSpecifications.cs +++ b/src/Domain/Specifications/UserSpecifications.cs @@ -1,5 +1,6 @@ using System.Linq.Expressions; using Domain.Aggregates.User; +using Domain.Entities; namespace Domain.Specifications; @@ -21,7 +22,7 @@ public sealed class UserHasRoleSpecification(string role) : Specification { public override Expression> ToExpression() { - return user => user.Roles.Contains(role); + return user => user.HasRole(role); } } @@ -54,7 +55,7 @@ public sealed class AdminUserSpecification : Specification { public override Expression> ToExpression() { - return user => user.Roles.Contains("Admin"); + return user => user.HasRole("Admin"); } } @@ -65,9 +66,9 @@ public sealed class LegalAccessUserSpecification : Specification { public override Expression> ToExpression() { - return user => user.Roles.Contains("Admin") || - user.Roles.Contains("LegalExpert") || - user.Roles.Contains("Manager"); + return user => user.HasRole("Admin") || + user.HasRole("LegalExpert") || + user.HasRole("Manager"); } } diff --git a/src/Infrastructure/Configuration/EmailSettings.cs b/src/Infrastructure/Configuration/EmailSettings.cs new file mode 100644 index 0000000..e7d67d5 --- /dev/null +++ b/src/Infrastructure/Configuration/EmailSettings.cs @@ -0,0 +1,17 @@ +namespace Infrastructure.Configuration; + +/// +/// Email service configuration settings +/// +public sealed class EmailSettings +{ + public const string SectionName = "EmailSettings"; + + public string SmtpServer { get; set; } = string.Empty; + public int SmtpPort { get; set; } = 587; + public string Username { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + public string FromEmail { get; set; } = string.Empty; + public string FromName { get; set; } = string.Empty; + public bool EnableSsl { get; set; } = true; +} diff --git a/src/Infrastructure/Configuration/FileStorageSettings.cs b/src/Infrastructure/Configuration/FileStorageSettings.cs new file mode 100644 index 0000000..5bdcea5 --- /dev/null +++ b/src/Infrastructure/Configuration/FileStorageSettings.cs @@ -0,0 +1,16 @@ +namespace Infrastructure.Configuration; + +/// +/// File storage configuration settings +/// +public sealed class FileStorageSettings +{ + public const string SectionName = "FileStorageSettings"; + + public string Provider { get; set; } = "Local"; // Local, Azure, AWS + public string ConnectionString { get; set; } = string.Empty; + public string ContainerName { get; set; } = "files"; + public string BasePath { get; set; } = "uploads"; + public long MaxFileSizeBytes { get; set; } = 10 * 1024 * 1024; // 10MB + public string[] AllowedExtensions { get; set; } = [".pdf", ".docx", ".png", ".jpg"]; +} diff --git a/src/Infrastructure/Configuration/JwtSettings.cs b/src/Infrastructure/Configuration/JwtSettings.cs new file mode 100644 index 0000000..e132722 --- /dev/null +++ b/src/Infrastructure/Configuration/JwtSettings.cs @@ -0,0 +1,15 @@ +namespace Infrastructure.Configuration; + +/// +/// JWT authentication configuration settings +/// +public sealed class JwtSettings +{ + public const string SectionName = "JwtSettings"; + + public string SecretKey { get; set; } = string.Empty; + public string Issuer { get; set; } = string.Empty; + public string Audience { get; set; } = string.Empty; + public int ExpirationHours { get; set; } = 1; + public int RefreshTokenExpirationDays { get; set; } = 7; +} diff --git a/src/Infrastructure/Data/Contexts/DataContext.cs b/src/Infrastructure/Data/Contexts/DataContext.cs index 5725473..07e15a6 100644 --- a/src/Infrastructure/Data/Contexts/DataContext.cs +++ b/src/Infrastructure/Data/Contexts/DataContext.cs @@ -24,8 +24,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { - base.OnConfiguring(optionsBuilder); - // Cấu hình logging cho Entity Framework (chỉ trong Development) #if DEBUG optionsBuilder.EnableSensitiveDataLogging(); diff --git a/src/Infrastructure/Exceptions/DomainEventDispatchException.cs b/src/Infrastructure/Exceptions/DomainEventDispatchException.cs new file mode 100644 index 0000000..ab4c568 --- /dev/null +++ b/src/Infrastructure/Exceptions/DomainEventDispatchException.cs @@ -0,0 +1,15 @@ +namespace Infrastructure.Exceptions; + +/// +/// Exception thrown when domain event dispatching fails +/// +public sealed class DomainEventDispatchException : Exception +{ + public DomainEventDispatchException(string message) : base(message) + { + } + + public DomainEventDispatchException(string message, Exception innerException) : base(message, innerException) + { + } +} diff --git a/src/Infrastructure/Extensions/ServiceCollectionExtensions.cs b/src/Infrastructure/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..cfc53de --- /dev/null +++ b/src/Infrastructure/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,171 @@ +using Application.Common; +using Application.Interfaces; +using Infrastructure.Configuration; +using Infrastructure.Data.Contexts; +using Infrastructure.Repositories; +using Infrastructure.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Infrastructure.Extensions; + +/// +/// Infrastructure service registration extensions +/// +public static class ServiceCollectionExtensions +{ + /// + /// Add Infrastructure layer services + /// + public static IServiceCollection AddInfrastructureServices( + this IServiceCollection services, + IConfiguration configuration) + { + // Database + services.AddDatabase(configuration); + + // Repositories + services.AddRepositories(); + + // Services + services.AddInfrastructureApplicationServices(); + + // External Services + services.AddExternalServices(configuration); + + return services; + } + + /// + /// Add database services + /// + private static IServiceCollection AddDatabase( + this IServiceCollection services, + IConfiguration configuration) + { + var connectionString = configuration.GetConnectionString("DefaultConnection") + ?? "Server=(localdb)\\mssqllocaldb;Database=LegalAssistantDb;Trusted_Connection=true;MultipleActiveResultSets=true"; + + services.AddDbContext(options => + { + options.UseSqlServer(connectionString, sqlOptions => + sqlOptions.EnableRetryOnFailure( + maxRetryCount: 3, + maxRetryDelay: TimeSpan.FromSeconds(30), + errorNumbersToAdd: null)); + +#if DEBUG + options.EnableSensitiveDataLogging(); + options.EnableDetailedErrors(); +#endif + }); + + // Register as interface + services.AddScoped(provider => provider.GetRequiredService()); + + return services; + } + + /// + /// Add repository implementations + /// + private static IServiceCollection AddRepositories(this IServiceCollection services) + { + services.AddScoped(); + + // TODO: Add other repositories here + // services.AddScoped(); + // services.AddScoped(); + + return services; + } + + /// + /// Add Infrastructure implementations of Application interfaces + /// + private static IServiceCollection AddInfrastructureApplicationServices(this IServiceCollection services) + { + // Authentication & Security + services.AddScoped(); + services.AddScoped(); + + // Domain Events + services.AddScoped(); + + // TODO: Add other application services + // services.AddScoped(); + // services.AddScoped(); + // services.AddScoped(); + + return services; + } + + /// + /// Add external service integrations + /// + private static IServiceCollection AddExternalServices( + this IServiceCollection services, + IConfiguration configuration) + { + // Email Service + services.Configure(configuration.GetSection("EmailSettings")); + + // File Storage + services.Configure(configuration.GetSection("FileStorageSettings")); + + // JWT Settings + services.Configure(configuration.GetSection("JwtSettings")); + + // TODO: Add external service clients + // services.AddHttpClient(); + + return services; + } + + /// + /// Add caching services + /// + public static IServiceCollection AddCaching( + this IServiceCollection services, + IConfiguration configuration) + { + // Memory Cache (already added in Application layer) + + // Redis Cache (optional) + var redisConnectionString = configuration.GetConnectionString("Redis"); + if (!string.IsNullOrEmpty(redisConnectionString)) + { + services.AddStackExchangeRedisCache(options => options.Configuration = redisConnectionString); + } + + return services; + } + + /// + /// Add health checks + /// + public static IServiceCollection AddHealthChecks( + this IServiceCollection services, + IConfiguration configuration) + { + var healthChecks = services.AddHealthChecks(); + + // Database health check + var connectionString = configuration.GetConnectionString("DefaultConnection"); + if (!string.IsNullOrEmpty(connectionString)) + { + healthChecks.AddSqlServer(connectionString, name: "database"); + } + + // Redis health check (if configured) + var redisConnectionString = configuration.GetConnectionString("Redis"); + if (!string.IsNullOrEmpty(redisConnectionString)) + { + healthChecks.AddRedis(redisConnectionString, name: "redis"); + } + + return services; + } +} diff --git a/src/Infrastructure/Infrastructure.csproj b/src/Infrastructure/Infrastructure.csproj index 663d7cb..b92a4b1 100644 --- a/src/Infrastructure/Infrastructure.csproj +++ b/src/Infrastructure/Infrastructure.csproj @@ -17,6 +17,12 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + diff --git a/src/Infrastructure/Repositories/UserRepository.cs b/src/Infrastructure/Repositories/UserRepository.cs new file mode 100644 index 0000000..22744da --- /dev/null +++ b/src/Infrastructure/Repositories/UserRepository.cs @@ -0,0 +1,63 @@ +using Application.Interfaces; +using Domain.Entities; +using Infrastructure.Data.Contexts; +using Microsoft.EntityFrameworkCore; + +namespace Infrastructure.Repositories; + +/// +/// User repository implementation - connects to database +/// +public sealed class UserRepository : IUserRepository +{ + private readonly DataContext _context; + + public UserRepository(DataContext context) + { + _context = context; + } + + /// + /// Get user by email from database + /// + public async Task GetByEmailAsync(string email, CancellationToken cancellationToken = default) + { + return await _context.Set() + .Where(u => u.Email == email && !u.IsDeleted) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + /// Get user by ID from database + /// + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _context.Set() + .Where(u => u.Id == id && !u.IsDeleted) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + /// Update user in database + /// + public async Task UpdateAsync(User user, CancellationToken cancellationToken = default) + { + _context.Set().Update(user); + await _context.SaveChangesAsync(cancellationToken); + } + + /// + /// Create new user in database + /// + public async Task CreateAsync(User user, CancellationToken cancellationToken = default) + { + // Add to context + var entityEntry = await _context.Set().AddAsync(user, cancellationToken); + + // Save to database + await _context.SaveChangesAsync(cancellationToken); + + // Return created user with generated ID + return entityEntry.Entity; + } +} diff --git a/src/Infrastructure/Services/DomainEventDispatcher.cs b/src/Infrastructure/Services/DomainEventDispatcher.cs index 2e4a9ac..0613f3e 100644 --- a/src/Infrastructure/Services/DomainEventDispatcher.cs +++ b/src/Infrastructure/Services/DomainEventDispatcher.cs @@ -1,5 +1,6 @@ using Application.Common; using Domain.Common; +using Infrastructure.Exceptions; using MediatR; using Microsoft.Extensions.Logging; @@ -45,7 +46,7 @@ public async Task DispatchEventAsync(IDomainEvent domainEvent, CancellationToken catch (Exception ex) { logger.LogError(ex, "Error dispatching domain event: {EventType}", domainEvent.GetType().Name); - throw; + throw new DomainEventDispatchException($"Failed to dispatch domain event of type {domainEvent.GetType().Name}", ex); } } } diff --git a/src/Infrastructure/Services/PasswordHasher.cs b/src/Infrastructure/Services/PasswordHasher.cs new file mode 100644 index 0000000..f83ecfc --- /dev/null +++ b/src/Infrastructure/Services/PasswordHasher.cs @@ -0,0 +1,94 @@ +using Application.Interfaces; +using System.Security.Cryptography; +using System.Text; + +namespace Infrastructure.Services; + +/// +/// Password hashing service using BCrypt-like approach +/// +public sealed class PasswordHasher : IPasswordHasher +{ + private const int SaltSize = 16; + private const int HashSize = 32; + private const int Iterations = 100000; // Security requirement: min 100,000 iterations + + /// + /// Hash password with salt + /// + public string HashPassword(string password) + { + if (string.IsNullOrWhiteSpace(password)) + { + throw new ArgumentException("Password cannot be empty", nameof(password)); + } + + // Generate salt + using var rng = RandomNumberGenerator.Create(); + var salt = new byte[SaltSize]; + rng.GetBytes(salt); + + // Hash password with salt + using var pbkdf2 = new Rfc2898DeriveBytes( + Encoding.UTF8.GetBytes(password), + salt, + Iterations, + HashAlgorithmName.SHA256); + + var hash = pbkdf2.GetBytes(HashSize); + + // Combine salt + hash + var result = new byte[SaltSize + HashSize]; + Array.Copy(salt, 0, result, 0, SaltSize); + Array.Copy(hash, 0, result, SaltSize, HashSize); + + return Convert.ToBase64String(result); + } + + /// + /// Verify password against hash + /// + public bool VerifyPassword(string password, string hash) + { + if (string.IsNullOrWhiteSpace(password) || string.IsNullOrWhiteSpace(hash)) + { + return false; + } + + try + { + var hashBytes = Convert.FromBase64String(hash); + + if (hashBytes.Length != SaltSize + HashSize) + { + return false; + } + + // Extract salt + var salt = new byte[SaltSize]; + Array.Copy(hashBytes, 0, salt, 0, SaltSize); + + // Extract hash + var existingHash = new byte[HashSize]; + Array.Copy(hashBytes, SaltSize, existingHash, 0, HashSize); + + // Hash input password with same salt +#pragma warning disable S2053 // Password hashing functions should use an unpredictable salt + using var pbkdf2 = new Rfc2898DeriveBytes( + Encoding.UTF8.GetBytes(password), + salt, + Iterations, + HashAlgorithmName.SHA256); +#pragma warning restore S2053 // Password hashing functions should use an unpredictable salt + + var newHash = pbkdf2.GetBytes(HashSize); + + // Compare hashes + return CryptographicOperations.FixedTimeEquals(existingHash, newHash); + } + catch + { + return false; + } + } +} diff --git a/src/Infrastructure/Services/TokenService.cs b/src/Infrastructure/Services/TokenService.cs new file mode 100644 index 0000000..3faf8e8 --- /dev/null +++ b/src/Infrastructure/Services/TokenService.cs @@ -0,0 +1,126 @@ +using Application.Interfaces; +using Microsoft.Extensions.Configuration; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using Microsoft.IdentityModel.Tokens; +using Application.Features.Auth.Login; +using System.Globalization; + +namespace Infrastructure.Services; + +/// +/// JWT token service for authentication +/// +public sealed class TokenService : ITokenService +{ + private readonly IConfiguration _configuration; + private readonly JwtSecurityTokenHandler _tokenHandler; + + public TokenService(IConfiguration configuration) + { + _configuration = configuration; + _tokenHandler = new JwtSecurityTokenHandler(); + } + + /// + /// Generate access token (JWT) + /// + public string GenerateAccessToken(UserInfo user) + { + var jwtSettings = _configuration.GetSection("JwtSettings"); + var secretKey = jwtSettings["SecretKey"] ?? "YourSuperSecretKeyThatIsAtLeast32Characters!!"; + var issuer = jwtSettings["Issuer"] ?? "LegalAssistant"; + var audience = jwtSettings["Audience"] ?? "LegalAssistantUsers"; + var expirationHours = int.Parse(jwtSettings["ExpirationHours"] ?? "1", CultureInfo.InvariantCulture); + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey)); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var claims = new List + { + new(ClaimTypes.NameIdentifier, user.Id.ToString()), + new(ClaimTypes.Email, user.Email), + new(ClaimTypes.Name, user.FullName), + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + new(JwtRegisteredClaimNames.Iat, + DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture), + ClaimValueTypes.Integer64) + }; + + // Add roles + foreach (var role in user.Roles) + { + claims.Add(new Claim(ClaimTypes.Role, role)); + } + + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity(claims), + Expires = DateTime.UtcNow.AddHours(expirationHours), + Issuer = issuer, + Audience = audience, + SigningCredentials = credentials + }; + + var token = _tokenHandler.CreateToken(tokenDescriptor); + return _tokenHandler.WriteToken(token); + } + + /// + /// Generate refresh token (random string) + /// + public string GenerateRefreshToken() + { + var randomBytes = new byte[32]; + using var rng = RandomNumberGenerator.Create(); + rng.GetBytes(randomBytes); + return Convert.ToBase64String(randomBytes); + } + + public Task?> GetClaimsFromTokenAsync(string token) + { + throw new NotImplementedException(); + } + + public Task ValidateAccessTokenAsync(string token) + { + throw new NotImplementedException(); + } + + /// + /// Validate token and extract claims + /// + public ClaimsPrincipal? ValidateToken(string token) + { + try + { + var jwtSettings = _configuration.GetSection("JwtSettings"); + var secretKey = jwtSettings["SecretKey"] ?? "YourSuperSecretKeyThatIsAtLeast32Characters!!"; + var issuer = jwtSettings["Issuer"] ?? "LegalAssistant"; + var audience = jwtSettings["Audience"] ?? "LegalAssistantUsers"; + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey)); + + var validationParameters = new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = key, + ValidateIssuer = true, + ValidIssuer = issuer, + ValidateAudience = true, + ValidAudience = audience, + ValidateLifetime = true, + ClockSkew = TimeSpan.Zero + }; + + var principal = _tokenHandler.ValidateToken(token, validationParameters, out _); + return principal; + } + catch + { + return null; + } + } +} diff --git a/src/Web.Api/Controllers/V1/AuthController.cs b/src/Web.Api/Controllers/V1/AuthController.cs index ee26040..e567a9b 100644 --- a/src/Web.Api/Controllers/V1/AuthController.cs +++ b/src/Web.Api/Controllers/V1/AuthController.cs @@ -29,6 +29,6 @@ public async Task Login([FromBody] LoginCommand command) return Ok(ApiResponse.CreateSuccess(result.Value!, "Login successful")); } - return BadRequest(ApiResponse.CreateFailure(result.Error!.Message)); + return BadRequest(ApiResponse.CreateFailure(result.Error!.Description)); } } diff --git a/src/Web.Api/Controllers/V1/UsersController.cs b/src/Web.Api/Controllers/V1/UsersController.cs index 4fd5566..fab22fb 100644 --- a/src/Web.Api/Controllers/V1/UsersController.cs +++ b/src/Web.Api/Controllers/V1/UsersController.cs @@ -30,7 +30,7 @@ public async Task GetUsers([FromQuery] GetUsersQuery query) return Ok(ApiResponse>.CreateSuccess(result.Value!, "Users retrieved successfully")); } - return BadRequest(ApiResponse.CreateFailure(result.Error!.Message)); + return BadRequest(ApiResponse.CreateFailure(result.Error!.Description)); } /// @@ -55,6 +55,6 @@ public async Task CreateUser([FromBody] CreateUserCommand command ApiResponse.CreateSuccess(result.Value, "User created successfully")); } - return BadRequest(ApiResponse.CreateFailure(result.Error!.Message)); + return BadRequest(ApiResponse.CreateFailure(result.Error!.Description)); } } diff --git a/src/Web.Api/Filters/ValidateModelFilter.cs b/src/Web.Api/Filters/ValidateModelFilter.cs index 337b15a..c257acb 100644 --- a/src/Web.Api/Filters/ValidateModelFilter.cs +++ b/src/Web.Api/Filters/ValidateModelFilter.cs @@ -7,7 +7,8 @@ namespace Web.Api.Filters; /// /// Action filter để validate model state /// -public sealed class ValidateModelFilter : ActionFilterAttribute +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] +public sealed class ValidateModelFilterAttribute : ActionFilterAttribute { public override void OnActionExecuting(ActionExecutingContext context) { diff --git a/src/Web.Api/Program.cs b/src/Web.Api/Program.cs index ee9b7bf..7342ed6 100644 --- a/src/Web.Api/Program.cs +++ b/src/Web.Api/Program.cs @@ -1,19 +1,57 @@ +using Infrastructure.Extensions; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; +using System.Text; using Web.Api.Extensions; +using Web.Api.Middleware; var builder = WebApplication.CreateBuilder(args); // Add services to the container. builder.Services.AddControllers(); +// Add Infrastructure services (Database, Repositories, Services) +builder.Services.AddInfrastructureServices(builder.Configuration); + // Add Application services (MediatR, Behaviors, Validation) builder.Services.AddApplicationServices(); +// Add Authentication +builder.Services.AddAuthentication(options => +{ + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; +}) +.AddJwtBearer(options => +{ + var jwtSettings = builder.Configuration.GetSection("JwtSettings"); + var secretKey = jwtSettings["SecretKey"] ?? "YourSuperSecretKeyThatIsAtLeast32CharactersLong!!"; + + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey)), + ValidateIssuer = true, + ValidIssuer = jwtSettings["Issuer"] ?? "LegalAssistant", + ValidateAudience = true, + ValidAudience = jwtSettings["Audience"] ?? "LegalAssistantUsers", + ValidateLifetime = true, + ClockSkew = TimeSpan.Zero + }; +}); + +// Add Authorization +builder.Services.AddAuthorization(); + // Add API services builder.Services.AddApiVersioningConfiguration(); builder.Services.AddSwaggerDocumentation(); builder.Services.AddCorsPolicies(); builder.Services.AddEndpointsApiExplorer(); +// Add Health Checks +builder.Services.AddHealthChecks(builder.Configuration); + var app = builder.Build(); // Configure the HTTP request pipeline. @@ -23,43 +61,50 @@ app.UseSwaggerUI(); } +// Add Global Exception Middleware +app.UseMiddleware(); + app.UseHttpsRedirection(); // Add CORS app.UseCors("DefaultPolicy"); -// Add Authentication & Authorization (when implemented) -// app.UseAuthentication(); -// app.UseAuthorization(); +// Add Authentication & Authorization +app.UseAuthentication(); +app.UseAuthorization(); // Map controllers app.MapControllers(); -var summaries = new[] -{ - "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" -}; +// Map Health Checks +app.MapHealthChecks("/health"); -app.MapGet("/weatherforecast", () => -{ -#pragma warning disable CA5394 // Do not use insecure randomness - var forecast = Enumerable.Range(1, 5).Select(index => - new WeatherForecast - ( - DateOnly.FromDateTime(DateTime.Now.AddDays(index)), - Random.Shared.Next(-20, 55), - summaries[Random.Shared.Next(summaries.Length)] - )) - .ToArray(); -#pragma warning restore CA5394 - return forecast; -}) -.WithName("GetWeatherForecast") -.WithOpenApi(); +// Ensure database is created +await EnsureDatabaseCreatedAsync(app); -app.Run(); +await app.RunAsync(); -internal record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) +/// +/// Ensure database is created and optionally seeded +/// +static async Task EnsureDatabaseCreatedAsync(WebApplication app) { - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); + using var scope = app.Services.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + try + { + // Create database if it doesn't exist + await context.Database.EnsureCreatedAsync(); + + // TODO: Add data seeding here if needed + // await SeedDataAsync(context); + + app.Logger.LogInformation("Database ensured and ready"); + } + catch (Exception ex) + { + app.Logger.LogError(ex, "An error occurred while ensuring the database was created"); + throw new InvalidOperationException("Failed to initialize database during application startup", ex); + } } diff --git a/src/Web.Api/Services/CurrentUserService.cs b/src/Web.Api/Services/CurrentUserService.cs index 6e2f9da..cdeac9d 100644 --- a/src/Web.Api/Services/CurrentUserService.cs +++ b/src/Web.Api/Services/CurrentUserService.cs @@ -6,34 +6,82 @@ namespace Web.Api.Services; /// /// Current user service implementation /// -public sealed class CurrentUserService(IHttpContextAccessor httpContextAccessor) : ICurrentUser +public sealed class CurrentUserService : ICurrentUser { + private readonly IHttpContextAccessor _httpContextAccessor; + + public CurrentUserService(IHttpContextAccessor httpContextAccessor) + { + _httpContextAccessor = httpContextAccessor; + } + public Guid? UserId { get { - var userIdClaim = httpContextAccessor.HttpContext?.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value; + var userIdClaim = _httpContextAccessor.HttpContext?.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value; return Guid.TryParse(userIdClaim, out var userId) ? userId : null; } } - public bool IsAuthenticated => httpContextAccessor.HttpContext?.User?.Identity?.IsAuthenticated ?? false; + public bool IsAuthenticated => _httpContextAccessor.HttpContext?.User?.Identity?.IsAuthenticated ?? false; + + public List Roles => _httpContextAccessor.HttpContext?.User?.FindAll(ClaimTypes.Role) + .Select(c => c.Value) + .ToList() ?? []; - public string[] Roles + public List Permissions { get { - var rolesClaims = httpContextAccessor.HttpContext?.User?.FindAll(ClaimTypes.Role); - return rolesClaims?.Select(c => c.Value).ToArray() ?? []; + // TODO: Implement permissions from claims or external service + // For now, derive from roles + var roles = Roles; + var permissions = new List(); + + foreach (var role in roles) + { + permissions.AddRange(GetPermissionsForRole(role)); + } + + return permissions.Distinct().ToList(); } } - public string[] Permissions + /// + /// Get permissions for a specific role + /// + private static List GetPermissionsForRole(string role) { - get + return role.ToUpperInvariant() switch { - var permissionsClaims = httpContextAccessor.HttpContext?.User?.FindAll("permission"); - return permissionsClaims?.Select(c => c.Value).ToArray() ?? []; - } + "ADMIN" => [ + "users.create", "users.read", "users.update", "users.delete", + "conversations.create", "conversations.read", "conversations.update", "conversations.delete", + "messages.create", "messages.read", "messages.update", "messages.delete", + "system.manage" + ], + "MANAGER" => [ + "users.read", "users.update", + "conversations.create", "conversations.read", "conversations.update", + "messages.create", "messages.read", "messages.update", + "reports.read" + ], + "LEGALEXPERT" => [ + "conversations.create", "conversations.read", "conversations.update", + "messages.create", "messages.read", "messages.update", + "legal.advise" + ], + "PREMIUM" => [ + "conversations.create", "conversations.read", + "messages.create", "messages.read", + "premium.features" + ], + "USER" => [ + "conversations.create", "conversations.read", + "messages.create", "messages.read" + ], + _ => [] + }; } } diff --git a/src/Web.Api/Web.Api.csproj b/src/Web.Api/Web.Api.csproj index 2b667f5..75b5ab6 100644 --- a/src/Web.Api/Web.Api.csproj +++ b/src/Web.Api/Web.Api.csproj @@ -15,6 +15,7 @@ + diff --git a/src/Web.Api/appsettings.json b/src/Web.Api/appsettings.json index 10f68b8..2fbf78c 100644 --- a/src/Web.Api/appsettings.json +++ b/src/Web.Api/appsettings.json @@ -5,5 +5,33 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "ConnectionStrings": { + "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=LegalAssistantDb;Trusted_Connection=true;MultipleActiveResultSets=true;", + "Redis": "" + }, + "JwtSettings": { + "SecretKey": "YourSuperSecretKeyThatIsAtLeast32CharactersLong!!", + "Issuer": "LegalAssistant", + "Audience": "LegalAssistantUsers", + "ExpirationHours": 1, + "RefreshTokenExpirationDays": 7 + }, + "EmailSettings": { + "SmtpServer": "smtp.gmail.com", + "SmtpPort": 587, + "Username": "", + "Password": "", + "FromEmail": "noreply@legalassistant.com", + "FromName": "Legal Assistant", + "EnableSsl": true + }, + "FileStorageSettings": { + "Provider": "Local", + "ConnectionString": "", + "ContainerName": "files", + "BasePath": "uploads", + "MaxFileSizeBytes": 10485760, + "AllowedExtensions": [".pdf", ".docx", ".png", ".jpg", ".jpeg"] + } }