Compare commits
3 Commits
709068f68b
...
a220e5d5d7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a220e5d5d7 | ||
|
|
5c541ddb79 | ||
|
|
0e503176c4 |
@@ -5,7 +5,9 @@
|
|||||||
"Bash(tasklist:*)",
|
"Bash(tasklist:*)",
|
||||||
"Bash(dotnet test:*)",
|
"Bash(dotnet test:*)",
|
||||||
"Bash(tree:*)",
|
"Bash(tree:*)",
|
||||||
"Bash(dotnet add:*)"
|
"Bash(dotnet add:*)",
|
||||||
|
"Bash(timeout 5 powershell:*)",
|
||||||
|
"Bash(Select-String -Pattern \"Tenant ID:|User ID:|Role\")"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|||||||
3315
colaflow-api/DAY7-PRD.md
Normal file
3315
colaflow-api/DAY7-PRD.md
Normal file
File diff suppressed because it is too large
Load Diff
950
colaflow-api/DOMAIN-EVENTS-ANALYSIS.md
Normal file
950
colaflow-api/DOMAIN-EVENTS-ANALYSIS.md
Normal file
@@ -0,0 +1,950 @@
|
|||||||
|
# Domain Events Implementation Analysis & Plan
|
||||||
|
|
||||||
|
**Date:** 2025-11-03
|
||||||
|
**Module:** Identity Module (ColaFlow.Modules.Identity)
|
||||||
|
**Status:** Gap Analysis Complete - Implementation Required
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
### Current State
|
||||||
|
The Identity module has **partial domain events implementation**:
|
||||||
|
- ✅ Domain event infrastructure exists (base classes, AggregateRoot pattern)
|
||||||
|
- ✅ **11 domain events defined** in the domain layer
|
||||||
|
- ✅ Domain events are being **raised** in aggregates (Tenant, User)
|
||||||
|
- ❌ **Domain events are NOT being dispatched** (events are raised but never published)
|
||||||
|
- ❌ **No domain event handlers** implemented
|
||||||
|
- ❌ Repository pattern calls `SaveChangesAsync` directly, bypassing event dispatching
|
||||||
|
|
||||||
|
### Critical Finding
|
||||||
|
**Domain events are being collected but never published!** This means:
|
||||||
|
- Events like `TenantCreated`, `UserCreated`, `UserRoleAssigned` are raised but silently discarded
|
||||||
|
- No audit logging, no side effects, no cross-module notifications
|
||||||
|
- The infrastructure is 80% complete but missing the final critical piece
|
||||||
|
|
||||||
|
### Recommended Action
|
||||||
|
**Immediate implementation required** - Domain events are foundational for:
|
||||||
|
- Audit logging (required for compliance)
|
||||||
|
- Cross-module communication (required for modularity)
|
||||||
|
- Side effects (email notifications, cache invalidation, etc.)
|
||||||
|
- Event sourcing (future requirement)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Current State Assessment
|
||||||
|
|
||||||
|
### 1.1 Domain Event Infrastructure (✅ Complete)
|
||||||
|
|
||||||
|
#### Base Classes
|
||||||
|
|
||||||
|
**`ColaFlow.Shared.Kernel.Events.DomainEvent`**
|
||||||
|
```csharp
|
||||||
|
public abstract record DomainEvent
|
||||||
|
{
|
||||||
|
public Guid EventId { get; init; } = Guid.NewGuid();
|
||||||
|
public DateTime OccurredOn { get; init; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- ✅ Properly designed as record (immutable)
|
||||||
|
- ✅ Auto-generates EventId and timestamp
|
||||||
|
- ✅ Follows best practices
|
||||||
|
|
||||||
|
**`ColaFlow.Shared.Kernel.Common.AggregateRoot`**
|
||||||
|
```csharp
|
||||||
|
public abstract class AggregateRoot : Entity
|
||||||
|
{
|
||||||
|
private readonly List<DomainEvent> _domainEvents = new();
|
||||||
|
|
||||||
|
public IReadOnlyCollection<DomainEvent> DomainEvents => _domainEvents.AsReadOnly();
|
||||||
|
|
||||||
|
protected void AddDomainEvent(DomainEvent domainEvent)
|
||||||
|
{
|
||||||
|
_domainEvents.Add(domainEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ClearDomainEvents()
|
||||||
|
{
|
||||||
|
_domainEvents.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- ✅ Encapsulates domain events collection
|
||||||
|
- ✅ Provides AddDomainEvent method for aggregates
|
||||||
|
- ✅ Provides ClearDomainEvents for cleanup after dispatching
|
||||||
|
- ✅ Follows DDD best practices (encapsulation)
|
||||||
|
|
||||||
|
### 1.2 Domain Events Defined (✅ Complete)
|
||||||
|
|
||||||
|
#### Tenant Events (7 events)
|
||||||
|
|
||||||
|
| Event | File | Raised In | Purpose |
|
||||||
|
|-------|------|-----------|---------|
|
||||||
|
| `TenantCreatedEvent` | `Tenants/Events/` | `Tenant.Create()` | New tenant registration |
|
||||||
|
| `TenantActivatedEvent` | `Tenants/Events/` | `Tenant.Activate()` | Tenant reactivation |
|
||||||
|
| `TenantSuspendedEvent` | `Tenants/Events/` | `Tenant.Suspend()` | Tenant suspension |
|
||||||
|
| `TenantCancelledEvent` | `Tenants/Events/` | `Tenant.Cancel()` | Tenant cancellation |
|
||||||
|
| `TenantPlanUpgradedEvent` | `Tenants/Events/` | `Tenant.UpgradePlan()` | Plan upgrade |
|
||||||
|
| `SsoConfiguredEvent` | `Tenants/Events/` | `Tenant.ConfigureSso()` | SSO setup |
|
||||||
|
| `SsoDisabledEvent` | `Tenants/Events/` | `Tenant.DisableSso()` | SSO removal |
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```csharp
|
||||||
|
public sealed record TenantCreatedEvent(Guid TenantId, string Slug) : DomainEvent;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### User Events (4 events)
|
||||||
|
|
||||||
|
| Event | File | Raised In | Purpose |
|
||||||
|
|-------|------|-----------|---------|
|
||||||
|
| `UserCreatedEvent` | `Users/Events/` | `User.CreateLocal()` | Local user registration |
|
||||||
|
| `UserCreatedFromSsoEvent` | `Users/Events/` | `User.CreateFromSso()` | SSO user registration |
|
||||||
|
| `UserPasswordChangedEvent` | `Users/Events/` | `User.UpdatePassword()` | Password change |
|
||||||
|
| `UserSuspendedEvent` | `Users/Events/` | `User.Suspend()` | User suspension |
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```csharp
|
||||||
|
public sealed record UserCreatedEvent(
|
||||||
|
Guid UserId,
|
||||||
|
string Email,
|
||||||
|
TenantId TenantId
|
||||||
|
) : DomainEvent;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 Event Dispatching Infrastructure (❌ Missing in Identity Module)
|
||||||
|
|
||||||
|
#### ProjectManagement Module (Reference Implementation)
|
||||||
|
|
||||||
|
**`ColaFlow.Modules.ProjectManagement.Infrastructure.Persistence.UnitOfWork`**
|
||||||
|
```csharp
|
||||||
|
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// Dispatch domain events before saving
|
||||||
|
await DispatchDomainEventsAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Save changes to database
|
||||||
|
return await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DispatchDomainEventsAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Get all entities with domain events
|
||||||
|
var domainEntities = _context.ChangeTracker
|
||||||
|
.Entries<AggregateRoot>()
|
||||||
|
.Where(x => x.Entity.DomainEvents.Any())
|
||||||
|
.Select(x => x.Entity)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Get all domain events
|
||||||
|
var domainEvents = domainEntities
|
||||||
|
.SelectMany(x => x.DomainEvents)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Clear domain events from entities
|
||||||
|
domainEntities.ForEach(entity => entity.ClearDomainEvents());
|
||||||
|
|
||||||
|
// TODO: Dispatch domain events to handlers
|
||||||
|
// This will be implemented when we add MediatR
|
||||||
|
await Task.CompletedTask;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status:** ✅ Infrastructure exists in ProjectManagement module, ❌ Not implemented in Identity module
|
||||||
|
|
||||||
|
#### Identity Module (Current Implementation)
|
||||||
|
|
||||||
|
**`IdentityDbContext`**
|
||||||
|
- ❌ No `SaveChangesAsync` override
|
||||||
|
- ❌ No domain event dispatching
|
||||||
|
- ❌ No UnitOfWork pattern
|
||||||
|
|
||||||
|
**Repositories (TenantRepository, UserRepository, etc.)**
|
||||||
|
```csharp
|
||||||
|
public async Task AddAsync(Tenant tenant, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await _context.Tenants.AddAsync(tenant, cancellationToken);
|
||||||
|
await _context.SaveChangesAsync(cancellationToken); // ❌ Direct call, bypasses events
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem:** Repositories call `DbContext.SaveChangesAsync()` directly, so domain events are never dispatched.
|
||||||
|
|
||||||
|
### 1.4 Domain Event Handlers (❌ Missing)
|
||||||
|
|
||||||
|
**Current State:**
|
||||||
|
- ❌ No `INotificationHandler<TEvent>` implementations
|
||||||
|
- ❌ No event handler folder structure
|
||||||
|
- ❌ MediatR registered in Application layer but not configured for domain events
|
||||||
|
|
||||||
|
**Expected Structure (Not Present):**
|
||||||
|
```
|
||||||
|
ColaFlow.Modules.Identity.Application/
|
||||||
|
├── EventHandlers/
|
||||||
|
│ ├── Tenants/
|
||||||
|
│ │ ├── TenantCreatedEventHandler.cs ❌ Missing
|
||||||
|
│ │ └── TenantPlanUpgradedEventHandler.cs ❌ Missing
|
||||||
|
│ └── Users/
|
||||||
|
│ ├── UserCreatedEventHandler.cs ❌ Missing
|
||||||
|
│ └── UserSuspendedEventHandler.cs ❌ Missing
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Gap Analysis
|
||||||
|
|
||||||
|
### 2.1 What's Working
|
||||||
|
|
||||||
|
| Component | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| Domain Event Base Class | ✅ Complete | Well-designed record with EventId and timestamp |
|
||||||
|
| AggregateRoot Pattern | ✅ Complete | Proper encapsulation of domain events collection |
|
||||||
|
| Domain Events Defined | ✅ Complete | 11 events defined and raised in aggregates |
|
||||||
|
| MediatR Registration | ✅ Complete | MediatR registered in Application layer |
|
||||||
|
|
||||||
|
### 2.2 What's Missing
|
||||||
|
|
||||||
|
| Component | Status | Impact | Priority |
|
||||||
|
|-----------|--------|--------|----------|
|
||||||
|
| **Event Dispatching in DbContext** | ❌ Missing | HIGH - Events never published | **CRITICAL** |
|
||||||
|
| **UnitOfWork Pattern** | ❌ Missing | HIGH - No transaction boundary for events | **CRITICAL** |
|
||||||
|
| **Domain Event Handlers** | ❌ Missing | HIGH - No side effects, no audit logging | **HIGH** |
|
||||||
|
| **MediatR Integration for Events** | ❌ Missing | HIGH - Events not routed to handlers | **CRITICAL** |
|
||||||
|
| **Repository Pattern Refactoring** | ❌ Missing | MEDIUM - Repositories bypass UnitOfWork | **HIGH** |
|
||||||
|
|
||||||
|
### 2.3 Missing Events (Day 6+ Features)
|
||||||
|
|
||||||
|
Based on Day 4-6 implementation, these events should exist but don't:
|
||||||
|
|
||||||
|
| Event | Scenario | Raised In | Priority |
|
||||||
|
|-------|----------|-----------|----------|
|
||||||
|
| `UserLoggedInEvent` | Login success | LoginCommandHandler | HIGH |
|
||||||
|
| `UserLoginFailedEvent` | Login failure | LoginCommandHandler | MEDIUM |
|
||||||
|
| `RefreshTokenGeneratedEvent` | Token refresh | RefreshTokenService | MEDIUM |
|
||||||
|
| `RefreshTokenRevokedEvent` | Token revocation | RefreshTokenService | MEDIUM |
|
||||||
|
| `UserRoleAssignedEvent` | Role assignment | AssignUserRoleCommand | **HIGH** |
|
||||||
|
| `UserRoleUpdatedEvent` | Role change | AssignUserRoleCommand | **HIGH** |
|
||||||
|
| `UserRemovedFromTenantEvent` | User removal | RemoveUserFromTenantCommand | **HIGH** |
|
||||||
|
| `UserTokensRevokedEvent` | Token revocation | RemoveUserFromTenantCommand | MEDIUM |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Recommended Architecture
|
||||||
|
|
||||||
|
### 3.1 Domain Event Dispatching Pattern
|
||||||
|
|
||||||
|
**Option A: Dispatch in DbContext.SaveChangesAsync (Recommended)**
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- ✅ Centralized event dispatching
|
||||||
|
- ✅ Consistent across all operations
|
||||||
|
- ✅ Events dispatched within transaction boundary
|
||||||
|
- ✅ Follows EF Core best practices
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- ⚠️ Requires overriding `SaveChangesAsync` in each module's DbContext
|
||||||
|
- ⚠️ Tight coupling to EF Core
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```csharp
|
||||||
|
// IdentityDbContext.cs
|
||||||
|
public class IdentityDbContext : DbContext
|
||||||
|
{
|
||||||
|
private readonly IMediator _mediator;
|
||||||
|
|
||||||
|
public IdentityDbContext(
|
||||||
|
DbContextOptions<IdentityDbContext> options,
|
||||||
|
ITenantContext tenantContext,
|
||||||
|
IMediator mediator) // ✅ Inject MediatR
|
||||||
|
: base(options)
|
||||||
|
{
|
||||||
|
_tenantContext = tenantContext;
|
||||||
|
_mediator = mediator;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// Dispatch domain events BEFORE saving
|
||||||
|
await DispatchDomainEventsAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Save changes to database
|
||||||
|
return await base.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DispatchDomainEventsAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Get all aggregate roots with domain events
|
||||||
|
var domainEntities = ChangeTracker
|
||||||
|
.Entries<AggregateRoot>()
|
||||||
|
.Where(x => x.Entity.DomainEvents.Any())
|
||||||
|
.Select(x => x.Entity)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Get all domain events
|
||||||
|
var domainEvents = domainEntities
|
||||||
|
.SelectMany(x => x.DomainEvents)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Clear domain events from entities
|
||||||
|
domainEntities.ForEach(entity => entity.ClearDomainEvents());
|
||||||
|
|
||||||
|
// Dispatch events to handlers via MediatR
|
||||||
|
foreach (var domainEvent in domainEvents)
|
||||||
|
{
|
||||||
|
await _mediator.Publish(domainEvent, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option B: Dispatch in UnitOfWork (Alternative)**
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- ✅ Decouples from DbContext
|
||||||
|
- ✅ Testable without EF Core
|
||||||
|
- ✅ Follows Clean Architecture more strictly
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- ⚠️ Requires UnitOfWork pattern implementation
|
||||||
|
- ⚠️ More boilerplate code
|
||||||
|
- ⚠️ Repositories must use UnitOfWork instead of direct SaveChangesAsync
|
||||||
|
|
||||||
|
**Not recommended for now** - Option A is simpler and sufficient for current needs.
|
||||||
|
|
||||||
|
### 3.2 MediatR Configuration
|
||||||
|
|
||||||
|
**Current Configuration:**
|
||||||
|
```csharp
|
||||||
|
// Application/DependencyInjection.cs
|
||||||
|
public static IServiceCollection AddIdentityApplication(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
// MediatR
|
||||||
|
services.AddMediatR(config =>
|
||||||
|
{
|
||||||
|
config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly);
|
||||||
|
});
|
||||||
|
|
||||||
|
// FluentValidation
|
||||||
|
services.AddValidatorsFromAssembly(typeof(DependencyInjection).Assembly);
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status:** ✅ Already configured for commands/queries, will automatically handle domain events
|
||||||
|
|
||||||
|
**How MediatR Works:**
|
||||||
|
1. Domain events inherit from `DomainEvent` (which is a record)
|
||||||
|
2. Event handlers implement `INotificationHandler<TEvent>`
|
||||||
|
3. `_mediator.Publish(event)` dispatches to ALL handlers
|
||||||
|
|
||||||
|
**Key Point:** MediatR treats domain events as notifications (pub-sub pattern), so multiple handlers can react to the same event.
|
||||||
|
|
||||||
|
### 3.3 Domain Event Handler Pattern
|
||||||
|
|
||||||
|
**Handler Structure:**
|
||||||
|
```csharp
|
||||||
|
// Application/EventHandlers/Users/UserCreatedEventHandler.cs
|
||||||
|
public class UserCreatedEventHandler : INotificationHandler<UserCreatedEvent>
|
||||||
|
{
|
||||||
|
private readonly IAuditLogRepository _auditLogRepository;
|
||||||
|
private readonly ILogger<UserCreatedEventHandler> _logger;
|
||||||
|
|
||||||
|
public UserCreatedEventHandler(
|
||||||
|
IAuditLogRepository auditLogRepository,
|
||||||
|
ILogger<UserCreatedEventHandler> logger)
|
||||||
|
{
|
||||||
|
_auditLogRepository = auditLogRepository;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Handle(UserCreatedEvent notification, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"User {UserId} created in tenant {TenantId}",
|
||||||
|
notification.UserId,
|
||||||
|
notification.TenantId);
|
||||||
|
|
||||||
|
// Example: Log to audit trail
|
||||||
|
var auditLog = AuditLog.Create(
|
||||||
|
entityType: "User",
|
||||||
|
entityId: notification.UserId,
|
||||||
|
action: "Created",
|
||||||
|
performedBy: notification.UserId, // Self-registration
|
||||||
|
timestamp: notification.OccurredOn);
|
||||||
|
|
||||||
|
await _auditLogRepository.AddAsync(auditLog, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Multiple Handlers for Same Event:**
|
||||||
|
```csharp
|
||||||
|
// Application/EventHandlers/Users/UserCreatedEmailNotificationHandler.cs
|
||||||
|
public class UserCreatedEmailNotificationHandler : INotificationHandler<UserCreatedEvent>
|
||||||
|
{
|
||||||
|
private readonly IEmailService _emailService;
|
||||||
|
|
||||||
|
public async Task Handle(UserCreatedEvent notification, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Send welcome email
|
||||||
|
await _emailService.SendWelcomeEmailAsync(
|
||||||
|
notification.Email,
|
||||||
|
notification.UserId,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Benefits:**
|
||||||
|
- ✅ Single Responsibility Principle (each handler does one thing)
|
||||||
|
- ✅ Decoupled side effects (audit, email, cache, etc.)
|
||||||
|
- ✅ Easy to add new handlers without modifying existing code
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Implementation Plan
|
||||||
|
|
||||||
|
### Option A: Implement Now (Recommended)
|
||||||
|
|
||||||
|
**Reasoning:**
|
||||||
|
- Domain events are fundamental to the architecture
|
||||||
|
- Required for Day 6 features (role management audit)
|
||||||
|
- Critical for audit logging and compliance
|
||||||
|
- Relatively small implementation effort (2-4 hours)
|
||||||
|
|
||||||
|
**Timeline:** Day 6 (Today) - Implement alongside role management features
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Option B: Implement in Day 7
|
||||||
|
|
||||||
|
**Reasoning:**
|
||||||
|
- Can defer if Day 6 deadline is tight
|
||||||
|
- Focus on completing role management first
|
||||||
|
- Implement events as cleanup/refactoring task
|
||||||
|
|
||||||
|
**Timeline:** Day 7 (Tomorrow) - Dedicated domain events implementation day
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Option C: Incremental Implementation
|
||||||
|
|
||||||
|
**Reasoning:**
|
||||||
|
- Implement infrastructure first (dispatching in DbContext)
|
||||||
|
- Add event handlers incrementally as needed
|
||||||
|
- Start with critical events (UserCreated, TenantCreated, UserRoleAssigned)
|
||||||
|
|
||||||
|
**Timeline:** Days 6-8 - Spread across multiple days
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ✅ RECOMMENDED: Option C (Incremental Implementation)
|
||||||
|
|
||||||
|
**Phase 1: Infrastructure (Day 6, ~1 hour)**
|
||||||
|
1. Override `SaveChangesAsync` in `IdentityDbContext`
|
||||||
|
2. Implement `DispatchDomainEventsAsync` method
|
||||||
|
3. Inject `IMediator` into DbContext
|
||||||
|
4. Test that events are being published (add logging)
|
||||||
|
|
||||||
|
**Phase 2: Critical Event Handlers (Day 6-7, ~2 hours)**
|
||||||
|
1. `UserCreatedEventHandler` - Audit logging
|
||||||
|
2. `TenantCreatedEventHandler` - Audit logging
|
||||||
|
3. `UserRoleAssignedEventHandler` - Audit logging + cache invalidation
|
||||||
|
|
||||||
|
**Phase 3: Additional Event Handlers (Day 7-8, ~2 hours)**
|
||||||
|
1. `UserLoggedInEvent` + handler - Login audit trail
|
||||||
|
2. `RefreshTokenRevokedEvent` + handler - Security audit
|
||||||
|
3. `TenantSuspendedEvent` + handler - Notify users, revoke tokens
|
||||||
|
|
||||||
|
**Phase 4: Future Events (Day 9+)**
|
||||||
|
1. Email verification events
|
||||||
|
2. Password reset events
|
||||||
|
3. SSO events
|
||||||
|
4. Cross-module integration events
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Step-by-Step Implementation Guide
|
||||||
|
|
||||||
|
### Step 1: Add Domain Event Dispatching to DbContext
|
||||||
|
|
||||||
|
**File:** `src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/IdentityDbContext.cs`
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
```csharp
|
||||||
|
using ColaFlow.Shared.Kernel.Common;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
public class IdentityDbContext : DbContext
|
||||||
|
{
|
||||||
|
private readonly ITenantContext _tenantContext;
|
||||||
|
private readonly IMediator _mediator; // ✅ Add
|
||||||
|
|
||||||
|
public IdentityDbContext(
|
||||||
|
DbContextOptions<IdentityDbContext> options,
|
||||||
|
ITenantContext tenantContext,
|
||||||
|
IMediator mediator) // ✅ Add
|
||||||
|
: base(options)
|
||||||
|
{
|
||||||
|
_tenantContext = tenantContext;
|
||||||
|
_mediator = mediator; // ✅ Add
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Add SaveChangesAsync override
|
||||||
|
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await DispatchDomainEventsAsync(cancellationToken);
|
||||||
|
return await base.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Add DispatchDomainEventsAsync method
|
||||||
|
private async Task DispatchDomainEventsAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var domainEntities = ChangeTracker
|
||||||
|
.Entries<AggregateRoot>()
|
||||||
|
.Where(x => x.Entity.DomainEvents.Any())
|
||||||
|
.Select(x => x.Entity)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var domainEvents = domainEntities
|
||||||
|
.SelectMany(x => x.DomainEvents)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
domainEntities.ForEach(entity => entity.ClearDomainEvents());
|
||||||
|
|
||||||
|
foreach (var domainEvent in domainEvents)
|
||||||
|
{
|
||||||
|
await _mediator.Publish(domainEvent, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Estimated Time:** 15 minutes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 2: Create Missing Domain Events
|
||||||
|
|
||||||
|
**File:** `src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Users/Events/UserRoleAssignedEvent.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ColaFlow.Shared.Kernel.Events;
|
||||||
|
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
|
||||||
|
|
||||||
|
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Users.Events;
|
||||||
|
|
||||||
|
public sealed record UserRoleAssignedEvent(
|
||||||
|
Guid UserId,
|
||||||
|
TenantId TenantId,
|
||||||
|
TenantRole Role,
|
||||||
|
Guid AssignedBy
|
||||||
|
) : DomainEvent;
|
||||||
|
```
|
||||||
|
|
||||||
|
**File:** `src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Users/Events/UserRemovedFromTenantEvent.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public sealed record UserRemovedFromTenantEvent(
|
||||||
|
Guid UserId,
|
||||||
|
TenantId TenantId,
|
||||||
|
Guid RemovedBy
|
||||||
|
) : DomainEvent;
|
||||||
|
```
|
||||||
|
|
||||||
|
**File:** `src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Users/Events/UserLoggedInEvent.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public sealed record UserLoggedInEvent(
|
||||||
|
Guid UserId,
|
||||||
|
TenantId TenantId,
|
||||||
|
string IpAddress,
|
||||||
|
string UserAgent
|
||||||
|
) : DomainEvent;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Estimated Time:** 30 minutes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 3: Raise Events in Aggregates
|
||||||
|
|
||||||
|
**Update:** `AssignUserRoleCommandHandler` to raise `UserRoleAssignedEvent`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// AssignUserRoleCommandHandler.cs
|
||||||
|
public async Task<Unit> Handle(AssignUserRoleCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// ... existing validation logic ...
|
||||||
|
|
||||||
|
// Create or update role assignment
|
||||||
|
var userTenantRole = UserTenantRole.Create(userId, tenantId, request.Role);
|
||||||
|
await _userTenantRoleRepository.AddOrUpdateAsync(userTenantRole, cancellationToken);
|
||||||
|
|
||||||
|
// ✅ Raise domain event (if we make UserTenantRole an AggregateRoot)
|
||||||
|
// OR raise event from User aggregate
|
||||||
|
var user = await _userRepository.GetByIdAsync(userId, cancellationToken);
|
||||||
|
if (user != null)
|
||||||
|
{
|
||||||
|
user.AddDomainEvent(new UserRoleAssignedEvent(
|
||||||
|
userId.Value,
|
||||||
|
tenantId,
|
||||||
|
request.Role,
|
||||||
|
currentUserId)); // From JWT claims
|
||||||
|
}
|
||||||
|
|
||||||
|
return Unit.Value;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Estimated Time:** 1 hour (refactor command handlers)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 4: Create Event Handlers
|
||||||
|
|
||||||
|
**File:** `src/Modules/Identity/ColaFlow.Modules.Identity.Application/EventHandlers/Users/UserRoleAssignedEventHandler.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ColaFlow.Modules.Identity.Domain.Aggregates.Users.Events;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace ColaFlow.Modules.Identity.Application.EventHandlers.Users;
|
||||||
|
|
||||||
|
public class UserRoleAssignedEventHandler : INotificationHandler<UserRoleAssignedEvent>
|
||||||
|
{
|
||||||
|
private readonly ILogger<UserRoleAssignedEventHandler> _logger;
|
||||||
|
|
||||||
|
public UserRoleAssignedEventHandler(ILogger<UserRoleAssignedEventHandler> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task Handle(UserRoleAssignedEvent notification, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"User {UserId} assigned role {Role} in tenant {TenantId} by user {AssignedBy}",
|
||||||
|
notification.UserId,
|
||||||
|
notification.Role,
|
||||||
|
notification.TenantId,
|
||||||
|
notification.AssignedBy);
|
||||||
|
|
||||||
|
// TODO: Add to audit log
|
||||||
|
// TODO: Invalidate user's cached permissions
|
||||||
|
// TODO: Send notification to user
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Estimated Time:** 30 minutes per handler (create 3-5 handlers)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 5: Test Domain Events
|
||||||
|
|
||||||
|
**Test Script:**
|
||||||
|
```csharp
|
||||||
|
// Integration test
|
||||||
|
[Fact]
|
||||||
|
public async Task AssignUserRole_Should_Raise_UserRoleAssignedEvent()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var command = new AssignUserRoleCommand(userId, tenantId, TenantRole.Admin);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await _mediator.Send(command);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
// Verify event was raised and handled
|
||||||
|
_mockLogger.Verify(
|
||||||
|
x => x.LogInformation(
|
||||||
|
It.Is<string>(s => s.Contains("User") && s.Contains("assigned role")),
|
||||||
|
It.IsAny<object[]>()),
|
||||||
|
Times.Once);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Manual Test:**
|
||||||
|
1. Assign a role to a user via API
|
||||||
|
2. Check logs for "User {UserId} assigned role {Role}"
|
||||||
|
3. Verify event was published and handler executed
|
||||||
|
|
||||||
|
**Estimated Time:** 30 minutes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Priority Assessment
|
||||||
|
|
||||||
|
### Critical Events (Implement in Day 6)
|
||||||
|
|
||||||
|
| Event | Scenario | Handler Actions | Priority |
|
||||||
|
|-------|----------|----------------|----------|
|
||||||
|
| `UserRoleAssignedEvent` | Role assignment | Audit log, cache invalidation, notification | **CRITICAL** |
|
||||||
|
| `UserRemovedFromTenantEvent` | User removal | Audit log, revoke tokens, cleanup | **CRITICAL** |
|
||||||
|
| `TenantCreatedEvent` | Tenant registration | Audit log, send welcome email | **HIGH** |
|
||||||
|
| `UserCreatedEvent` | User registration | Audit log, send welcome email | **HIGH** |
|
||||||
|
|
||||||
|
### High Priority Events (Implement in Day 7)
|
||||||
|
|
||||||
|
| Event | Scenario | Handler Actions | Priority |
|
||||||
|
|-------|----------|----------------|----------|
|
||||||
|
| `UserLoggedInEvent` | Login success | Audit log, update LastLoginAt | **HIGH** |
|
||||||
|
| `RefreshTokenRevokedEvent` | Token revocation | Audit log, security notification | **HIGH** |
|
||||||
|
| `TenantSuspendedEvent` | Tenant suspension | Notify users, revoke all tokens | **HIGH** |
|
||||||
|
| `UserSuspendedEvent` | User suspension | Revoke tokens, audit log | **HIGH** |
|
||||||
|
|
||||||
|
### Medium Priority Events (Implement in Day 8+)
|
||||||
|
|
||||||
|
| Event | Scenario | Handler Actions | Priority |
|
||||||
|
|-------|----------|----------------|----------|
|
||||||
|
| `UserPasswordChangedEvent` | Password change | Audit log, revoke old tokens, email notification | MEDIUM |
|
||||||
|
| `TenantPlanUpgradedEvent` | Plan upgrade | Update limits, audit log, send invoice | MEDIUM |
|
||||||
|
| `SsoConfiguredEvent` | SSO setup | Audit log, notify admins | MEDIUM |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Risks & Mitigation
|
||||||
|
|
||||||
|
### Risk 1: Performance Impact
|
||||||
|
**Risk:** Dispatching many events could slow down SaveChangesAsync
|
||||||
|
**Mitigation:**
|
||||||
|
- Domain events are published in-process (fast)
|
||||||
|
- Consider async background processing for non-critical events (future)
|
||||||
|
- Monitor performance with logging
|
||||||
|
|
||||||
|
### Risk 2: Event Handler Failures
|
||||||
|
**Risk:** Event handler throws exception, entire transaction rolls back
|
||||||
|
**Mitigation:**
|
||||||
|
- Wrap event dispatching in try-catch
|
||||||
|
- Log exceptions but don't fail transaction
|
||||||
|
- Consider eventual consistency for non-critical handlers
|
||||||
|
|
||||||
|
### Risk 3: Event Ordering
|
||||||
|
**Risk:** Events might be processed out of order
|
||||||
|
**Mitigation:**
|
||||||
|
- Events are dispatched in the order they were raised (in single transaction)
|
||||||
|
- Use OccurredOn timestamp for ordering if needed
|
||||||
|
- Consider event sequence numbers (future)
|
||||||
|
|
||||||
|
### Risk 4: Missing Events
|
||||||
|
**Risk:** Forgetting to raise events in new features
|
||||||
|
**Mitigation:**
|
||||||
|
- Document event-raising conventions
|
||||||
|
- Code review checklist
|
||||||
|
- Integration tests to verify events are raised
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Success Metrics
|
||||||
|
|
||||||
|
### Implementation Success Criteria
|
||||||
|
|
||||||
|
**Phase 1: Infrastructure (Day 6)**
|
||||||
|
- ✅ `SaveChangesAsync` override implemented in IdentityDbContext
|
||||||
|
- ✅ Domain events are being published (verified via logging)
|
||||||
|
- ✅ No breaking changes to existing functionality
|
||||||
|
- ✅ Unit tests pass
|
||||||
|
|
||||||
|
**Phase 2: Critical Handlers (Day 6-7)**
|
||||||
|
- ✅ 3-5 event handlers implemented and tested
|
||||||
|
- ✅ Audit logs are being created for critical operations
|
||||||
|
- ✅ Events are visible in application logs
|
||||||
|
- ✅ Integration tests verify event handling
|
||||||
|
|
||||||
|
**Phase 3: Full Coverage (Day 8+)**
|
||||||
|
- ✅ All 15+ events have at least one handler
|
||||||
|
- ✅ Audit logging complete for all major operations
|
||||||
|
- ✅ Cross-module events work correctly
|
||||||
|
- ✅ Performance impact is acceptable (<10ms per event)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Example: Complete Event Flow
|
||||||
|
|
||||||
|
### Scenario: User Role Assignment
|
||||||
|
|
||||||
|
**1. Domain Event Definition**
|
||||||
|
```csharp
|
||||||
|
// Domain/Aggregates/Users/Events/UserRoleAssignedEvent.cs
|
||||||
|
public sealed record UserRoleAssignedEvent(
|
||||||
|
Guid UserId,
|
||||||
|
TenantId TenantId,
|
||||||
|
TenantRole Role,
|
||||||
|
Guid AssignedBy
|
||||||
|
) : DomainEvent;
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Raise Event in Aggregate**
|
||||||
|
```csharp
|
||||||
|
// Domain/Aggregates/Users/User.cs
|
||||||
|
public class User : AggregateRoot
|
||||||
|
{
|
||||||
|
public void AssignRole(TenantRole role, Guid assignedBy)
|
||||||
|
{
|
||||||
|
// Business logic validation
|
||||||
|
if (Status == UserStatus.Deleted)
|
||||||
|
throw new InvalidOperationException("Cannot assign role to deleted user");
|
||||||
|
|
||||||
|
// Raise domain event
|
||||||
|
AddDomainEvent(new UserRoleAssignedEvent(
|
||||||
|
Id,
|
||||||
|
TenantId,
|
||||||
|
role,
|
||||||
|
assignedBy));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Event Handler (Audit Logging)**
|
||||||
|
```csharp
|
||||||
|
// Application/EventHandlers/Users/UserRoleAssignedAuditHandler.cs
|
||||||
|
public class UserRoleAssignedAuditHandler : INotificationHandler<UserRoleAssignedEvent>
|
||||||
|
{
|
||||||
|
private readonly IAuditLogRepository _auditLogRepository;
|
||||||
|
|
||||||
|
public async Task Handle(UserRoleAssignedEvent notification, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var auditLog = AuditLog.Create(
|
||||||
|
entityType: "User",
|
||||||
|
entityId: notification.UserId,
|
||||||
|
action: $"RoleAssigned:{notification.Role}",
|
||||||
|
performedBy: notification.AssignedBy,
|
||||||
|
timestamp: notification.OccurredOn,
|
||||||
|
tenantId: notification.TenantId);
|
||||||
|
|
||||||
|
await _auditLogRepository.AddAsync(auditLog, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. Event Handler (Cache Invalidation)**
|
||||||
|
```csharp
|
||||||
|
// Application/EventHandlers/Users/UserRoleAssignedCacheHandler.cs
|
||||||
|
public class UserRoleAssignedCacheHandler : INotificationHandler<UserRoleAssignedEvent>
|
||||||
|
{
|
||||||
|
private readonly IDistributedCache _cache;
|
||||||
|
|
||||||
|
public async Task Handle(UserRoleAssignedEvent notification, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Invalidate user's permissions cache
|
||||||
|
var cacheKey = $"user:permissions:{notification.UserId}";
|
||||||
|
await _cache.RemoveAsync(cacheKey, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**5. Event Handler (Notification)**
|
||||||
|
```csharp
|
||||||
|
// Application/EventHandlers/Users/UserRoleAssignedNotificationHandler.cs
|
||||||
|
public class UserRoleAssignedNotificationHandler : INotificationHandler<UserRoleAssignedEvent>
|
||||||
|
{
|
||||||
|
private readonly INotificationService _notificationService;
|
||||||
|
|
||||||
|
public async Task Handle(UserRoleAssignedEvent notification, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Send notification to user
|
||||||
|
await _notificationService.SendAsync(
|
||||||
|
userId: notification.UserId,
|
||||||
|
title: "Role Updated",
|
||||||
|
message: $"Your role has been changed to {notification.Role}",
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**6. Dispatching Flow**
|
||||||
|
```
|
||||||
|
User calls: POST /api/tenants/{tenantId}/users/{userId}/role
|
||||||
|
|
||||||
|
→ AssignUserRoleCommandHandler
|
||||||
|
→ user.AssignRole(role, currentUserId)
|
||||||
|
→ user.AddDomainEvent(new UserRoleAssignedEvent(...))
|
||||||
|
→ _userRepository.UpdateAsync(user)
|
||||||
|
→ _context.SaveChangesAsync()
|
||||||
|
→ DispatchDomainEventsAsync()
|
||||||
|
→ _mediator.Publish(UserRoleAssignedEvent)
|
||||||
|
→ UserRoleAssignedAuditHandler.Handle()
|
||||||
|
→ UserRoleAssignedCacheHandler.Handle()
|
||||||
|
→ UserRoleAssignedNotificationHandler.Handle()
|
||||||
|
→ base.SaveChangesAsync() // Commit transaction
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Next Steps
|
||||||
|
|
||||||
|
### Immediate Actions (Day 6)
|
||||||
|
|
||||||
|
1. **Implement Domain Event Dispatching**
|
||||||
|
- Override `SaveChangesAsync` in `IdentityDbContext`
|
||||||
|
- Inject `IMediator` into DbContext
|
||||||
|
- Test event dispatching with logging
|
||||||
|
|
||||||
|
2. **Create Missing Events**
|
||||||
|
- `UserRoleAssignedEvent`
|
||||||
|
- `UserRemovedFromTenantEvent`
|
||||||
|
- `UserLoggedInEvent`
|
||||||
|
|
||||||
|
3. **Implement Critical Handlers**
|
||||||
|
- `UserRoleAssignedEventHandler` (audit logging)
|
||||||
|
- `TenantCreatedEventHandler` (audit logging)
|
||||||
|
- `UserCreatedEventHandler` (audit logging)
|
||||||
|
|
||||||
|
### Follow-up Actions (Day 7-8)
|
||||||
|
|
||||||
|
4. **Expand Event Coverage**
|
||||||
|
- Add handlers for all existing 11 domain events
|
||||||
|
- Implement audit logging for all major operations
|
||||||
|
- Add cache invalidation handlers where needed
|
||||||
|
|
||||||
|
5. **Testing & Validation**
|
||||||
|
- Integration tests for event handling
|
||||||
|
- Performance testing (event dispatching overhead)
|
||||||
|
- Audit log verification
|
||||||
|
|
||||||
|
6. **Documentation**
|
||||||
|
- Update architecture documentation
|
||||||
|
- Document event-raising conventions
|
||||||
|
- Create event handler development guide
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Conclusion
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
**Current State:**
|
||||||
|
- Domain event infrastructure: 80% complete
|
||||||
|
- Domain events defined: 11 events (sufficient for Day 1-6)
|
||||||
|
- Critical gap: Event dispatching not implemented
|
||||||
|
|
||||||
|
**Recommended Action:**
|
||||||
|
- Implement domain event dispatching in Day 6 (1 hour)
|
||||||
|
- Add critical event handlers alongside Day 6 features (2 hours)
|
||||||
|
- Complete event coverage in Day 7-8 (2-4 hours)
|
||||||
|
|
||||||
|
**Total Effort:** 5-7 hours spread across Days 6-8
|
||||||
|
|
||||||
|
**Value:**
|
||||||
|
- Complete audit trail for compliance
|
||||||
|
- Foundation for cross-module communication
|
||||||
|
- Side effects (notifications, cache invalidation)
|
||||||
|
- Event sourcing ready (future)
|
||||||
|
|
||||||
|
### Decision
|
||||||
|
|
||||||
|
**Proceed with Option C (Incremental Implementation)**
|
||||||
|
- Phase 1 (Day 6): Infrastructure + critical handlers
|
||||||
|
- Phase 2 (Day 7-8): Complete event coverage
|
||||||
|
- Phase 3 (Day 9+): Advanced features (background processing, event sourcing)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Document Status:** ✅ Analysis Complete
|
||||||
|
**Recommended Decision:** Implement domain events incrementally starting Day 6
|
||||||
|
**Next Review:** After Phase 1 implementation
|
||||||
|
**Owner:** Backend Team
|
||||||
|
**Last Updated:** 2025-11-03
|
||||||
@@ -11,29 +11,30 @@ namespace ColaFlow.API.Controllers;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
public class AuthController : ControllerBase
|
public class AuthController(
|
||||||
{
|
|
||||||
private readonly IMediator _mediator;
|
|
||||||
private readonly IRefreshTokenService _refreshTokenService;
|
|
||||||
private readonly ILogger<AuthController> _logger;
|
|
||||||
|
|
||||||
public AuthController(
|
|
||||||
IMediator mediator,
|
IMediator mediator,
|
||||||
IRefreshTokenService refreshTokenService,
|
IRefreshTokenService refreshTokenService,
|
||||||
ILogger<AuthController> logger)
|
ILogger<AuthController> logger)
|
||||||
{
|
: ControllerBase
|
||||||
_mediator = mediator;
|
{
|
||||||
_refreshTokenService = refreshTokenService;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Login with email and password
|
/// Login with email and password
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost("login")]
|
[HttpPost("login")]
|
||||||
public async Task<IActionResult> Login([FromBody] LoginCommand command)
|
public async Task<IActionResult> Login([FromBody] LoginRequest request)
|
||||||
{
|
{
|
||||||
var result = await _mediator.Send(command);
|
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||||
|
var userAgent = HttpContext.Request.Headers["User-Agent"].ToString();
|
||||||
|
|
||||||
|
var command = new LoginCommand(
|
||||||
|
request.TenantSlug,
|
||||||
|
request.Email,
|
||||||
|
request.Password,
|
||||||
|
ipAddress,
|
||||||
|
userAgent
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = await mediator.Send(command);
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +79,7 @@ public class AuthController : ControllerBase
|
|||||||
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
|
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||||
var userAgent = HttpContext.Request.Headers["User-Agent"].ToString();
|
var userAgent = HttpContext.Request.Headers["User-Agent"].ToString();
|
||||||
|
|
||||||
var (accessToken, newRefreshToken) = await _refreshTokenService.RefreshTokenAsync(
|
var (accessToken, newRefreshToken) = await refreshTokenService.RefreshTokenAsync(
|
||||||
request.RefreshToken,
|
request.RefreshToken,
|
||||||
ipAddress,
|
ipAddress,
|
||||||
userAgent,
|
userAgent,
|
||||||
@@ -94,7 +95,7 @@ public class AuthController : ControllerBase
|
|||||||
}
|
}
|
||||||
catch (UnauthorizedAccessException ex)
|
catch (UnauthorizedAccessException ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Refresh token failed");
|
logger.LogWarning(ex, "Refresh token failed");
|
||||||
return Unauthorized(new { message = "Invalid or expired refresh token" });
|
return Unauthorized(new { message = "Invalid or expired refresh token" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,7 +111,7 @@ public class AuthController : ControllerBase
|
|||||||
{
|
{
|
||||||
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
|
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||||
|
|
||||||
await _refreshTokenService.RevokeTokenAsync(
|
await refreshTokenService.RevokeTokenAsync(
|
||||||
request.RefreshToken,
|
request.RefreshToken,
|
||||||
ipAddress,
|
ipAddress,
|
||||||
HttpContext.RequestAborted);
|
HttpContext.RequestAborted);
|
||||||
@@ -119,7 +120,7 @@ public class AuthController : ControllerBase
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Logout failed");
|
logger.LogError(ex, "Logout failed");
|
||||||
return BadRequest(new { message = "Logout failed" });
|
return BadRequest(new { message = "Logout failed" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,7 +136,7 @@ public class AuthController : ControllerBase
|
|||||||
{
|
{
|
||||||
var userId = Guid.Parse(User.FindFirstValue("user_id")!);
|
var userId = Guid.Parse(User.FindFirstValue("user_id")!);
|
||||||
|
|
||||||
await _refreshTokenService.RevokeAllUserTokensAsync(
|
await refreshTokenService.RevokeAllUserTokensAsync(
|
||||||
userId,
|
userId,
|
||||||
HttpContext.RequestAborted);
|
HttpContext.RequestAborted);
|
||||||
|
|
||||||
@@ -143,8 +144,14 @@ public class AuthController : ControllerBase
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Logout from all devices failed");
|
logger.LogError(ex, "Logout from all devices failed");
|
||||||
return BadRequest(new { message = "Logout failed" });
|
return BadRequest(new { message = "Logout failed" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public record LoginRequest(
|
||||||
|
string TenantSlug,
|
||||||
|
string Email,
|
||||||
|
string Password
|
||||||
|
);
|
||||||
|
|||||||
@@ -13,14 +13,9 @@ namespace ColaFlow.API.Controllers;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1")]
|
[Route("api/v1")]
|
||||||
public class EpicsController : ControllerBase
|
public class EpicsController(IMediator mediator) : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IMediator _mediator;
|
private readonly IMediator _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
||||||
|
|
||||||
public EpicsController(IMediator mediator)
|
|
||||||
{
|
|
||||||
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get all epics for a project
|
/// Get all epics for a project
|
||||||
|
|||||||
@@ -12,14 +12,9 @@ namespace ColaFlow.API.Controllers;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/[controller]")]
|
[Route("api/v1/[controller]")]
|
||||||
public class ProjectsController : ControllerBase
|
public class ProjectsController(IMediator mediator) : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IMediator _mediator;
|
private readonly IMediator _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
||||||
|
|
||||||
public ProjectsController(IMediator mediator)
|
|
||||||
{
|
|
||||||
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get all projects
|
/// Get all projects
|
||||||
|
|||||||
@@ -16,14 +16,9 @@ namespace ColaFlow.API.Controllers;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1")]
|
[Route("api/v1")]
|
||||||
public class StoriesController : ControllerBase
|
public class StoriesController(IMediator mediator) : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IMediator _mediator;
|
private readonly IMediator _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
||||||
|
|
||||||
public StoriesController(IMediator mediator)
|
|
||||||
{
|
|
||||||
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get story by ID
|
/// Get story by ID
|
||||||
|
|||||||
@@ -17,14 +17,9 @@ namespace ColaFlow.API.Controllers;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1")]
|
[Route("api/v1")]
|
||||||
public class TasksController : ControllerBase
|
public class TasksController(IMediator mediator) : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IMediator _mediator;
|
private readonly IMediator _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
||||||
|
|
||||||
public TasksController(IMediator mediator)
|
|
||||||
{
|
|
||||||
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get task by ID
|
/// Get task by ID
|
||||||
|
|||||||
@@ -11,15 +11,8 @@ namespace ColaFlow.API.Controllers;
|
|||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/tenants/{tenantId}/users")]
|
[Route("api/tenants/{tenantId}/users")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public class TenantUsersController : ControllerBase
|
public class TenantUsersController(IMediator mediator) : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IMediator _mediator;
|
|
||||||
|
|
||||||
public TenantUsersController(IMediator mediator)
|
|
||||||
{
|
|
||||||
_mediator = mediator;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// List all users in a tenant with their roles
|
/// List all users in a tenant with their roles
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -41,7 +34,7 @@ public class TenantUsersController : ControllerBase
|
|||||||
return StatusCode(403, new { error = "Access denied: You can only manage users in your own tenant" });
|
return StatusCode(403, new { error = "Access denied: You can only manage users in your own tenant" });
|
||||||
|
|
||||||
var query = new ListTenantUsersQuery(tenantId, pageNumber, pageSize, search);
|
var query = new ListTenantUsersQuery(tenantId, pageNumber, pageSize, search);
|
||||||
var result = await _mediator.Send(query);
|
var result = await mediator.Send(query);
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,8 +57,15 @@ public class TenantUsersController : ControllerBase
|
|||||||
if (userTenantId != tenantId)
|
if (userTenantId != tenantId)
|
||||||
return StatusCode(403, new { error = "Access denied: You can only manage users in your own tenant" });
|
return StatusCode(403, new { error = "Access denied: You can only manage users in your own tenant" });
|
||||||
|
|
||||||
var command = new AssignUserRoleCommand(tenantId, userId, request.Role);
|
// Extract current user ID from claims
|
||||||
await _mediator.Send(command);
|
var currentUserIdClaim = User.FindFirst("user_id")?.Value;
|
||||||
|
if (currentUserIdClaim == null)
|
||||||
|
return Unauthorized(new { error = "User ID not found in token" });
|
||||||
|
|
||||||
|
var currentUserId = Guid.Parse(currentUserIdClaim);
|
||||||
|
|
||||||
|
var command = new AssignUserRoleCommand(tenantId, userId, request.Role, currentUserId);
|
||||||
|
await mediator.Send(command);
|
||||||
return Ok(new { Message = "Role assigned successfully" });
|
return Ok(new { Message = "Role assigned successfully" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,8 +87,15 @@ public class TenantUsersController : ControllerBase
|
|||||||
if (userTenantId != tenantId)
|
if (userTenantId != tenantId)
|
||||||
return StatusCode(403, new { error = "Access denied: You can only manage users in your own tenant" });
|
return StatusCode(403, new { error = "Access denied: You can only manage users in your own tenant" });
|
||||||
|
|
||||||
var command = new RemoveUserFromTenantCommand(tenantId, userId);
|
// Extract current user ID from claims
|
||||||
await _mediator.Send(command);
|
var currentUserIdClaim = User.FindFirst("user_id")?.Value;
|
||||||
|
if (currentUserIdClaim == null)
|
||||||
|
return Unauthorized(new { error = "User ID not found in token" });
|
||||||
|
|
||||||
|
var currentUserId = Guid.Parse(currentUserIdClaim);
|
||||||
|
|
||||||
|
var command = new RemoveUserFromTenantCommand(tenantId, userId, currentUserId, null);
|
||||||
|
await mediator.Send(command);
|
||||||
return Ok(new { Message = "User removed from tenant successfully" });
|
return Ok(new { Message = "User removed from tenant successfully" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,22 +7,15 @@ namespace ColaFlow.API.Controllers;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
public class TenantsController : ControllerBase
|
public class TenantsController(IMediator mediator) : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IMediator _mediator;
|
|
||||||
|
|
||||||
public TenantsController(IMediator mediator)
|
|
||||||
{
|
|
||||||
_mediator = mediator;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Register a new tenant (company signup)
|
/// Register a new tenant (company signup)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost("register")]
|
[HttpPost("register")]
|
||||||
public async Task<IActionResult> Register([FromBody] RegisterTenantCommand command)
|
public async Task<IActionResult> Register([FromBody] RegisterTenantCommand command)
|
||||||
{
|
{
|
||||||
var result = await _mediator.Send(command);
|
var result = await mediator.Send(command);
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,7 +26,7 @@ public class TenantsController : ControllerBase
|
|||||||
public async Task<IActionResult> GetBySlug(string slug)
|
public async Task<IActionResult> GetBySlug(string slug)
|
||||||
{
|
{
|
||||||
var query = new GetTenantBySlugQuery(slug);
|
var query = new GetTenantBySlugQuery(slug);
|
||||||
var result = await _mediator.Send(query);
|
var result = await mediator.Send(query);
|
||||||
|
|
||||||
if (result == null)
|
if (result == null)
|
||||||
return NotFound(new { message = "Tenant not found" });
|
return NotFound(new { message = "Tenant not found" });
|
||||||
@@ -48,7 +41,7 @@ public class TenantsController : ControllerBase
|
|||||||
public async Task<IActionResult> CheckSlug(string slug)
|
public async Task<IActionResult> CheckSlug(string slug)
|
||||||
{
|
{
|
||||||
var query = new GetTenantBySlugQuery(slug);
|
var query = new GetTenantBySlugQuery(slug);
|
||||||
var result = await _mediator.Send(query);
|
var result = await mediator.Send(query);
|
||||||
|
|
||||||
return Ok(new { available = result == null });
|
return Ok(new { available = result == null });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,14 +11,9 @@ namespace ColaFlow.API.Handlers;
|
|||||||
/// Global exception handler using IExceptionHandler (.NET 8+)
|
/// Global exception handler using IExceptionHandler (.NET 8+)
|
||||||
/// Handles all unhandled exceptions and converts them to ProblemDetails responses
|
/// Handles all unhandled exceptions and converts them to ProblemDetails responses
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class GlobalExceptionHandler : IExceptionHandler
|
public sealed class GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger) : IExceptionHandler
|
||||||
{
|
{
|
||||||
private readonly ILogger<GlobalExceptionHandler> _logger;
|
private readonly ILogger<GlobalExceptionHandler> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
|
||||||
public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
|
|
||||||
{
|
|
||||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask<bool> TryHandleAsync(
|
public async ValueTask<bool> TryHandleAsync(
|
||||||
HttpContext httpContext,
|
HttpContext httpContext,
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ namespace ColaFlow.Modules.Identity.Application.Commands.AssignUserRole;
|
|||||||
public record AssignUserRoleCommand(
|
public record AssignUserRoleCommand(
|
||||||
Guid TenantId,
|
Guid TenantId,
|
||||||
Guid UserId,
|
Guid UserId,
|
||||||
string Role) : IRequest<Unit>;
|
string Role,
|
||||||
|
Guid AssignedBy) : IRequest<Unit>;
|
||||||
|
|||||||
@@ -5,31 +5,21 @@ using MediatR;
|
|||||||
|
|
||||||
namespace ColaFlow.Modules.Identity.Application.Commands.AssignUserRole;
|
namespace ColaFlow.Modules.Identity.Application.Commands.AssignUserRole;
|
||||||
|
|
||||||
public class AssignUserRoleCommandHandler : IRequestHandler<AssignUserRoleCommand, Unit>
|
public class AssignUserRoleCommandHandler(
|
||||||
{
|
|
||||||
private readonly IUserTenantRoleRepository _userTenantRoleRepository;
|
|
||||||
private readonly IUserRepository _userRepository;
|
|
||||||
private readonly ITenantRepository _tenantRepository;
|
|
||||||
|
|
||||||
public AssignUserRoleCommandHandler(
|
|
||||||
IUserTenantRoleRepository userTenantRoleRepository,
|
IUserTenantRoleRepository userTenantRoleRepository,
|
||||||
IUserRepository userRepository,
|
IUserRepository userRepository,
|
||||||
ITenantRepository tenantRepository)
|
ITenantRepository tenantRepository)
|
||||||
{
|
: IRequestHandler<AssignUserRoleCommand, Unit>
|
||||||
_userTenantRoleRepository = userTenantRoleRepository;
|
{
|
||||||
_userRepository = userRepository;
|
|
||||||
_tenantRepository = tenantRepository;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Unit> Handle(AssignUserRoleCommand request, CancellationToken cancellationToken)
|
public async Task<Unit> Handle(AssignUserRoleCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// Validate user exists
|
// Validate user exists
|
||||||
var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
|
var user = await userRepository.GetByIdAsync(request.UserId, cancellationToken);
|
||||||
if (user == null)
|
if (user == null)
|
||||||
throw new InvalidOperationException("User not found");
|
throw new InvalidOperationException("User not found");
|
||||||
|
|
||||||
// Validate tenant exists
|
// Validate tenant exists
|
||||||
var tenant = await _tenantRepository.GetByIdAsync(TenantId.Create(request.TenantId), cancellationToken);
|
var tenant = await tenantRepository.GetByIdAsync(TenantId.Create(request.TenantId), cancellationToken);
|
||||||
if (tenant == null)
|
if (tenant == null)
|
||||||
throw new InvalidOperationException("Tenant not found");
|
throw new InvalidOperationException("Tenant not found");
|
||||||
|
|
||||||
@@ -42,7 +32,7 @@ public class AssignUserRoleCommandHandler : IRequestHandler<AssignUserRoleComman
|
|||||||
throw new InvalidOperationException("AIAgent role cannot be assigned manually");
|
throw new InvalidOperationException("AIAgent role cannot be assigned manually");
|
||||||
|
|
||||||
// Check if user already has a role in this tenant
|
// Check if user already has a role in this tenant
|
||||||
var existingRole = await _userTenantRoleRepository.GetByUserAndTenantAsync(
|
var existingRole = await userTenantRoleRepository.GetByUserAndTenantAsync(
|
||||||
request.UserId,
|
request.UserId,
|
||||||
request.TenantId,
|
request.TenantId,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
@@ -51,7 +41,7 @@ public class AssignUserRoleCommandHandler : IRequestHandler<AssignUserRoleComman
|
|||||||
{
|
{
|
||||||
// Update existing role
|
// Update existing role
|
||||||
existingRole.UpdateRole(role, Guid.Empty); // OperatorUserId can be set from HttpContext in controller
|
existingRole.UpdateRole(role, Guid.Empty); // OperatorUserId can be set from HttpContext in controller
|
||||||
await _userTenantRoleRepository.UpdateAsync(existingRole, cancellationToken);
|
await userTenantRoleRepository.UpdateAsync(existingRole, cancellationToken);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -62,7 +52,7 @@ public class AssignUserRoleCommandHandler : IRequestHandler<AssignUserRoleComman
|
|||||||
role,
|
role,
|
||||||
null); // AssignedByUserId can be set from HttpContext in controller
|
null); // AssignedByUserId can be set from HttpContext in controller
|
||||||
|
|
||||||
await _userTenantRoleRepository.AddAsync(userTenantRole, cancellationToken);
|
await userTenantRoleRepository.AddAsync(userTenantRole, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Unit.Value;
|
return Unit.Value;
|
||||||
|
|||||||
@@ -6,5 +6,7 @@ namespace ColaFlow.Modules.Identity.Application.Commands.Login;
|
|||||||
public record LoginCommand(
|
public record LoginCommand(
|
||||||
string TenantSlug,
|
string TenantSlug,
|
||||||
string Email,
|
string Email,
|
||||||
string Password
|
string Password,
|
||||||
|
string? IpAddress,
|
||||||
|
string? UserAgent
|
||||||
) : IRequest<LoginResponseDto>;
|
) : IRequest<LoginResponseDto>;
|
||||||
|
|||||||
@@ -7,36 +7,20 @@ using MediatR;
|
|||||||
|
|
||||||
namespace ColaFlow.Modules.Identity.Application.Commands.Login;
|
namespace ColaFlow.Modules.Identity.Application.Commands.Login;
|
||||||
|
|
||||||
public class LoginCommandHandler : IRequestHandler<LoginCommand, LoginResponseDto>
|
public class LoginCommandHandler(
|
||||||
{
|
|
||||||
private readonly ITenantRepository _tenantRepository;
|
|
||||||
private readonly IUserRepository _userRepository;
|
|
||||||
private readonly IJwtService _jwtService;
|
|
||||||
private readonly IPasswordHasher _passwordHasher;
|
|
||||||
private readonly IRefreshTokenService _refreshTokenService;
|
|
||||||
private readonly IUserTenantRoleRepository _userTenantRoleRepository;
|
|
||||||
|
|
||||||
public LoginCommandHandler(
|
|
||||||
ITenantRepository tenantRepository,
|
ITenantRepository tenantRepository,
|
||||||
IUserRepository userRepository,
|
IUserRepository userRepository,
|
||||||
IJwtService jwtService,
|
IJwtService jwtService,
|
||||||
IPasswordHasher passwordHasher,
|
IPasswordHasher passwordHasher,
|
||||||
IRefreshTokenService refreshTokenService,
|
IRefreshTokenService refreshTokenService,
|
||||||
IUserTenantRoleRepository userTenantRoleRepository)
|
IUserTenantRoleRepository userTenantRoleRepository)
|
||||||
{
|
: IRequestHandler<LoginCommand, LoginResponseDto>
|
||||||
_tenantRepository = tenantRepository;
|
{
|
||||||
_userRepository = userRepository;
|
|
||||||
_jwtService = jwtService;
|
|
||||||
_passwordHasher = passwordHasher;
|
|
||||||
_refreshTokenService = refreshTokenService;
|
|
||||||
_userTenantRoleRepository = userTenantRoleRepository;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<LoginResponseDto> Handle(LoginCommand request, CancellationToken cancellationToken)
|
public async Task<LoginResponseDto> Handle(LoginCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// 1. Find tenant
|
// 1. Find tenant
|
||||||
var slug = TenantSlug.Create(request.TenantSlug);
|
var slug = TenantSlug.Create(request.TenantSlug);
|
||||||
var tenant = await _tenantRepository.GetBySlugAsync(slug, cancellationToken);
|
var tenant = await tenantRepository.GetBySlugAsync(slug, cancellationToken);
|
||||||
if (tenant == null)
|
if (tenant == null)
|
||||||
{
|
{
|
||||||
throw new UnauthorizedAccessException("Invalid credentials");
|
throw new UnauthorizedAccessException("Invalid credentials");
|
||||||
@@ -44,20 +28,20 @@ public class LoginCommandHandler : IRequestHandler<LoginCommand, LoginResponseDt
|
|||||||
|
|
||||||
// 2. Find user
|
// 2. Find user
|
||||||
var email = Email.Create(request.Email);
|
var email = Email.Create(request.Email);
|
||||||
var user = await _userRepository.GetByEmailAsync(TenantId.Create(tenant.Id), email, cancellationToken);
|
var user = await userRepository.GetByEmailAsync(TenantId.Create(tenant.Id), email, cancellationToken);
|
||||||
if (user == null)
|
if (user == null)
|
||||||
{
|
{
|
||||||
throw new UnauthorizedAccessException("Invalid credentials");
|
throw new UnauthorizedAccessException("Invalid credentials");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Verify password
|
// 3. Verify password
|
||||||
if (user.PasswordHash == null || !_passwordHasher.VerifyPassword(request.Password, user.PasswordHash))
|
if (user.PasswordHash == null || !passwordHasher.VerifyPassword(request.Password, user.PasswordHash))
|
||||||
{
|
{
|
||||||
throw new UnauthorizedAccessException("Invalid credentials");
|
throw new UnauthorizedAccessException("Invalid credentials");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Get user's tenant role
|
// 4. Get user's tenant role
|
||||||
var userTenantRole = await _userTenantRoleRepository.GetByUserAndTenantAsync(
|
var userTenantRole = await userTenantRoleRepository.GetByUserAndTenantAsync(
|
||||||
user.Id,
|
user.Id,
|
||||||
tenant.Id,
|
tenant.Id,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
@@ -68,10 +52,10 @@ public class LoginCommandHandler : IRequestHandler<LoginCommand, LoginResponseDt
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 5. Generate JWT token with role
|
// 5. Generate JWT token with role
|
||||||
var accessToken = _jwtService.GenerateToken(user, tenant, userTenantRole.Role);
|
var accessToken = jwtService.GenerateToken(user, tenant, userTenantRole.Role);
|
||||||
|
|
||||||
// 6. Generate refresh token
|
// 6. Generate refresh token
|
||||||
var refreshToken = await _refreshTokenService.GenerateRefreshTokenAsync(
|
var refreshToken = await refreshTokenService.GenerateRefreshTokenAsync(
|
||||||
user,
|
user,
|
||||||
ipAddress: null,
|
ipAddress: null,
|
||||||
userAgent: null,
|
userAgent: null,
|
||||||
@@ -79,7 +63,7 @@ public class LoginCommandHandler : IRequestHandler<LoginCommand, LoginResponseDt
|
|||||||
|
|
||||||
// 7. Update last login time
|
// 7. Update last login time
|
||||||
user.RecordLogin();
|
user.RecordLogin();
|
||||||
await _userRepository.UpdateAsync(user, cancellationToken);
|
await userRepository.UpdateAsync(user, cancellationToken);
|
||||||
|
|
||||||
// 8. Return result
|
// 8. Return result
|
||||||
return new LoginResponseDto
|
return new LoginResponseDto
|
||||||
|
|||||||
@@ -6,38 +6,22 @@ using MediatR;
|
|||||||
|
|
||||||
namespace ColaFlow.Modules.Identity.Application.Commands.RegisterTenant;
|
namespace ColaFlow.Modules.Identity.Application.Commands.RegisterTenant;
|
||||||
|
|
||||||
public class RegisterTenantCommandHandler : IRequestHandler<RegisterTenantCommand, RegisterTenantResult>
|
public class RegisterTenantCommandHandler(
|
||||||
{
|
|
||||||
private readonly ITenantRepository _tenantRepository;
|
|
||||||
private readonly IUserRepository _userRepository;
|
|
||||||
private readonly IJwtService _jwtService;
|
|
||||||
private readonly IPasswordHasher _passwordHasher;
|
|
||||||
private readonly IRefreshTokenService _refreshTokenService;
|
|
||||||
private readonly IUserTenantRoleRepository _userTenantRoleRepository;
|
|
||||||
|
|
||||||
public RegisterTenantCommandHandler(
|
|
||||||
ITenantRepository tenantRepository,
|
ITenantRepository tenantRepository,
|
||||||
IUserRepository userRepository,
|
IUserRepository userRepository,
|
||||||
IJwtService jwtService,
|
IJwtService jwtService,
|
||||||
IPasswordHasher passwordHasher,
|
IPasswordHasher passwordHasher,
|
||||||
IRefreshTokenService refreshTokenService,
|
IRefreshTokenService refreshTokenService,
|
||||||
IUserTenantRoleRepository userTenantRoleRepository)
|
IUserTenantRoleRepository userTenantRoleRepository)
|
||||||
{
|
: IRequestHandler<RegisterTenantCommand, RegisterTenantResult>
|
||||||
_tenantRepository = tenantRepository;
|
{
|
||||||
_userRepository = userRepository;
|
|
||||||
_jwtService = jwtService;
|
|
||||||
_passwordHasher = passwordHasher;
|
|
||||||
_refreshTokenService = refreshTokenService;
|
|
||||||
_userTenantRoleRepository = userTenantRoleRepository;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<RegisterTenantResult> Handle(
|
public async Task<RegisterTenantResult> Handle(
|
||||||
RegisterTenantCommand request,
|
RegisterTenantCommand request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// 1. Validate slug uniqueness
|
// 1. Validate slug uniqueness
|
||||||
var slug = TenantSlug.Create(request.TenantSlug);
|
var slug = TenantSlug.Create(request.TenantSlug);
|
||||||
var slugExists = await _tenantRepository.ExistsBySlugAsync(slug, cancellationToken);
|
var slugExists = await tenantRepository.ExistsBySlugAsync(slug, cancellationToken);
|
||||||
if (slugExists)
|
if (slugExists)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException($"Tenant slug '{request.TenantSlug}' is already taken");
|
throw new InvalidOperationException($"Tenant slug '{request.TenantSlug}' is already taken");
|
||||||
@@ -50,17 +34,17 @@ public class RegisterTenantCommandHandler : IRequestHandler<RegisterTenantComman
|
|||||||
slug,
|
slug,
|
||||||
plan);
|
plan);
|
||||||
|
|
||||||
await _tenantRepository.AddAsync(tenant, cancellationToken);
|
await tenantRepository.AddAsync(tenant, cancellationToken);
|
||||||
|
|
||||||
// 3. Create admin user with hashed password
|
// 3. Create admin user with hashed password
|
||||||
var hashedPassword = _passwordHasher.HashPassword(request.AdminPassword);
|
var hashedPassword = passwordHasher.HashPassword(request.AdminPassword);
|
||||||
var adminUser = User.CreateLocal(
|
var adminUser = User.CreateLocal(
|
||||||
TenantId.Create(tenant.Id),
|
TenantId.Create(tenant.Id),
|
||||||
Email.Create(request.AdminEmail),
|
Email.Create(request.AdminEmail),
|
||||||
hashedPassword,
|
hashedPassword,
|
||||||
FullName.Create(request.AdminFullName));
|
FullName.Create(request.AdminFullName));
|
||||||
|
|
||||||
await _userRepository.AddAsync(adminUser, cancellationToken);
|
await userRepository.AddAsync(adminUser, cancellationToken);
|
||||||
|
|
||||||
// 4. Assign TenantOwner role to admin user
|
// 4. Assign TenantOwner role to admin user
|
||||||
var tenantOwnerRole = UserTenantRole.Create(
|
var tenantOwnerRole = UserTenantRole.Create(
|
||||||
@@ -68,13 +52,13 @@ public class RegisterTenantCommandHandler : IRequestHandler<RegisterTenantComman
|
|||||||
TenantId.Create(tenant.Id),
|
TenantId.Create(tenant.Id),
|
||||||
TenantRole.TenantOwner);
|
TenantRole.TenantOwner);
|
||||||
|
|
||||||
await _userTenantRoleRepository.AddAsync(tenantOwnerRole, cancellationToken);
|
await userTenantRoleRepository.AddAsync(tenantOwnerRole, cancellationToken);
|
||||||
|
|
||||||
// 5. Generate JWT token with role
|
// 5. Generate JWT token with role
|
||||||
var accessToken = _jwtService.GenerateToken(adminUser, tenant, TenantRole.TenantOwner);
|
var accessToken = jwtService.GenerateToken(adminUser, tenant, TenantRole.TenantOwner);
|
||||||
|
|
||||||
// 6. Generate refresh token
|
// 6. Generate refresh token
|
||||||
var refreshToken = await _refreshTokenService.GenerateRefreshTokenAsync(
|
var refreshToken = await refreshTokenService.GenerateRefreshTokenAsync(
|
||||||
adminUser,
|
adminUser,
|
||||||
ipAddress: null,
|
ipAddress: null,
|
||||||
userAgent: null,
|
userAgent: null,
|
||||||
|
|||||||
@@ -4,4 +4,6 @@ namespace ColaFlow.Modules.Identity.Application.Commands.RemoveUserFromTenant;
|
|||||||
|
|
||||||
public record RemoveUserFromTenantCommand(
|
public record RemoveUserFromTenantCommand(
|
||||||
Guid TenantId,
|
Guid TenantId,
|
||||||
Guid UserId) : IRequest<Unit>;
|
Guid UserId,
|
||||||
|
Guid RemovedBy,
|
||||||
|
string? Reason) : IRequest<Unit>;
|
||||||
|
|||||||
@@ -1,26 +1,21 @@
|
|||||||
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
|
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
|
||||||
|
using ColaFlow.Modules.Identity.Domain.Aggregates.Users.Events;
|
||||||
|
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
|
||||||
using ColaFlow.Modules.Identity.Domain.Repositories;
|
using ColaFlow.Modules.Identity.Domain.Repositories;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
|
||||||
namespace ColaFlow.Modules.Identity.Application.Commands.RemoveUserFromTenant;
|
namespace ColaFlow.Modules.Identity.Application.Commands.RemoveUserFromTenant;
|
||||||
|
|
||||||
public class RemoveUserFromTenantCommandHandler : IRequestHandler<RemoveUserFromTenantCommand, Unit>
|
public class RemoveUserFromTenantCommandHandler(
|
||||||
{
|
|
||||||
private readonly IUserTenantRoleRepository _userTenantRoleRepository;
|
|
||||||
private readonly IRefreshTokenRepository _refreshTokenRepository;
|
|
||||||
|
|
||||||
public RemoveUserFromTenantCommandHandler(
|
|
||||||
IUserTenantRoleRepository userTenantRoleRepository,
|
IUserTenantRoleRepository userTenantRoleRepository,
|
||||||
IRefreshTokenRepository refreshTokenRepository)
|
IRefreshTokenRepository refreshTokenRepository,
|
||||||
{
|
IUserRepository userRepository)
|
||||||
_userTenantRoleRepository = userTenantRoleRepository;
|
: IRequestHandler<RemoveUserFromTenantCommand, Unit>
|
||||||
_refreshTokenRepository = refreshTokenRepository;
|
{
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Unit> Handle(RemoveUserFromTenantCommand request, CancellationToken cancellationToken)
|
public async Task<Unit> Handle(RemoveUserFromTenantCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// Get user's role in tenant
|
// Get user's role in tenant
|
||||||
var userTenantRole = await _userTenantRoleRepository.GetByUserAndTenantAsync(
|
var userTenantRole = await userTenantRoleRepository.GetByUserAndTenantAsync(
|
||||||
request.UserId,
|
request.UserId,
|
||||||
request.TenantId,
|
request.TenantId,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
@@ -29,29 +24,42 @@ public class RemoveUserFromTenantCommandHandler : IRequestHandler<RemoveUserFrom
|
|||||||
throw new InvalidOperationException("User is not a member of this tenant");
|
throw new InvalidOperationException("User is not a member of this tenant");
|
||||||
|
|
||||||
// Check if this is the last TenantOwner
|
// Check if this is the last TenantOwner
|
||||||
if (await _userTenantRoleRepository.IsLastTenantOwnerAsync(request.TenantId, request.UserId, cancellationToken))
|
if (await userTenantRoleRepository.IsLastTenantOwnerAsync(request.TenantId, request.UserId, cancellationToken))
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("Cannot remove the last TenantOwner from the tenant");
|
throw new InvalidOperationException("Cannot remove the last TenantOwner from the tenant");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Revoke all user's refresh tokens for this tenant
|
// Revoke all user's refresh tokens for this tenant
|
||||||
var userTokens = await _refreshTokenRepository.GetByUserAndTenantAsync(
|
var userTokens = await refreshTokenRepository.GetByUserAndTenantAsync(
|
||||||
request.UserId,
|
request.UserId,
|
||||||
request.TenantId,
|
request.TenantId,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
foreach (var token in userTokens.Where(t => !t.RevokedAt.HasValue))
|
foreach (var token in userTokens.Where(t => !t.RevokedAt.HasValue))
|
||||||
{
|
{
|
||||||
token.Revoke("User removed from tenant");
|
token.Revoke(request.Reason ?? "User removed from tenant");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userTokens.Any())
|
if (userTokens.Any())
|
||||||
{
|
{
|
||||||
await _refreshTokenRepository.UpdateRangeAsync(userTokens, cancellationToken);
|
await refreshTokenRepository.UpdateRangeAsync(userTokens, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raise domain event before deletion
|
||||||
|
var user = await userRepository.GetByIdAsync(request.UserId, cancellationToken);
|
||||||
|
if (user != null)
|
||||||
|
{
|
||||||
|
user.RaiseRemovedFromTenantEvent(
|
||||||
|
TenantId.Create(request.TenantId),
|
||||||
|
request.RemovedBy,
|
||||||
|
request.Reason ?? "User removed from tenant"
|
||||||
|
);
|
||||||
|
|
||||||
|
await userRepository.UpdateAsync(user, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove user's role
|
// Remove user's role
|
||||||
await _userTenantRoleRepository.DeleteAsync(userTenantRole, cancellationToken);
|
await userTenantRoleRepository.DeleteAsync(userTenantRole, cancellationToken);
|
||||||
|
|
||||||
return Unit.Value;
|
return Unit.Value;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants.Events;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace ColaFlow.Modules.Identity.Application.EventHandlers;
|
||||||
|
|
||||||
|
public sealed class TenantCreatedEventHandler(ILogger<TenantCreatedEventHandler> logger)
|
||||||
|
: INotificationHandler<TenantCreatedEvent>
|
||||||
|
{
|
||||||
|
public Task Handle(TenantCreatedEvent notification, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
logger.LogInformation(
|
||||||
|
"Tenant {TenantId} created with slug '{Slug}'",
|
||||||
|
notification.TenantId,
|
||||||
|
notification.Slug);
|
||||||
|
|
||||||
|
// Future: Send welcome email
|
||||||
|
// Future: Initialize default settings
|
||||||
|
// Future: Create audit log entry
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using ColaFlow.Modules.Identity.Domain.Aggregates.Users.Events;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace ColaFlow.Modules.Identity.Application.EventHandlers;
|
||||||
|
|
||||||
|
public sealed class UserLoggedInEventHandler(ILogger<UserLoggedInEventHandler> logger)
|
||||||
|
: INotificationHandler<UserLoggedInEvent>
|
||||||
|
{
|
||||||
|
public Task Handle(UserLoggedInEvent notification, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
logger.LogInformation(
|
||||||
|
"User {UserId} logged in to tenant {TenantId} from IP {IpAddress}",
|
||||||
|
notification.UserId,
|
||||||
|
notification.TenantId.Value,
|
||||||
|
notification.IpAddress);
|
||||||
|
|
||||||
|
// Future: Track login history
|
||||||
|
// Future: Detect suspicious login patterns
|
||||||
|
// Future: Update last login timestamp
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using ColaFlow.Modules.Identity.Domain.Aggregates.Users.Events;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace ColaFlow.Modules.Identity.Application.EventHandlers;
|
||||||
|
|
||||||
|
public sealed class UserRemovedFromTenantEventHandler(ILogger<UserRemovedFromTenantEventHandler> logger)
|
||||||
|
: INotificationHandler<UserRemovedFromTenantEvent>
|
||||||
|
{
|
||||||
|
public Task Handle(UserRemovedFromTenantEvent notification, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
logger.LogInformation(
|
||||||
|
"User {UserId} removed from tenant {TenantId}. Removed by: {RemovedBy}. Reason: {Reason}",
|
||||||
|
notification.UserId,
|
||||||
|
notification.TenantId.Value,
|
||||||
|
notification.RemovedBy,
|
||||||
|
notification.Reason);
|
||||||
|
|
||||||
|
// Future: Send notification to user
|
||||||
|
// Future: Audit log
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using ColaFlow.Modules.Identity.Domain.Aggregates.Users.Events;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace ColaFlow.Modules.Identity.Application.EventHandlers;
|
||||||
|
|
||||||
|
public sealed class UserRoleAssignedEventHandler(ILogger<UserRoleAssignedEventHandler> logger)
|
||||||
|
: INotificationHandler<UserRoleAssignedEvent>
|
||||||
|
{
|
||||||
|
public Task Handle(UserRoleAssignedEvent notification, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
logger.LogInformation(
|
||||||
|
"User {UserId} assigned role {Role} in tenant {TenantId}. Previous role: {PreviousRole}. Assigned by: {AssignedBy}",
|
||||||
|
notification.UserId,
|
||||||
|
notification.Role,
|
||||||
|
notification.TenantId.Value,
|
||||||
|
notification.PreviousRole,
|
||||||
|
notification.AssignedBy);
|
||||||
|
|
||||||
|
// Future: Add audit log to database
|
||||||
|
// Future: Invalidate user permissions cache
|
||||||
|
// Future: Send notification to user
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,19 +5,13 @@ using MediatR;
|
|||||||
|
|
||||||
namespace ColaFlow.Modules.Identity.Application.Queries.GetTenantBySlug;
|
namespace ColaFlow.Modules.Identity.Application.Queries.GetTenantBySlug;
|
||||||
|
|
||||||
public class GetTenantBySlugQueryHandler : IRequestHandler<GetTenantBySlugQuery, TenantDto?>
|
public class GetTenantBySlugQueryHandler(ITenantRepository tenantRepository)
|
||||||
|
: IRequestHandler<GetTenantBySlugQuery, TenantDto?>
|
||||||
{
|
{
|
||||||
private readonly ITenantRepository _tenantRepository;
|
|
||||||
|
|
||||||
public GetTenantBySlugQueryHandler(ITenantRepository tenantRepository)
|
|
||||||
{
|
|
||||||
_tenantRepository = tenantRepository;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<TenantDto?> Handle(GetTenantBySlugQuery request, CancellationToken cancellationToken)
|
public async Task<TenantDto?> Handle(GetTenantBySlugQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var slug = TenantSlug.Create(request.Slug);
|
var slug = TenantSlug.Create(request.Slug);
|
||||||
var tenant = await _tenantRepository.GetBySlugAsync(slug, cancellationToken);
|
var tenant = await tenantRepository.GetBySlugAsync(slug, cancellationToken);
|
||||||
|
|
||||||
if (tenant == null)
|
if (tenant == null)
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -4,24 +4,16 @@ using MediatR;
|
|||||||
|
|
||||||
namespace ColaFlow.Modules.Identity.Application.Queries.ListTenantUsers;
|
namespace ColaFlow.Modules.Identity.Application.Queries.ListTenantUsers;
|
||||||
|
|
||||||
public class ListTenantUsersQueryHandler : IRequestHandler<ListTenantUsersQuery, PagedResultDto<UserWithRoleDto>>
|
public class ListTenantUsersQueryHandler(
|
||||||
{
|
|
||||||
private readonly IUserTenantRoleRepository _userTenantRoleRepository;
|
|
||||||
private readonly IUserRepository _userRepository;
|
|
||||||
|
|
||||||
public ListTenantUsersQueryHandler(
|
|
||||||
IUserTenantRoleRepository userTenantRoleRepository,
|
IUserTenantRoleRepository userTenantRoleRepository,
|
||||||
IUserRepository userRepository)
|
IUserRepository userRepository)
|
||||||
{
|
: IRequestHandler<ListTenantUsersQuery, PagedResultDto<UserWithRoleDto>>
|
||||||
_userTenantRoleRepository = userTenantRoleRepository;
|
{
|
||||||
_userRepository = userRepository;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<PagedResultDto<UserWithRoleDto>> Handle(
|
public async Task<PagedResultDto<UserWithRoleDto>> Handle(
|
||||||
ListTenantUsersQuery request,
|
ListTenantUsersQuery request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var (roles, totalCount) = await _userTenantRoleRepository.GetTenantUsersWithRolesAsync(
|
var (roles, totalCount) = await userTenantRoleRepository.GetTenantUsersWithRolesAsync(
|
||||||
request.TenantId,
|
request.TenantId,
|
||||||
request.PageNumber,
|
request.PageNumber,
|
||||||
request.PageSize,
|
request.PageSize,
|
||||||
@@ -32,7 +24,7 @@ public class ListTenantUsersQueryHandler : IRequestHandler<ListTenantUsersQuery,
|
|||||||
|
|
||||||
foreach (var role in roles)
|
foreach (var role in roles)
|
||||||
{
|
{
|
||||||
var user = await _userRepository.GetByIdAsync(role.UserId, cancellationToken);
|
var user = await userRepository.GetByIdAsync(role.UserId, cancellationToken);
|
||||||
|
|
||||||
if (user != null)
|
if (user != null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using ColaFlow.Shared.Kernel.Events;
|
||||||
|
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
|
||||||
|
|
||||||
|
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Users.Events;
|
||||||
|
|
||||||
|
public sealed record UserLoggedInEvent(
|
||||||
|
Guid UserId,
|
||||||
|
TenantId TenantId,
|
||||||
|
string IpAddress,
|
||||||
|
string UserAgent
|
||||||
|
) : DomainEvent;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using ColaFlow.Shared.Kernel.Events;
|
||||||
|
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
|
||||||
|
|
||||||
|
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Users.Events;
|
||||||
|
|
||||||
|
public sealed record UserRemovedFromTenantEvent(
|
||||||
|
Guid UserId,
|
||||||
|
TenantId TenantId,
|
||||||
|
Guid RemovedBy,
|
||||||
|
string Reason
|
||||||
|
) : DomainEvent;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using ColaFlow.Shared.Kernel.Events;
|
||||||
|
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
|
||||||
|
|
||||||
|
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Users.Events;
|
||||||
|
|
||||||
|
public sealed record UserRoleAssignedEvent(
|
||||||
|
Guid UserId,
|
||||||
|
TenantId TenantId,
|
||||||
|
TenantRole Role,
|
||||||
|
TenantRole? PreviousRole,
|
||||||
|
Guid AssignedBy
|
||||||
|
) : DomainEvent;
|
||||||
@@ -139,6 +139,24 @@ public sealed class User : AggregateRoot
|
|||||||
UpdatedAt = DateTime.UtcNow;
|
UpdatedAt = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void RecordLoginWithEvent(TenantId tenantId, string ipAddress, string userAgent)
|
||||||
|
{
|
||||||
|
LastLoginAt = DateTime.UtcNow;
|
||||||
|
UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
AddDomainEvent(new UserLoggedInEvent(Id, tenantId, ipAddress, userAgent));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RaiseRoleAssignedEvent(TenantId tenantId, TenantRole role, TenantRole? previousRole, Guid assignedBy)
|
||||||
|
{
|
||||||
|
AddDomainEvent(new UserRoleAssignedEvent(Id, tenantId, role, previousRole, assignedBy));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RaiseRemovedFromTenantEvent(TenantId tenantId, Guid removedBy, string reason)
|
||||||
|
{
|
||||||
|
AddDomainEvent(new UserRemovedFromTenantEvent(Id, tenantId, removedBy, reason));
|
||||||
|
}
|
||||||
|
|
||||||
public void VerifyEmail()
|
public void VerifyEmail()
|
||||||
{
|
{
|
||||||
EmailVerifiedAt = DateTime.UtcNow;
|
EmailVerifiedAt = DateTime.UtcNow;
|
||||||
|
|||||||
@@ -1,22 +1,18 @@
|
|||||||
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
|
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
|
||||||
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
|
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
|
||||||
using ColaFlow.Modules.Identity.Infrastructure.Services;
|
using ColaFlow.Modules.Identity.Infrastructure.Services;
|
||||||
|
using ColaFlow.Shared.Kernel.Common;
|
||||||
|
using MediatR;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace ColaFlow.Modules.Identity.Infrastructure.Persistence;
|
namespace ColaFlow.Modules.Identity.Infrastructure.Persistence;
|
||||||
|
|
||||||
public class IdentityDbContext : DbContext
|
public class IdentityDbContext(
|
||||||
{
|
|
||||||
private readonly ITenantContext _tenantContext;
|
|
||||||
|
|
||||||
public IdentityDbContext(
|
|
||||||
DbContextOptions<IdentityDbContext> options,
|
DbContextOptions<IdentityDbContext> options,
|
||||||
ITenantContext tenantContext)
|
ITenantContext tenantContext,
|
||||||
: base(options)
|
IMediator mediator)
|
||||||
{
|
: DbContext(options)
|
||||||
_tenantContext = tenantContext;
|
{
|
||||||
}
|
|
||||||
|
|
||||||
public DbSet<Tenant> Tenants => Set<Tenant>();
|
public DbSet<Tenant> Tenants => Set<Tenant>();
|
||||||
public DbSet<User> Users => Set<User>();
|
public DbSet<User> Users => Set<User>();
|
||||||
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||||||
@@ -38,7 +34,7 @@ public class IdentityDbContext : DbContext
|
|||||||
// User entity global query filter
|
// User entity global query filter
|
||||||
// Automatically adds: WHERE tenant_id = @current_tenant_id
|
// Automatically adds: WHERE tenant_id = @current_tenant_id
|
||||||
modelBuilder.Entity<User>().HasQueryFilter(u =>
|
modelBuilder.Entity<User>().HasQueryFilter(u =>
|
||||||
!_tenantContext.IsSet || u.TenantId == _tenantContext.TenantId);
|
!tenantContext.IsSet || u.TenantId == tenantContext.TenantId);
|
||||||
|
|
||||||
// Tenant entity doesn't need filter (need to query all tenants)
|
// Tenant entity doesn't need filter (need to query all tenants)
|
||||||
}
|
}
|
||||||
@@ -50,4 +46,43 @@ public class IdentityDbContext : DbContext
|
|||||||
{
|
{
|
||||||
return Set<T>().IgnoreQueryFilters();
|
return Set<T>().IgnoreQueryFilters();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Override SaveChangesAsync to dispatch domain events before saving
|
||||||
|
/// </summary>
|
||||||
|
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// Dispatch domain events BEFORE saving changes
|
||||||
|
await DispatchDomainEventsAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Save changes to database
|
||||||
|
return await base.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dispatch domain events to handlers via MediatR
|
||||||
|
/// </summary>
|
||||||
|
private async Task DispatchDomainEventsAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Get all aggregate roots with pending domain events
|
||||||
|
var domainEntities = ChangeTracker
|
||||||
|
.Entries<AggregateRoot>()
|
||||||
|
.Where(x => x.Entity.DomainEvents.Any())
|
||||||
|
.Select(x => x.Entity)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Collect all domain events
|
||||||
|
var domainEvents = domainEntities
|
||||||
|
.SelectMany(x => x.DomainEvents)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Clear events from aggregates (prevent double-publishing)
|
||||||
|
domainEntities.ForEach(entity => entity.ClearDomainEvents());
|
||||||
|
|
||||||
|
// Publish each event via MediatR
|
||||||
|
foreach (var domainEvent in domainEvents)
|
||||||
|
{
|
||||||
|
await mediator.Publish(domainEvent, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,20 +4,13 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
|
|
||||||
namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Repositories;
|
namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Repositories;
|
||||||
|
|
||||||
public class RefreshTokenRepository : IRefreshTokenRepository
|
public class RefreshTokenRepository(IdentityDbContext context) : IRefreshTokenRepository
|
||||||
{
|
{
|
||||||
private readonly IdentityDbContext _context;
|
|
||||||
|
|
||||||
public RefreshTokenRepository(IdentityDbContext context)
|
|
||||||
{
|
|
||||||
_context = context;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<RefreshToken?> GetByTokenHashAsync(
|
public async Task<RefreshToken?> GetByTokenHashAsync(
|
||||||
string tokenHash,
|
string tokenHash,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await _context.RefreshTokens
|
return await context.RefreshTokens
|
||||||
.FirstOrDefaultAsync(rt => rt.TokenHash == tokenHash, cancellationToken);
|
.FirstOrDefaultAsync(rt => rt.TokenHash == tokenHash, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,7 +18,7 @@ public class RefreshTokenRepository : IRefreshTokenRepository
|
|||||||
Guid userId,
|
Guid userId,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await _context.RefreshTokens
|
return await context.RefreshTokens
|
||||||
.Where(rt => rt.UserId.Value == userId)
|
.Where(rt => rt.UserId.Value == userId)
|
||||||
.OrderByDescending(rt => rt.CreatedAt)
|
.OrderByDescending(rt => rt.CreatedAt)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
@@ -36,7 +29,7 @@ public class RefreshTokenRepository : IRefreshTokenRepository
|
|||||||
Guid tenantId,
|
Guid tenantId,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await _context.RefreshTokens
|
return await context.RefreshTokens
|
||||||
.Where(rt => rt.UserId.Value == userId && rt.TenantId == tenantId)
|
.Where(rt => rt.UserId.Value == userId && rt.TenantId == tenantId)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
@@ -45,24 +38,24 @@ public class RefreshTokenRepository : IRefreshTokenRepository
|
|||||||
RefreshToken refreshToken,
|
RefreshToken refreshToken,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
await _context.RefreshTokens.AddAsync(refreshToken, cancellationToken);
|
await context.RefreshTokens.AddAsync(refreshToken, cancellationToken);
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
await context.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UpdateAsync(
|
public async Task UpdateAsync(
|
||||||
RefreshToken refreshToken,
|
RefreshToken refreshToken,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
_context.RefreshTokens.Update(refreshToken);
|
context.RefreshTokens.Update(refreshToken);
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
await context.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UpdateRangeAsync(
|
public async Task UpdateRangeAsync(
|
||||||
IEnumerable<RefreshToken> refreshTokens,
|
IEnumerable<RefreshToken> refreshTokens,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
_context.RefreshTokens.UpdateRange(refreshTokens);
|
context.RefreshTokens.UpdateRange(refreshTokens);
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
await context.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task RevokeAllUserTokensAsync(
|
public async Task RevokeAllUserTokensAsync(
|
||||||
@@ -70,7 +63,7 @@ public class RefreshTokenRepository : IRefreshTokenRepository
|
|||||||
string reason,
|
string reason,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var tokens = await _context.RefreshTokens
|
var tokens = await context.RefreshTokens
|
||||||
.Where(rt => rt.UserId.Value == userId && rt.RevokedAt == null)
|
.Where(rt => rt.UserId.Value == userId && rt.RevokedAt == null)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
@@ -79,16 +72,16 @@ public class RefreshTokenRepository : IRefreshTokenRepository
|
|||||||
token.Revoke(reason);
|
token.Revoke(reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
await context.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeleteExpiredTokensAsync(CancellationToken cancellationToken = default)
|
public async Task DeleteExpiredTokensAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var expiredTokens = await _context.RefreshTokens
|
var expiredTokens = await context.RefreshTokens
|
||||||
.Where(rt => rt.ExpiresAt < DateTime.UtcNow)
|
.Where(rt => rt.ExpiresAt < DateTime.UtcNow)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
_context.RefreshTokens.RemoveRange(expiredTokens);
|
context.RefreshTokens.RemoveRange(expiredTokens);
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
await context.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,53 +4,46 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
|
|
||||||
namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Repositories;
|
namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Repositories;
|
||||||
|
|
||||||
public class TenantRepository : ITenantRepository
|
public class TenantRepository(IdentityDbContext context) : ITenantRepository
|
||||||
{
|
{
|
||||||
private readonly IdentityDbContext _context;
|
|
||||||
|
|
||||||
public TenantRepository(IdentityDbContext context)
|
|
||||||
{
|
|
||||||
_context = context;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Tenant?> GetByIdAsync(TenantId tenantId, CancellationToken cancellationToken = default)
|
public async Task<Tenant?> GetByIdAsync(TenantId tenantId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await _context.Tenants
|
return await context.Tenants
|
||||||
.FirstOrDefaultAsync(t => t.Id == tenantId, cancellationToken);
|
.FirstOrDefaultAsync(t => t.Id == tenantId, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Tenant?> GetBySlugAsync(TenantSlug slug, CancellationToken cancellationToken = default)
|
public async Task<Tenant?> GetBySlugAsync(TenantSlug slug, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await _context.Tenants
|
return await context.Tenants
|
||||||
.FirstOrDefaultAsync(t => t.Slug == slug, cancellationToken);
|
.FirstOrDefaultAsync(t => t.Slug == slug, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> ExistsBySlugAsync(TenantSlug slug, CancellationToken cancellationToken = default)
|
public async Task<bool> ExistsBySlugAsync(TenantSlug slug, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await _context.Tenants
|
return await context.Tenants
|
||||||
.AnyAsync(t => t.Slug == slug, cancellationToken);
|
.AnyAsync(t => t.Slug == slug, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<Tenant>> GetAllAsync(CancellationToken cancellationToken = default)
|
public async Task<IReadOnlyList<Tenant>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await _context.Tenants.ToListAsync(cancellationToken);
|
return await context.Tenants.ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task AddAsync(Tenant tenant, CancellationToken cancellationToken = default)
|
public async Task AddAsync(Tenant tenant, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
await _context.Tenants.AddAsync(tenant, cancellationToken);
|
await context.Tenants.AddAsync(tenant, cancellationToken);
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
await context.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UpdateAsync(Tenant tenant, CancellationToken cancellationToken = default)
|
public async Task UpdateAsync(Tenant tenant, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
_context.Tenants.Update(tenant);
|
context.Tenants.Update(tenant);
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
await context.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeleteAsync(Tenant tenant, CancellationToken cancellationToken = default)
|
public async Task DeleteAsync(Tenant tenant, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
_context.Tenants.Remove(tenant);
|
context.Tenants.Remove(tenant);
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
await context.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,26 +5,19 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
|
|
||||||
namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Repositories;
|
namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Repositories;
|
||||||
|
|
||||||
public class UserRepository : IUserRepository
|
public class UserRepository(IdentityDbContext context) : IUserRepository
|
||||||
{
|
{
|
||||||
private readonly IdentityDbContext _context;
|
|
||||||
|
|
||||||
public UserRepository(IdentityDbContext context)
|
|
||||||
{
|
|
||||||
_context = context;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<User?> GetByIdAsync(UserId userId, CancellationToken cancellationToken = default)
|
public async Task<User?> GetByIdAsync(UserId userId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
// Global Query Filter automatically applies
|
// Global Query Filter automatically applies
|
||||||
return await _context.Users
|
return await context.Users
|
||||||
.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken);
|
.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<User?> GetByIdAsync(Guid userId, CancellationToken cancellationToken = default)
|
public async Task<User?> GetByIdAsync(Guid userId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var userIdVO = UserId.Create(userId);
|
var userIdVO = UserId.Create(userId);
|
||||||
return await _context.Users
|
return await context.Users
|
||||||
.FirstOrDefaultAsync(u => u.Id == userIdVO, cancellationToken);
|
.FirstOrDefaultAsync(u => u.Id == userIdVO, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +42,7 @@ public class UserRepository : IUserRepository
|
|||||||
|
|
||||||
public async Task<User?> GetByEmailAsync(TenantId tenantId, Email email, CancellationToken cancellationToken = default)
|
public async Task<User?> GetByEmailAsync(TenantId tenantId, Email email, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await _context.Users
|
return await context.Users
|
||||||
.FirstOrDefaultAsync(u => u.TenantId == tenantId && u.Email == email, cancellationToken);
|
.FirstOrDefaultAsync(u => u.TenantId == tenantId && u.Email == email, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +52,7 @@ public class UserRepository : IUserRepository
|
|||||||
string externalUserId,
|
string externalUserId,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await _context.Users
|
return await context.Users
|
||||||
.FirstOrDefaultAsync(
|
.FirstOrDefaultAsync(
|
||||||
u => u.TenantId == tenantId &&
|
u => u.TenantId == tenantId &&
|
||||||
u.AuthProvider == provider &&
|
u.AuthProvider == provider &&
|
||||||
@@ -69,38 +62,38 @@ public class UserRepository : IUserRepository
|
|||||||
|
|
||||||
public async Task<bool> ExistsByEmailAsync(TenantId tenantId, Email email, CancellationToken cancellationToken = default)
|
public async Task<bool> ExistsByEmailAsync(TenantId tenantId, Email email, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await _context.Users
|
return await context.Users
|
||||||
.AnyAsync(u => u.TenantId == tenantId && u.Email == email, cancellationToken);
|
.AnyAsync(u => u.TenantId == tenantId && u.Email == email, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<User>> GetAllByTenantAsync(TenantId tenantId, CancellationToken cancellationToken = default)
|
public async Task<IReadOnlyList<User>> GetAllByTenantAsync(TenantId tenantId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await _context.Users
|
return await context.Users
|
||||||
.Where(u => u.TenantId == tenantId)
|
.Where(u => u.TenantId == tenantId)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<int> GetActiveUsersCountAsync(TenantId tenantId, CancellationToken cancellationToken = default)
|
public async Task<int> GetActiveUsersCountAsync(TenantId tenantId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await _context.Users
|
return await context.Users
|
||||||
.CountAsync(u => u.TenantId == tenantId && u.Status == UserStatus.Active, cancellationToken);
|
.CountAsync(u => u.TenantId == tenantId && u.Status == UserStatus.Active, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task AddAsync(User user, CancellationToken cancellationToken = default)
|
public async Task AddAsync(User user, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
await _context.Users.AddAsync(user, cancellationToken);
|
await context.Users.AddAsync(user, cancellationToken);
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
await context.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UpdateAsync(User user, CancellationToken cancellationToken = default)
|
public async Task UpdateAsync(User user, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
_context.Users.Update(user);
|
context.Users.Update(user);
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
await context.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeleteAsync(User user, CancellationToken cancellationToken = default)
|
public async Task DeleteAsync(User user, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
_context.Users.Remove(user);
|
context.Users.Remove(user);
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
await context.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,15 +5,8 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
|
|
||||||
namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Repositories;
|
namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Repositories;
|
||||||
|
|
||||||
public class UserTenantRoleRepository : IUserTenantRoleRepository
|
public class UserTenantRoleRepository(IdentityDbContext context) : IUserTenantRoleRepository
|
||||||
{
|
{
|
||||||
private readonly IdentityDbContext _context;
|
|
||||||
|
|
||||||
public UserTenantRoleRepository(IdentityDbContext context)
|
|
||||||
{
|
|
||||||
_context = context;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<UserTenantRole?> GetByUserAndTenantAsync(
|
public async Task<UserTenantRole?> GetByUserAndTenantAsync(
|
||||||
Guid userId,
|
Guid userId,
|
||||||
Guid tenantId,
|
Guid tenantId,
|
||||||
@@ -23,7 +16,7 @@ public class UserTenantRoleRepository : IUserTenantRoleRepository
|
|||||||
var userIdVO = UserId.Create(userId);
|
var userIdVO = UserId.Create(userId);
|
||||||
var tenantIdVO = TenantId.Create(tenantId);
|
var tenantIdVO = TenantId.Create(tenantId);
|
||||||
|
|
||||||
return await _context.UserTenantRoles
|
return await context.UserTenantRoles
|
||||||
.FirstOrDefaultAsync(
|
.FirstOrDefaultAsync(
|
||||||
utr => utr.UserId == userIdVO && utr.TenantId == tenantIdVO,
|
utr => utr.UserId == userIdVO && utr.TenantId == tenantIdVO,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
@@ -36,7 +29,7 @@ public class UserTenantRoleRepository : IUserTenantRoleRepository
|
|||||||
// Create value object to avoid LINQ translation issues with .Value property
|
// Create value object to avoid LINQ translation issues with .Value property
|
||||||
var userIdVO = UserId.Create(userId);
|
var userIdVO = UserId.Create(userId);
|
||||||
|
|
||||||
return await _context.UserTenantRoles
|
return await context.UserTenantRoles
|
||||||
.Where(utr => utr.UserId == userIdVO)
|
.Where(utr => utr.UserId == userIdVO)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
@@ -48,7 +41,7 @@ public class UserTenantRoleRepository : IUserTenantRoleRepository
|
|||||||
// Create value object to avoid LINQ translation issues with .Value property
|
// Create value object to avoid LINQ translation issues with .Value property
|
||||||
var tenantIdVO = TenantId.Create(tenantId);
|
var tenantIdVO = TenantId.Create(tenantId);
|
||||||
|
|
||||||
return await _context.UserTenantRoles
|
return await context.UserTenantRoles
|
||||||
.Where(utr => utr.TenantId == tenantIdVO)
|
.Where(utr => utr.TenantId == tenantIdVO)
|
||||||
// Note: User navigation is ignored in EF config, so Include is skipped
|
// Note: User navigation is ignored in EF config, so Include is skipped
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
@@ -56,20 +49,20 @@ public class UserTenantRoleRepository : IUserTenantRoleRepository
|
|||||||
|
|
||||||
public async Task AddAsync(UserTenantRole role, CancellationToken cancellationToken = default)
|
public async Task AddAsync(UserTenantRole role, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
await _context.UserTenantRoles.AddAsync(role, cancellationToken);
|
await context.UserTenantRoles.AddAsync(role, cancellationToken);
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
await context.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UpdateAsync(UserTenantRole role, CancellationToken cancellationToken = default)
|
public async Task UpdateAsync(UserTenantRole role, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
_context.UserTenantRoles.Update(role);
|
context.UserTenantRoles.Update(role);
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
await context.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeleteAsync(UserTenantRole role, CancellationToken cancellationToken = default)
|
public async Task DeleteAsync(UserTenantRole role, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
_context.UserTenantRoles.Remove(role);
|
context.UserTenantRoles.Remove(role);
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
await context.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<(List<UserTenantRole> Items, int TotalCount)> GetTenantUsersWithRolesAsync(
|
public async Task<(List<UserTenantRole> Items, int TotalCount)> GetTenantUsersWithRolesAsync(
|
||||||
@@ -81,7 +74,7 @@ public class UserTenantRoleRepository : IUserTenantRoleRepository
|
|||||||
{
|
{
|
||||||
var tenantIdVO = TenantId.Create(tenantId);
|
var tenantIdVO = TenantId.Create(tenantId);
|
||||||
|
|
||||||
var query = _context.UserTenantRoles
|
var query = context.UserTenantRoles
|
||||||
.Where(utr => utr.TenantId == tenantIdVO);
|
.Where(utr => utr.TenantId == tenantIdVO);
|
||||||
|
|
||||||
// Note: Search filtering would require joining with Users table
|
// Note: Search filtering would require joining with Users table
|
||||||
@@ -105,14 +98,14 @@ public class UserTenantRoleRepository : IUserTenantRoleRepository
|
|||||||
{
|
{
|
||||||
var tenantIdVO = TenantId.Create(tenantId);
|
var tenantIdVO = TenantId.Create(tenantId);
|
||||||
|
|
||||||
var ownerCount = await _context.UserTenantRoles
|
var ownerCount = await context.UserTenantRoles
|
||||||
.Where(utr => utr.TenantId == tenantIdVO && utr.Role == TenantRole.TenantOwner)
|
.Where(utr => utr.TenantId == tenantIdVO && utr.Role == TenantRole.TenantOwner)
|
||||||
.CountAsync(cancellationToken);
|
.CountAsync(cancellationToken);
|
||||||
|
|
||||||
if (ownerCount <= 1)
|
if (ownerCount <= 1)
|
||||||
{
|
{
|
||||||
var userIdVO = UserId.Create(userId);
|
var userIdVO = UserId.Create(userId);
|
||||||
var userIsOwner = await _context.UserTenantRoles
|
var userIsOwner = await context.UserTenantRoles
|
||||||
.AnyAsync(utr => utr.TenantId == tenantIdVO &&
|
.AnyAsync(utr => utr.TenantId == tenantIdVO &&
|
||||||
utr.UserId == userIdVO &&
|
utr.UserId == userIdVO &&
|
||||||
utr.Role == TenantRole.TenantOwner,
|
utr.Role == TenantRole.TenantOwner,
|
||||||
@@ -131,7 +124,7 @@ public class UserTenantRoleRepository : IUserTenantRoleRepository
|
|||||||
{
|
{
|
||||||
var tenantIdVO = TenantId.Create(tenantId);
|
var tenantIdVO = TenantId.Create(tenantId);
|
||||||
|
|
||||||
return await _context.UserTenantRoles
|
return await context.UserTenantRoles
|
||||||
.CountAsync(utr => utr.TenantId == tenantIdVO && utr.Role == role,
|
.CountAsync(utr => utr.TenantId == tenantIdVO && utr.Role == role,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,19 +9,12 @@ using System.Text;
|
|||||||
|
|
||||||
namespace ColaFlow.Modules.Identity.Infrastructure.Services;
|
namespace ColaFlow.Modules.Identity.Infrastructure.Services;
|
||||||
|
|
||||||
public class JwtService : IJwtService
|
public class JwtService(IConfiguration configuration) : IJwtService
|
||||||
{
|
{
|
||||||
private readonly IConfiguration _configuration;
|
|
||||||
|
|
||||||
public JwtService(IConfiguration configuration)
|
|
||||||
{
|
|
||||||
_configuration = configuration;
|
|
||||||
}
|
|
||||||
|
|
||||||
public string GenerateToken(User user, Tenant tenant, TenantRole tenantRole)
|
public string GenerateToken(User user, Tenant tenant, TenantRole tenantRole)
|
||||||
{
|
{
|
||||||
var securityKey = new SymmetricSecurityKey(
|
var securityKey = new SymmetricSecurityKey(
|
||||||
Encoding.UTF8.GetBytes(_configuration["Jwt:SecretKey"] ?? throw new InvalidOperationException("JWT SecretKey not configured")));
|
Encoding.UTF8.GetBytes(configuration["Jwt:SecretKey"] ?? throw new InvalidOperationException("JWT SecretKey not configured")));
|
||||||
|
|
||||||
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
|
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
|
||||||
|
|
||||||
@@ -43,10 +36,10 @@ public class JwtService : IJwtService
|
|||||||
};
|
};
|
||||||
|
|
||||||
var token = new JwtSecurityToken(
|
var token = new JwtSecurityToken(
|
||||||
issuer: _configuration["Jwt:Issuer"],
|
issuer: configuration["Jwt:Issuer"],
|
||||||
audience: _configuration["Jwt:Audience"],
|
audience: configuration["Jwt:Audience"],
|
||||||
claims: claims,
|
claims: claims,
|
||||||
expires: DateTime.UtcNow.AddMinutes(Convert.ToDouble(_configuration["Jwt:ExpirationMinutes"] ?? "60")),
|
expires: DateTime.UtcNow.AddMinutes(Convert.ToDouble(configuration["Jwt:ExpirationMinutes"] ?? "60")),
|
||||||
signingCredentials: credentials
|
signingCredentials: credentials
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -9,17 +9,7 @@ using System.Text;
|
|||||||
|
|
||||||
namespace ColaFlow.Modules.Identity.Infrastructure.Services;
|
namespace ColaFlow.Modules.Identity.Infrastructure.Services;
|
||||||
|
|
||||||
public class RefreshTokenService : IRefreshTokenService
|
public class RefreshTokenService(
|
||||||
{
|
|
||||||
private readonly IRefreshTokenRepository _refreshTokenRepository;
|
|
||||||
private readonly IUserRepository _userRepository;
|
|
||||||
private readonly ITenantRepository _tenantRepository;
|
|
||||||
private readonly IUserTenantRoleRepository _userTenantRoleRepository;
|
|
||||||
private readonly IJwtService _jwtService;
|
|
||||||
private readonly IConfiguration _configuration;
|
|
||||||
private readonly ILogger<RefreshTokenService> _logger;
|
|
||||||
|
|
||||||
public RefreshTokenService(
|
|
||||||
IRefreshTokenRepository refreshTokenRepository,
|
IRefreshTokenRepository refreshTokenRepository,
|
||||||
IUserRepository userRepository,
|
IUserRepository userRepository,
|
||||||
ITenantRepository tenantRepository,
|
ITenantRepository tenantRepository,
|
||||||
@@ -27,16 +17,8 @@ public class RefreshTokenService : IRefreshTokenService
|
|||||||
IJwtService jwtService,
|
IJwtService jwtService,
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
ILogger<RefreshTokenService> logger)
|
ILogger<RefreshTokenService> logger)
|
||||||
{
|
: IRefreshTokenService
|
||||||
_refreshTokenRepository = refreshTokenRepository;
|
{
|
||||||
_userRepository = userRepository;
|
|
||||||
_tenantRepository = tenantRepository;
|
|
||||||
_userTenantRoleRepository = userTenantRoleRepository;
|
|
||||||
_jwtService = jwtService;
|
|
||||||
_configuration = configuration;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<string> GenerateRefreshTokenAsync(
|
public async Task<string> GenerateRefreshTokenAsync(
|
||||||
User user,
|
User user,
|
||||||
string? ipAddress = null,
|
string? ipAddress = null,
|
||||||
@@ -53,7 +35,7 @@ public class RefreshTokenService : IRefreshTokenService
|
|||||||
var tokenHash = ComputeSha256Hash(token);
|
var tokenHash = ComputeSha256Hash(token);
|
||||||
|
|
||||||
// Get expiration from configuration (default 7 days)
|
// Get expiration from configuration (default 7 days)
|
||||||
var expirationDays = _configuration.GetValue<int>("Jwt:RefreshTokenExpirationDays", 7);
|
var expirationDays = configuration.GetValue<int>("Jwt:RefreshTokenExpirationDays", 7);
|
||||||
var expiresAt = DateTime.UtcNow.AddDays(expirationDays);
|
var expiresAt = DateTime.UtcNow.AddDays(expirationDays);
|
||||||
|
|
||||||
// Create refresh token entity
|
// Create refresh token entity
|
||||||
@@ -68,9 +50,9 @@ public class RefreshTokenService : IRefreshTokenService
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Save to database
|
// Save to database
|
||||||
await _refreshTokenRepository.AddAsync(refreshToken, cancellationToken);
|
await refreshTokenRepository.AddAsync(refreshToken, cancellationToken);
|
||||||
|
|
||||||
_logger.LogInformation(
|
logger.LogInformation(
|
||||||
"Generated refresh token for user {UserId}, expires at {ExpiresAt}",
|
"Generated refresh token for user {UserId}, expires at {ExpiresAt}",
|
||||||
user.Id, expiresAt);
|
user.Id, expiresAt);
|
||||||
|
|
||||||
@@ -88,25 +70,25 @@ public class RefreshTokenService : IRefreshTokenService
|
|||||||
var tokenHash = ComputeSha256Hash(refreshToken);
|
var tokenHash = ComputeSha256Hash(refreshToken);
|
||||||
|
|
||||||
// Find existing token
|
// Find existing token
|
||||||
var existingToken = await _refreshTokenRepository.GetByTokenHashAsync(tokenHash, cancellationToken);
|
var existingToken = await refreshTokenRepository.GetByTokenHashAsync(tokenHash, cancellationToken);
|
||||||
|
|
||||||
if (existingToken == null)
|
if (existingToken == null)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Refresh token not found: {TokenHash}", tokenHash[..10] + "...");
|
logger.LogWarning("Refresh token not found: {TokenHash}", tokenHash[..10] + "...");
|
||||||
throw new UnauthorizedAccessException("Invalid refresh token");
|
throw new UnauthorizedAccessException("Invalid refresh token");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if token is active (not expired and not revoked)
|
// Check if token is active (not expired and not revoked)
|
||||||
if (!existingToken.IsActive())
|
if (!existingToken.IsActive())
|
||||||
{
|
{
|
||||||
_logger.LogWarning(
|
logger.LogWarning(
|
||||||
"Attempted to use invalid refresh token for user {UserId}. Expired: {IsExpired}, Revoked: {IsRevoked}",
|
"Attempted to use invalid refresh token for user {UserId}. Expired: {IsExpired}, Revoked: {IsRevoked}",
|
||||||
existingToken.UserId.Value, existingToken.IsExpired(), existingToken.IsRevoked());
|
existingToken.UserId.Value, existingToken.IsExpired(), existingToken.IsRevoked());
|
||||||
|
|
||||||
// SECURITY: Token reuse detection - revoke all user tokens
|
// SECURITY: Token reuse detection - revoke all user tokens
|
||||||
if (existingToken.IsRevoked())
|
if (existingToken.IsRevoked())
|
||||||
{
|
{
|
||||||
_logger.LogWarning(
|
logger.LogWarning(
|
||||||
"SECURITY ALERT: Revoked token reused for user {UserId}. Revoking all tokens.",
|
"SECURITY ALERT: Revoked token reused for user {UserId}. Revoking all tokens.",
|
||||||
existingToken.UserId.Value);
|
existingToken.UserId.Value);
|
||||||
await RevokeAllUserTokensAsync(existingToken.UserId.Value, cancellationToken);
|
await RevokeAllUserTokensAsync(existingToken.UserId.Value, cancellationToken);
|
||||||
@@ -116,34 +98,34 @@ public class RefreshTokenService : IRefreshTokenService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get user and tenant
|
// Get user and tenant
|
||||||
var user = await _userRepository.GetByIdAsync(existingToken.UserId, cancellationToken);
|
var user = await userRepository.GetByIdAsync(existingToken.UserId, cancellationToken);
|
||||||
if (user == null || user.Status != UserStatus.Active)
|
if (user == null || user.Status != UserStatus.Active)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("User not found or inactive: {UserId}", existingToken.UserId.Value);
|
logger.LogWarning("User not found or inactive: {UserId}", existingToken.UserId.Value);
|
||||||
throw new UnauthorizedAccessException("User not found or inactive");
|
throw new UnauthorizedAccessException("User not found or inactive");
|
||||||
}
|
}
|
||||||
|
|
||||||
var tenant = await _tenantRepository.GetByIdAsync(TenantId.Create(existingToken.TenantId), cancellationToken);
|
var tenant = await tenantRepository.GetByIdAsync(TenantId.Create(existingToken.TenantId), cancellationToken);
|
||||||
if (tenant == null || tenant.Status != TenantStatus.Active)
|
if (tenant == null || tenant.Status != TenantStatus.Active)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Tenant not found or inactive: {TenantId}", existingToken.TenantId);
|
logger.LogWarning("Tenant not found or inactive: {TenantId}", existingToken.TenantId);
|
||||||
throw new UnauthorizedAccessException("Tenant not found or inactive");
|
throw new UnauthorizedAccessException("Tenant not found or inactive");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user's tenant role
|
// Get user's tenant role
|
||||||
var userTenantRole = await _userTenantRoleRepository.GetByUserAndTenantAsync(
|
var userTenantRole = await userTenantRoleRepository.GetByUserAndTenantAsync(
|
||||||
user.Id,
|
user.Id,
|
||||||
tenant.Id,
|
tenant.Id,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
if (userTenantRole == null)
|
if (userTenantRole == null)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("User {UserId} has no role assigned for tenant {TenantId}", user.Id, tenant.Id);
|
logger.LogWarning("User {UserId} has no role assigned for tenant {TenantId}", user.Id, tenant.Id);
|
||||||
throw new UnauthorizedAccessException("User role not found");
|
throw new UnauthorizedAccessException("User role not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate new access token with role
|
// Generate new access token with role
|
||||||
var newAccessToken = _jwtService.GenerateToken(user, tenant, userTenantRole.Role);
|
var newAccessToken = jwtService.GenerateToken(user, tenant, userTenantRole.Role);
|
||||||
|
|
||||||
// Generate new refresh token (token rotation)
|
// Generate new refresh token (token rotation)
|
||||||
var newRefreshToken = await GenerateRefreshTokenAsync(user, ipAddress, userAgent, cancellationToken);
|
var newRefreshToken = await GenerateRefreshTokenAsync(user, ipAddress, userAgent, cancellationToken);
|
||||||
@@ -151,9 +133,9 @@ public class RefreshTokenService : IRefreshTokenService
|
|||||||
// Mark old token as replaced
|
// Mark old token as replaced
|
||||||
var newTokenHash = ComputeSha256Hash(newRefreshToken);
|
var newTokenHash = ComputeSha256Hash(newRefreshToken);
|
||||||
existingToken.MarkAsReplaced(newTokenHash);
|
existingToken.MarkAsReplaced(newTokenHash);
|
||||||
await _refreshTokenRepository.UpdateAsync(existingToken, cancellationToken);
|
await refreshTokenRepository.UpdateAsync(existingToken, cancellationToken);
|
||||||
|
|
||||||
_logger.LogInformation(
|
logger.LogInformation(
|
||||||
"Rotated refresh token for user {UserId}",
|
"Rotated refresh token for user {UserId}",
|
||||||
user.Id);
|
user.Id);
|
||||||
|
|
||||||
@@ -166,17 +148,17 @@ public class RefreshTokenService : IRefreshTokenService
|
|||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var tokenHash = ComputeSha256Hash(refreshToken);
|
var tokenHash = ComputeSha256Hash(refreshToken);
|
||||||
var token = await _refreshTokenRepository.GetByTokenHashAsync(tokenHash, cancellationToken);
|
var token = await refreshTokenRepository.GetByTokenHashAsync(tokenHash, cancellationToken);
|
||||||
|
|
||||||
if (token == null)
|
if (token == null)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Attempted to revoke non-existent token");
|
logger.LogWarning("Attempted to revoke non-existent token");
|
||||||
return; // Silent failure for security
|
return; // Silent failure for security
|
||||||
}
|
}
|
||||||
|
|
||||||
if (token.IsRevoked())
|
if (token.IsRevoked())
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Token already revoked: {TokenId}", token.Id);
|
logger.LogWarning("Token already revoked: {TokenId}", token.Id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,9 +167,9 @@ public class RefreshTokenService : IRefreshTokenService
|
|||||||
: "User logout";
|
: "User logout";
|
||||||
|
|
||||||
token.Revoke(reason);
|
token.Revoke(reason);
|
||||||
await _refreshTokenRepository.UpdateAsync(token, cancellationToken);
|
await refreshTokenRepository.UpdateAsync(token, cancellationToken);
|
||||||
|
|
||||||
_logger.LogInformation(
|
logger.LogInformation(
|
||||||
"Revoked refresh token {TokenId} for user {UserId}",
|
"Revoked refresh token {TokenId} for user {UserId}",
|
||||||
token.Id, token.UserId.Value);
|
token.Id, token.UserId.Value);
|
||||||
}
|
}
|
||||||
@@ -196,12 +178,12 @@ public class RefreshTokenService : IRefreshTokenService
|
|||||||
Guid userId,
|
Guid userId,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
await _refreshTokenRepository.RevokeAllUserTokensAsync(
|
await refreshTokenRepository.RevokeAllUserTokensAsync(
|
||||||
userId,
|
userId,
|
||||||
"User requested logout from all devices",
|
"User requested logout from all devices",
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
_logger.LogInformation(
|
logger.LogInformation(
|
||||||
"Revoked all refresh tokens for user {UserId}",
|
"Revoked all refresh tokens for user {UserId}",
|
||||||
userId);
|
userId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,22 +6,16 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Behaviors;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Pipeline behavior for request validation using FluentValidation
|
/// Pipeline behavior for request validation using FluentValidation
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
|
public sealed class ValidationBehavior<TRequest, TResponse>(IEnumerable<IValidator<TRequest>> validators)
|
||||||
|
: IPipelineBehavior<TRequest, TResponse>
|
||||||
where TRequest : IRequest<TResponse>
|
where TRequest : IRequest<TResponse>
|
||||||
{
|
{
|
||||||
private readonly IEnumerable<IValidator<TRequest>> _validators;
|
|
||||||
|
|
||||||
public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
|
|
||||||
{
|
|
||||||
_validators = validators;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<TResponse> Handle(
|
public async Task<TResponse> Handle(
|
||||||
TRequest request,
|
TRequest request,
|
||||||
RequestHandlerDelegate<TResponse> next,
|
RequestHandlerDelegate<TResponse> next,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (!_validators.Any())
|
if (!validators.Any())
|
||||||
{
|
{
|
||||||
return await next();
|
return await next();
|
||||||
}
|
}
|
||||||
@@ -29,7 +23,7 @@ public sealed class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<
|
|||||||
var context = new ValidationContext<TRequest>(request);
|
var context = new ValidationContext<TRequest>(request);
|
||||||
|
|
||||||
var validationResults = await Task.WhenAll(
|
var validationResults = await Task.WhenAll(
|
||||||
_validators.Select(v => v.ValidateAsync(context, cancellationToken)));
|
validators.Select(v => v.ValidateAsync(context, cancellationToken)));
|
||||||
|
|
||||||
var failures = validationResults
|
var failures = validationResults
|
||||||
.SelectMany(r => r.Errors)
|
.SelectMany(r => r.Errors)
|
||||||
|
|||||||
@@ -9,18 +9,13 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Commands.AssignStory;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handler for AssignStoryCommand
|
/// Handler for AssignStoryCommand
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class AssignStoryCommandHandler : IRequestHandler<AssignStoryCommand, StoryDto>
|
public sealed class AssignStoryCommandHandler(
|
||||||
{
|
|
||||||
private readonly IProjectRepository _projectRepository;
|
|
||||||
private readonly IUnitOfWork _unitOfWork;
|
|
||||||
|
|
||||||
public AssignStoryCommandHandler(
|
|
||||||
IProjectRepository projectRepository,
|
IProjectRepository projectRepository,
|
||||||
IUnitOfWork unitOfWork)
|
IUnitOfWork unitOfWork)
|
||||||
{
|
: IRequestHandler<AssignStoryCommand, StoryDto>
|
||||||
_projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
{
|
||||||
_unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
|
private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
||||||
}
|
private readonly IUnitOfWork _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
|
||||||
|
|
||||||
public async Task<StoryDto> Handle(AssignStoryCommand request, CancellationToken cancellationToken)
|
public async Task<StoryDto> Handle(AssignStoryCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,18 +10,13 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Commands.AssignTask;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handler for AssignTaskCommand
|
/// Handler for AssignTaskCommand
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class AssignTaskCommandHandler : IRequestHandler<AssignTaskCommand, TaskDto>
|
public sealed class AssignTaskCommandHandler(
|
||||||
{
|
|
||||||
private readonly IProjectRepository _projectRepository;
|
|
||||||
private readonly IUnitOfWork _unitOfWork;
|
|
||||||
|
|
||||||
public AssignTaskCommandHandler(
|
|
||||||
IProjectRepository projectRepository,
|
IProjectRepository projectRepository,
|
||||||
IUnitOfWork unitOfWork)
|
IUnitOfWork unitOfWork)
|
||||||
{
|
: IRequestHandler<AssignTaskCommand, TaskDto>
|
||||||
_projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
{
|
||||||
_unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
|
private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
||||||
}
|
private readonly IUnitOfWork _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
|
||||||
|
|
||||||
public async Task<TaskDto> Handle(AssignTaskCommand request, CancellationToken cancellationToken)
|
public async Task<TaskDto> Handle(AssignTaskCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,18 +9,13 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Commands.CreateEpic;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handler for CreateEpicCommand
|
/// Handler for CreateEpicCommand
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class CreateEpicCommandHandler : IRequestHandler<CreateEpicCommand, EpicDto>
|
public sealed class CreateEpicCommandHandler(
|
||||||
{
|
|
||||||
private readonly IProjectRepository _projectRepository;
|
|
||||||
private readonly IUnitOfWork _unitOfWork;
|
|
||||||
|
|
||||||
public CreateEpicCommandHandler(
|
|
||||||
IProjectRepository projectRepository,
|
IProjectRepository projectRepository,
|
||||||
IUnitOfWork unitOfWork)
|
IUnitOfWork unitOfWork)
|
||||||
{
|
: IRequestHandler<CreateEpicCommand, EpicDto>
|
||||||
_projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
{
|
||||||
_unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
|
private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
||||||
}
|
private readonly IUnitOfWork _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
|
||||||
|
|
||||||
public async Task<EpicDto> Handle(CreateEpicCommand request, CancellationToken cancellationToken)
|
public async Task<EpicDto> Handle(CreateEpicCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,18 +10,13 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Commands.CreateProject;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handler for CreateProjectCommand
|
/// Handler for CreateProjectCommand
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class CreateProjectCommandHandler : IRequestHandler<CreateProjectCommand, ProjectDto>
|
public sealed class CreateProjectCommandHandler(
|
||||||
{
|
|
||||||
private readonly IProjectRepository _projectRepository;
|
|
||||||
private readonly IUnitOfWork _unitOfWork;
|
|
||||||
|
|
||||||
public CreateProjectCommandHandler(
|
|
||||||
IProjectRepository projectRepository,
|
IProjectRepository projectRepository,
|
||||||
IUnitOfWork unitOfWork)
|
IUnitOfWork unitOfWork)
|
||||||
{
|
: IRequestHandler<CreateProjectCommand, ProjectDto>
|
||||||
_projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
{
|
||||||
_unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
|
private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
||||||
}
|
private readonly IUnitOfWork _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
|
||||||
|
|
||||||
public async Task<ProjectDto> Handle(CreateProjectCommand request, CancellationToken cancellationToken)
|
public async Task<ProjectDto> Handle(CreateProjectCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,18 +9,13 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Commands.CreateStory;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handler for CreateStoryCommand
|
/// Handler for CreateStoryCommand
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class CreateStoryCommandHandler : IRequestHandler<CreateStoryCommand, StoryDto>
|
public sealed class CreateStoryCommandHandler(
|
||||||
{
|
|
||||||
private readonly IProjectRepository _projectRepository;
|
|
||||||
private readonly IUnitOfWork _unitOfWork;
|
|
||||||
|
|
||||||
public CreateStoryCommandHandler(
|
|
||||||
IProjectRepository projectRepository,
|
IProjectRepository projectRepository,
|
||||||
IUnitOfWork unitOfWork)
|
IUnitOfWork unitOfWork)
|
||||||
{
|
: IRequestHandler<CreateStoryCommand, StoryDto>
|
||||||
_projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
{
|
||||||
_unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
|
private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
||||||
}
|
private readonly IUnitOfWork _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
|
||||||
|
|
||||||
public async Task<StoryDto> Handle(CreateStoryCommand request, CancellationToken cancellationToken)
|
public async Task<StoryDto> Handle(CreateStoryCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,18 +10,13 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Commands.CreateTask;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handler for CreateTaskCommand
|
/// Handler for CreateTaskCommand
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class CreateTaskCommandHandler : IRequestHandler<CreateTaskCommand, TaskDto>
|
public sealed class CreateTaskCommandHandler(
|
||||||
{
|
|
||||||
private readonly IProjectRepository _projectRepository;
|
|
||||||
private readonly IUnitOfWork _unitOfWork;
|
|
||||||
|
|
||||||
public CreateTaskCommandHandler(
|
|
||||||
IProjectRepository projectRepository,
|
IProjectRepository projectRepository,
|
||||||
IUnitOfWork unitOfWork)
|
IUnitOfWork unitOfWork)
|
||||||
{
|
: IRequestHandler<CreateTaskCommand, TaskDto>
|
||||||
_projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
{
|
||||||
_unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
|
private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
||||||
}
|
private readonly IUnitOfWork _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
|
||||||
|
|
||||||
public async Task<TaskDto> Handle(CreateTaskCommand request, CancellationToken cancellationToken)
|
public async Task<TaskDto> Handle(CreateTaskCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,18 +8,13 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Commands.DeleteStory;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handler for DeleteStoryCommand
|
/// Handler for DeleteStoryCommand
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class DeleteStoryCommandHandler : IRequestHandler<DeleteStoryCommand, Unit>
|
public sealed class DeleteStoryCommandHandler(
|
||||||
{
|
|
||||||
private readonly IProjectRepository _projectRepository;
|
|
||||||
private readonly IUnitOfWork _unitOfWork;
|
|
||||||
|
|
||||||
public DeleteStoryCommandHandler(
|
|
||||||
IProjectRepository projectRepository,
|
IProjectRepository projectRepository,
|
||||||
IUnitOfWork unitOfWork)
|
IUnitOfWork unitOfWork)
|
||||||
{
|
: IRequestHandler<DeleteStoryCommand, Unit>
|
||||||
_projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
{
|
||||||
_unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
|
private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
||||||
}
|
private readonly IUnitOfWork _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
|
||||||
|
|
||||||
public async Task<Unit> Handle(DeleteStoryCommand request, CancellationToken cancellationToken)
|
public async Task<Unit> Handle(DeleteStoryCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,18 +9,13 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Commands.DeleteTask;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handler for DeleteTaskCommand
|
/// Handler for DeleteTaskCommand
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class DeleteTaskCommandHandler : IRequestHandler<DeleteTaskCommand, Unit>
|
public sealed class DeleteTaskCommandHandler(
|
||||||
{
|
|
||||||
private readonly IProjectRepository _projectRepository;
|
|
||||||
private readonly IUnitOfWork _unitOfWork;
|
|
||||||
|
|
||||||
public DeleteTaskCommandHandler(
|
|
||||||
IProjectRepository projectRepository,
|
IProjectRepository projectRepository,
|
||||||
IUnitOfWork unitOfWork)
|
IUnitOfWork unitOfWork)
|
||||||
{
|
: IRequestHandler<DeleteTaskCommand, Unit>
|
||||||
_projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
{
|
||||||
_unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
|
private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
||||||
}
|
private readonly IUnitOfWork _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
|
||||||
|
|
||||||
public async Task<Unit> Handle(DeleteTaskCommand request, CancellationToken cancellationToken)
|
public async Task<Unit> Handle(DeleteTaskCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,18 +9,13 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Commands.UpdateEpic;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handler for UpdateEpicCommand
|
/// Handler for UpdateEpicCommand
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class UpdateEpicCommandHandler : IRequestHandler<UpdateEpicCommand, EpicDto>
|
public sealed class UpdateEpicCommandHandler(
|
||||||
{
|
|
||||||
private readonly IProjectRepository _projectRepository;
|
|
||||||
private readonly IUnitOfWork _unitOfWork;
|
|
||||||
|
|
||||||
public UpdateEpicCommandHandler(
|
|
||||||
IProjectRepository projectRepository,
|
IProjectRepository projectRepository,
|
||||||
IUnitOfWork unitOfWork)
|
IUnitOfWork unitOfWork)
|
||||||
{
|
: IRequestHandler<UpdateEpicCommand, EpicDto>
|
||||||
_projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
{
|
||||||
_unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
|
private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
||||||
}
|
private readonly IUnitOfWork _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
|
||||||
|
|
||||||
public async Task<EpicDto> Handle(UpdateEpicCommand request, CancellationToken cancellationToken)
|
public async Task<EpicDto> Handle(UpdateEpicCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,18 +9,13 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Commands.UpdateStory;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handler for UpdateStoryCommand
|
/// Handler for UpdateStoryCommand
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class UpdateStoryCommandHandler : IRequestHandler<UpdateStoryCommand, StoryDto>
|
public sealed class UpdateStoryCommandHandler(
|
||||||
{
|
|
||||||
private readonly IProjectRepository _projectRepository;
|
|
||||||
private readonly IUnitOfWork _unitOfWork;
|
|
||||||
|
|
||||||
public UpdateStoryCommandHandler(
|
|
||||||
IProjectRepository projectRepository,
|
IProjectRepository projectRepository,
|
||||||
IUnitOfWork unitOfWork)
|
IUnitOfWork unitOfWork)
|
||||||
{
|
: IRequestHandler<UpdateStoryCommand, StoryDto>
|
||||||
_projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
{
|
||||||
_unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
|
private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
||||||
}
|
private readonly IUnitOfWork _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
|
||||||
|
|
||||||
public async Task<StoryDto> Handle(UpdateStoryCommand request, CancellationToken cancellationToken)
|
public async Task<StoryDto> Handle(UpdateStoryCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,18 +10,13 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Commands.UpdateTask;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handler for UpdateTaskCommand
|
/// Handler for UpdateTaskCommand
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class UpdateTaskCommandHandler : IRequestHandler<UpdateTaskCommand, TaskDto>
|
public sealed class UpdateTaskCommandHandler(
|
||||||
{
|
|
||||||
private readonly IProjectRepository _projectRepository;
|
|
||||||
private readonly IUnitOfWork _unitOfWork;
|
|
||||||
|
|
||||||
public UpdateTaskCommandHandler(
|
|
||||||
IProjectRepository projectRepository,
|
IProjectRepository projectRepository,
|
||||||
IUnitOfWork unitOfWork)
|
IUnitOfWork unitOfWork)
|
||||||
{
|
: IRequestHandler<UpdateTaskCommand, TaskDto>
|
||||||
_projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
{
|
||||||
_unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
|
private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
||||||
}
|
private readonly IUnitOfWork _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
|
||||||
|
|
||||||
public async Task<TaskDto> Handle(UpdateTaskCommand request, CancellationToken cancellationToken)
|
public async Task<TaskDto> Handle(UpdateTaskCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,18 +10,13 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Commands.UpdateTaskStat
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handler for UpdateTaskStatusCommand
|
/// Handler for UpdateTaskStatusCommand
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class UpdateTaskStatusCommandHandler : IRequestHandler<UpdateTaskStatusCommand, TaskDto>
|
public sealed class UpdateTaskStatusCommandHandler(
|
||||||
{
|
|
||||||
private readonly IProjectRepository _projectRepository;
|
|
||||||
private readonly IUnitOfWork _unitOfWork;
|
|
||||||
|
|
||||||
public UpdateTaskStatusCommandHandler(
|
|
||||||
IProjectRepository projectRepository,
|
IProjectRepository projectRepository,
|
||||||
IUnitOfWork unitOfWork)
|
IUnitOfWork unitOfWork)
|
||||||
{
|
: IRequestHandler<UpdateTaskStatusCommand, TaskDto>
|
||||||
_projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
{
|
||||||
_unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
|
private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
||||||
}
|
private readonly IUnitOfWork _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
|
||||||
|
|
||||||
public async Task<TaskDto> Handle(UpdateTaskStatusCommand request, CancellationToken cancellationToken)
|
public async Task<TaskDto> Handle(UpdateTaskStatusCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,14 +9,10 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Queries.GetEpicById;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handler for GetEpicByIdQuery
|
/// Handler for GetEpicByIdQuery
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class GetEpicByIdQueryHandler : IRequestHandler<GetEpicByIdQuery, EpicDto>
|
public sealed class GetEpicByIdQueryHandler(IProjectRepository projectRepository)
|
||||||
|
: IRequestHandler<GetEpicByIdQuery, EpicDto>
|
||||||
{
|
{
|
||||||
private readonly IProjectRepository _projectRepository;
|
private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
||||||
|
|
||||||
public GetEpicByIdQueryHandler(IProjectRepository projectRepository)
|
|
||||||
{
|
|
||||||
_projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<EpicDto> Handle(GetEpicByIdQuery request, CancellationToken cancellationToken)
|
public async Task<EpicDto> Handle(GetEpicByIdQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,14 +9,10 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Queries.GetEpicsByProje
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handler for GetEpicsByProjectIdQuery
|
/// Handler for GetEpicsByProjectIdQuery
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class GetEpicsByProjectIdQueryHandler : IRequestHandler<GetEpicsByProjectIdQuery, List<EpicDto>>
|
public sealed class GetEpicsByProjectIdQueryHandler(IProjectRepository projectRepository)
|
||||||
|
: IRequestHandler<GetEpicsByProjectIdQuery, List<EpicDto>>
|
||||||
{
|
{
|
||||||
private readonly IProjectRepository _projectRepository;
|
private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
||||||
|
|
||||||
public GetEpicsByProjectIdQueryHandler(IProjectRepository projectRepository)
|
|
||||||
{
|
|
||||||
_projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<List<EpicDto>> Handle(GetEpicsByProjectIdQuery request, CancellationToken cancellationToken)
|
public async Task<List<EpicDto>> Handle(GetEpicsByProjectIdQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,14 +10,10 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Queries.GetProjectById;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handler for GetProjectByIdQuery
|
/// Handler for GetProjectByIdQuery
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class GetProjectByIdQueryHandler : IRequestHandler<GetProjectByIdQuery, ProjectDto>
|
public sealed class GetProjectByIdQueryHandler(IProjectRepository projectRepository)
|
||||||
|
: IRequestHandler<GetProjectByIdQuery, ProjectDto>
|
||||||
{
|
{
|
||||||
private readonly IProjectRepository _projectRepository;
|
private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
||||||
|
|
||||||
public GetProjectByIdQueryHandler(IProjectRepository projectRepository)
|
|
||||||
{
|
|
||||||
_projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<ProjectDto> Handle(GetProjectByIdQuery request, CancellationToken cancellationToken)
|
public async Task<ProjectDto> Handle(GetProjectByIdQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,14 +8,10 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Queries.GetProjects;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handler for GetProjectsQuery
|
/// Handler for GetProjectsQuery
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class GetProjectsQueryHandler : IRequestHandler<GetProjectsQuery, List<ProjectDto>>
|
public sealed class GetProjectsQueryHandler(IProjectRepository projectRepository)
|
||||||
|
: IRequestHandler<GetProjectsQuery, List<ProjectDto>>
|
||||||
{
|
{
|
||||||
private readonly IProjectRepository _projectRepository;
|
private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
||||||
|
|
||||||
public GetProjectsQueryHandler(IProjectRepository projectRepository)
|
|
||||||
{
|
|
||||||
_projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<List<ProjectDto>> Handle(GetProjectsQuery request, CancellationToken cancellationToken)
|
public async Task<List<ProjectDto>> Handle(GetProjectsQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,14 +9,10 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Queries.GetStoriesByEpi
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handler for GetStoriesByEpicIdQuery
|
/// Handler for GetStoriesByEpicIdQuery
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class GetStoriesByEpicIdQueryHandler : IRequestHandler<GetStoriesByEpicIdQuery, List<StoryDto>>
|
public sealed class GetStoriesByEpicIdQueryHandler(IProjectRepository projectRepository)
|
||||||
|
: IRequestHandler<GetStoriesByEpicIdQuery, List<StoryDto>>
|
||||||
{
|
{
|
||||||
private readonly IProjectRepository _projectRepository;
|
private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
||||||
|
|
||||||
public GetStoriesByEpicIdQueryHandler(IProjectRepository projectRepository)
|
|
||||||
{
|
|
||||||
_projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<List<StoryDto>> Handle(GetStoriesByEpicIdQuery request, CancellationToken cancellationToken)
|
public async Task<List<StoryDto>> Handle(GetStoriesByEpicIdQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,14 +9,10 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Queries.GetStoriesByPro
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handler for GetStoriesByProjectIdQuery
|
/// Handler for GetStoriesByProjectIdQuery
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class GetStoriesByProjectIdQueryHandler : IRequestHandler<GetStoriesByProjectIdQuery, List<StoryDto>>
|
public sealed class GetStoriesByProjectIdQueryHandler(IProjectRepository projectRepository)
|
||||||
|
: IRequestHandler<GetStoriesByProjectIdQuery, List<StoryDto>>
|
||||||
{
|
{
|
||||||
private readonly IProjectRepository _projectRepository;
|
private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
||||||
|
|
||||||
public GetStoriesByProjectIdQueryHandler(IProjectRepository projectRepository)
|
|
||||||
{
|
|
||||||
_projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<List<StoryDto>> Handle(GetStoriesByProjectIdQuery request, CancellationToken cancellationToken)
|
public async Task<List<StoryDto>> Handle(GetStoriesByProjectIdQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,14 +9,10 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Queries.GetStoryById;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handler for GetStoryByIdQuery
|
/// Handler for GetStoryByIdQuery
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class GetStoryByIdQueryHandler : IRequestHandler<GetStoryByIdQuery, StoryDto>
|
public sealed class GetStoryByIdQueryHandler(IProjectRepository projectRepository)
|
||||||
|
: IRequestHandler<GetStoryByIdQuery, StoryDto>
|
||||||
{
|
{
|
||||||
private readonly IProjectRepository _projectRepository;
|
private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
||||||
|
|
||||||
public GetStoryByIdQueryHandler(IProjectRepository projectRepository)
|
|
||||||
{
|
|
||||||
_projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<StoryDto> Handle(GetStoryByIdQuery request, CancellationToken cancellationToken)
|
public async Task<StoryDto> Handle(GetStoryByIdQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,14 +10,10 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Queries.GetTaskById;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handler for GetTaskByIdQuery
|
/// Handler for GetTaskByIdQuery
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class GetTaskByIdQueryHandler : IRequestHandler<GetTaskByIdQuery, TaskDto>
|
public sealed class GetTaskByIdQueryHandler(IProjectRepository projectRepository)
|
||||||
|
: IRequestHandler<GetTaskByIdQuery, TaskDto>
|
||||||
{
|
{
|
||||||
private readonly IProjectRepository _projectRepository;
|
private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
||||||
|
|
||||||
public GetTaskByIdQueryHandler(IProjectRepository projectRepository)
|
|
||||||
{
|
|
||||||
_projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<TaskDto> Handle(GetTaskByIdQuery request, CancellationToken cancellationToken)
|
public async Task<TaskDto> Handle(GetTaskByIdQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,14 +7,10 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Queries.GetTasksByAssig
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handler for GetTasksByAssigneeQuery
|
/// Handler for GetTasksByAssigneeQuery
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class GetTasksByAssigneeQueryHandler : IRequestHandler<GetTasksByAssigneeQuery, List<TaskDto>>
|
public sealed class GetTasksByAssigneeQueryHandler(IProjectRepository projectRepository)
|
||||||
|
: IRequestHandler<GetTasksByAssigneeQuery, List<TaskDto>>
|
||||||
{
|
{
|
||||||
private readonly IProjectRepository _projectRepository;
|
private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
||||||
|
|
||||||
public GetTasksByAssigneeQueryHandler(IProjectRepository projectRepository)
|
|
||||||
{
|
|
||||||
_projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<List<TaskDto>> Handle(GetTasksByAssigneeQuery request, CancellationToken cancellationToken)
|
public async Task<List<TaskDto>> Handle(GetTasksByAssigneeQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,14 +9,10 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Queries.GetTasksByProje
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handler for GetTasksByProjectIdQuery
|
/// Handler for GetTasksByProjectIdQuery
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class GetTasksByProjectIdQueryHandler : IRequestHandler<GetTasksByProjectIdQuery, List<TaskDto>>
|
public sealed class GetTasksByProjectIdQueryHandler(IProjectRepository projectRepository)
|
||||||
|
: IRequestHandler<GetTasksByProjectIdQuery, List<TaskDto>>
|
||||||
{
|
{
|
||||||
private readonly IProjectRepository _projectRepository;
|
private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
||||||
|
|
||||||
public GetTasksByProjectIdQueryHandler(IProjectRepository projectRepository)
|
|
||||||
{
|
|
||||||
_projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<List<TaskDto>> Handle(GetTasksByProjectIdQuery request, CancellationToken cancellationToken)
|
public async Task<List<TaskDto>> Handle(GetTasksByProjectIdQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,14 +9,10 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Queries.GetTasksByStory
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handler for GetTasksByStoryIdQuery
|
/// Handler for GetTasksByStoryIdQuery
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class GetTasksByStoryIdQueryHandler : IRequestHandler<GetTasksByStoryIdQuery, List<TaskDto>>
|
public sealed class GetTasksByStoryIdQueryHandler(IProjectRepository projectRepository)
|
||||||
|
: IRequestHandler<GetTasksByStoryIdQuery, List<TaskDto>>
|
||||||
{
|
{
|
||||||
private readonly IProjectRepository _projectRepository;
|
private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
||||||
|
|
||||||
public GetTasksByStoryIdQueryHandler(IProjectRepository projectRepository)
|
|
||||||
{
|
|
||||||
_projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<List<TaskDto>> Handle(GetTasksByStoryIdQuery request, CancellationToken cancellationToken)
|
public async Task<List<TaskDto>> Handle(GetTasksByStoryIdQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,12 +7,8 @@ namespace ColaFlow.Modules.ProjectManagement.Infrastructure.Persistence;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Project Management Module DbContext
|
/// Project Management Module DbContext
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class PMDbContext : DbContext
|
public class PMDbContext(DbContextOptions<PMDbContext> options) : DbContext(options)
|
||||||
{
|
{
|
||||||
public PMDbContext(DbContextOptions<PMDbContext> options) : base(options)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public DbSet<Project> Projects => Set<Project>();
|
public DbSet<Project> Projects => Set<Project>();
|
||||||
public DbSet<Epic> Epics => Set<Epic>();
|
public DbSet<Epic> Epics => Set<Epic>();
|
||||||
public DbSet<Story> Stories => Set<Story>();
|
public DbSet<Story> Stories => Set<Story>();
|
||||||
|
|||||||
@@ -6,14 +6,9 @@ namespace ColaFlow.Modules.ProjectManagement.Infrastructure.Persistence;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Unit of Work implementation for ProjectManagement module
|
/// Unit of Work implementation for ProjectManagement module
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class UnitOfWork : IUnitOfWork
|
public class UnitOfWork(PMDbContext context) : IUnitOfWork
|
||||||
{
|
{
|
||||||
private readonly PMDbContext _context;
|
private readonly PMDbContext _context = context ?? throw new ArgumentNullException(nameof(context));
|
||||||
|
|
||||||
public UnitOfWork(PMDbContext context)
|
|
||||||
{
|
|
||||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,14 +9,9 @@ namespace ColaFlow.Modules.ProjectManagement.Infrastructure.Repositories;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Project repository implementation using EF Core
|
/// Project repository implementation using EF Core
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ProjectRepository : IProjectRepository
|
public class ProjectRepository(PMDbContext context) : IProjectRepository
|
||||||
{
|
{
|
||||||
private readonly PMDbContext _context;
|
private readonly PMDbContext _context = context ?? throw new ArgumentNullException(nameof(context));
|
||||||
|
|
||||||
public ProjectRepository(PMDbContext context)
|
|
||||||
{
|
|
||||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Project?> GetByIdAsync(ProjectId id, CancellationToken cancellationToken = default)
|
public async Task<Project?> GetByIdAsync(ProjectId id, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="MediatR.Contracts" Version="2.0.1" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.2.0" />
|
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.2.0" />
|
||||||
|
|||||||
@@ -3,18 +3,12 @@ namespace ColaFlow.Shared.Kernel.Common;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Base class for all entities
|
/// Base class for all entities
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public abstract class Entity
|
public abstract class Entity(Guid id)
|
||||||
{
|
{
|
||||||
public Guid Id { get; protected set; }
|
public Guid Id { get; protected set; } = id;
|
||||||
|
|
||||||
protected Entity()
|
protected Entity() : this(Guid.NewGuid())
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected Entity(Guid id)
|
|
||||||
{
|
|
||||||
Id = id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override bool Equals(object? obj)
|
public override bool Equals(object? obj)
|
||||||
|
|||||||
@@ -5,16 +5,10 @@ namespace ColaFlow.Shared.Kernel.Common;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Base class for creating type-safe enumerations
|
/// Base class for creating type-safe enumerations
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public abstract class Enumeration : IComparable
|
public abstract class Enumeration(int id, string name) : IComparable
|
||||||
{
|
{
|
||||||
public int Id { get; private set; }
|
public int Id { get; private set; } = id;
|
||||||
public string Name { get; private set; }
|
public string Name { get; private set; } = name;
|
||||||
|
|
||||||
protected Enumeration(int id, string name)
|
|
||||||
{
|
|
||||||
Id = id;
|
|
||||||
Name = name;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override string ToString() => Name;
|
public override string ToString() => Name;
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
namespace ColaFlow.Shared.Kernel.Events;
|
namespace ColaFlow.Shared.Kernel.Events;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Base class for all domain events
|
/// Base class for all domain events
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public abstract record DomainEvent
|
public abstract record DomainEvent : INotification
|
||||||
{
|
{
|
||||||
public Guid EventId { get; init; } = Guid.NewGuid();
|
public Guid EventId { get; init; } = Guid.NewGuid();
|
||||||
public DateTime OccurredOn { get; init; } = DateTime.UtcNow;
|
public DateTime OccurredOn { get; init; } = DateTime.UtcNow;
|
||||||
|
|||||||
103
colaflow-api/test-domain-events-clean.ps1
Normal file
103
colaflow-api/test-domain-events-clean.ps1
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# Test Domain Events Implementation
|
||||||
|
# This script tests that domain events are being raised and handled
|
||||||
|
|
||||||
|
$baseUrl = "http://localhost:5167"
|
||||||
|
$tenantSlug = "event-test-$(Get-Random -Minimum 1000 -Maximum 9999)"
|
||||||
|
|
||||||
|
Write-Host "=== Domain Events Test ===" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Test 1: Register Tenant (TenantCreatedEvent)
|
||||||
|
Write-Host "Test 1: Registering tenant (should trigger TenantCreatedEvent)..." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
$registerRequest = @{
|
||||||
|
tenantSlug = $tenantSlug
|
||||||
|
tenantName = "Event Test Tenant"
|
||||||
|
subscriptionPlan = "Free"
|
||||||
|
adminEmail = "admin@eventtest.com"
|
||||||
|
adminPassword = "Admin@123"
|
||||||
|
adminFullName = "Event Test Admin"
|
||||||
|
} | ConvertTo-Json
|
||||||
|
|
||||||
|
$registerResponse = Invoke-RestMethod -Uri "$baseUrl/api/tenants/register" `
|
||||||
|
-Method Post `
|
||||||
|
-ContentType "application/json" `
|
||||||
|
-Body $registerRequest
|
||||||
|
|
||||||
|
Write-Host "??? Tenant registered successfully" -ForegroundColor Green
|
||||||
|
Write-Host " Tenant ID: $($registerResponse.tenant.id)"
|
||||||
|
Write-Host " Admin User ID: $($registerResponse.adminUser.id)"
|
||||||
|
Write-Host " Check API console for log: 'Tenant {id} created with name Event Test Tenant...'" -ForegroundColor Magenta
|
||||||
|
Write-Host ""
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
|
||||||
|
# Test 2: Login (UserLoggedInEvent)
|
||||||
|
Write-Host "Test 2: Logging in (should trigger UserLoggedInEvent)..." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
$loginRequest = @{
|
||||||
|
tenantSlug = $tenantSlug
|
||||||
|
email = "admin@eventtest.com"
|
||||||
|
password = "Admin@123"
|
||||||
|
} | ConvertTo-Json
|
||||||
|
|
||||||
|
$loginResponse = Invoke-RestMethod -Uri "$baseUrl/api/auth/login" `
|
||||||
|
-Method Post `
|
||||||
|
-ContentType "application/json" `
|
||||||
|
-Body $loginRequest
|
||||||
|
|
||||||
|
$accessToken = $loginResponse.accessToken
|
||||||
|
$userId = $registerResponse.adminUser.id
|
||||||
|
$tenantId = $registerResponse.tenant.id
|
||||||
|
|
||||||
|
Write-Host "??? Login successful" -ForegroundColor Green
|
||||||
|
Write-Host " Access Token: $($accessToken.Substring(0, 20))..."
|
||||||
|
Write-Host " Check API console for log: 'User {$userId} logged in to tenant {$tenantId} from IP...'" -ForegroundColor Magenta
|
||||||
|
Write-Host ""
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
|
||||||
|
# Test 3: Assign Role (UserRoleAssignedEvent)
|
||||||
|
Write-Host "Test 3: Assigning role (should trigger UserRoleAssignedEvent)..." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
$assignRoleRequest = @{
|
||||||
|
role = "TenantAdmin"
|
||||||
|
} | ConvertTo-Json
|
||||||
|
|
||||||
|
$headers = @{
|
||||||
|
"Authorization" = "Bearer $accessToken"
|
||||||
|
"Content-Type" = "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$assignRoleResponse = Invoke-RestMethod -Uri "$baseUrl/api/tenants/$tenantId/users/$userId/role" `
|
||||||
|
-Method Post `
|
||||||
|
-Headers $headers `
|
||||||
|
-Body $assignRoleRequest
|
||||||
|
|
||||||
|
Write-Host "??? Role assigned successfully" -ForegroundColor Green
|
||||||
|
Write-Host " Check API console for log: 'User {$userId} assigned role TenantAdmin...'" -ForegroundColor Magenta
|
||||||
|
} catch {
|
||||||
|
Write-Host "??? Expected behavior: Role already TenantOwner" -ForegroundColor Yellow
|
||||||
|
Write-Host " Trying to update to TenantMember instead..."
|
||||||
|
|
||||||
|
$assignRoleRequest = @{
|
||||||
|
role = "TenantMember"
|
||||||
|
} | ConvertTo-Json
|
||||||
|
|
||||||
|
$assignRoleResponse = Invoke-RestMethod -Uri "$baseUrl/api/tenants/$tenantId/users/$userId/role" `
|
||||||
|
-Method Post `
|
||||||
|
-Headers $headers `
|
||||||
|
-Body $assignRoleRequest
|
||||||
|
|
||||||
|
Write-Host "??? Role updated successfully to TenantMember" -ForegroundColor Green
|
||||||
|
Write-Host " Check API console for log: 'User {$userId} assigned role TenantMember. Previous role: TenantOwner...'" -ForegroundColor Magenta
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "=== Test Complete ===" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Expected Logs in API Console:" -ForegroundColor Yellow
|
||||||
|
Write-Host " 1. Tenant {guid} created with name 'Event Test Tenant' and slug '$tenantSlug'"
|
||||||
|
Write-Host " 2. User {guid} logged in to tenant {guid} from IP 127.0.0.1 or ::1"
|
||||||
|
Write-Host " 3. User {guid} assigned role TenantMember in tenant {guid}. Previous role: TenantOwner. Assigned by: {guid}"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "If you see these logs in the API console, Domain Events are working correctly!" -ForegroundColor Green
|
||||||
103
colaflow-api/test-domain-events.ps1
Normal file
103
colaflow-api/test-domain-events.ps1
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# Test Domain Events Implementation
|
||||||
|
# This script tests that domain events are being raised and handled
|
||||||
|
|
||||||
|
$baseUrl = "http://localhost:5167"
|
||||||
|
$tenantSlug = "event-test-$(Get-Random -Minimum 1000 -Maximum 9999)"
|
||||||
|
|
||||||
|
Write-Host "=== Domain Events Test ===" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Test 1: Register Tenant (TenantCreatedEvent)
|
||||||
|
Write-Host "Test 1: Registering tenant (should trigger TenantCreatedEvent)..." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
$registerRequest = @{
|
||||||
|
tenantSlug = $tenantSlug
|
||||||
|
tenantName = "Event Test Tenant"
|
||||||
|
subscriptionPlan = "Free"
|
||||||
|
adminEmail = "admin@eventtest.com"
|
||||||
|
adminPassword = "Admin@123"
|
||||||
|
adminFullName = "Event Test Admin"
|
||||||
|
} | ConvertTo-Json
|
||||||
|
|
||||||
|
$registerResponse = Invoke-RestMethod -Uri "$baseUrl/api/tenants/register" `
|
||||||
|
-Method Post `
|
||||||
|
-ContentType "application/json" `
|
||||||
|
-Body $registerRequest
|
||||||
|
|
||||||
|
Write-Host "✓ Tenant registered successfully" -ForegroundColor Green
|
||||||
|
Write-Host " Tenant ID: $($registerResponse.tenant.id)"
|
||||||
|
Write-Host " Admin User ID: $($registerResponse.adminUser.id)"
|
||||||
|
Write-Host " Check API console for log: 'Tenant {id} created with name Event Test Tenant...'" -ForegroundColor Magenta
|
||||||
|
Write-Host ""
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
|
||||||
|
# Test 2: Login (UserLoggedInEvent)
|
||||||
|
Write-Host "Test 2: Logging in (should trigger UserLoggedInEvent)..." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
$loginRequest = @{
|
||||||
|
tenantSlug = $tenantSlug
|
||||||
|
email = "admin@eventtest.com"
|
||||||
|
password = "Admin@123"
|
||||||
|
} | ConvertTo-Json
|
||||||
|
|
||||||
|
$loginResponse = Invoke-RestMethod -Uri "$baseUrl/api/auth/login" `
|
||||||
|
-Method Post `
|
||||||
|
-ContentType "application/json" `
|
||||||
|
-Body $loginRequest
|
||||||
|
|
||||||
|
$accessToken = $loginResponse.accessToken
|
||||||
|
$userId = $registerResponse.adminUser.id
|
||||||
|
$tenantId = $registerResponse.tenant.id
|
||||||
|
|
||||||
|
Write-Host "✓ Login successful" -ForegroundColor Green
|
||||||
|
Write-Host " Access Token: $($accessToken.Substring(0, 20))..."
|
||||||
|
Write-Host " Check API console for log: 'User {$userId} logged in to tenant {$tenantId} from IP...'" -ForegroundColor Magenta
|
||||||
|
Write-Host ""
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
|
||||||
|
# Test 3: Assign Role (UserRoleAssignedEvent)
|
||||||
|
Write-Host "Test 3: Assigning role (should trigger UserRoleAssignedEvent)..." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
$assignRoleRequest = @{
|
||||||
|
role = "TenantAdmin"
|
||||||
|
} | ConvertTo-Json
|
||||||
|
|
||||||
|
$headers = @{
|
||||||
|
"Authorization" = "Bearer $accessToken"
|
||||||
|
"Content-Type" = "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$assignRoleResponse = Invoke-RestMethod -Uri "$baseUrl/api/tenants/$tenantId/users/$userId/role" `
|
||||||
|
-Method Post `
|
||||||
|
-Headers $headers `
|
||||||
|
-Body $assignRoleRequest
|
||||||
|
|
||||||
|
Write-Host "✓ Role assigned successfully" -ForegroundColor Green
|
||||||
|
Write-Host " Check API console for log: 'User {$userId} assigned role TenantAdmin...'" -ForegroundColor Magenta
|
||||||
|
} catch {
|
||||||
|
Write-Host "✓ Expected behavior: Role already TenantOwner" -ForegroundColor Yellow
|
||||||
|
Write-Host " Trying to update to TenantMember instead..."
|
||||||
|
|
||||||
|
$assignRoleRequest = @{
|
||||||
|
role = "TenantMember"
|
||||||
|
} | ConvertTo-Json
|
||||||
|
|
||||||
|
$assignRoleResponse = Invoke-RestMethod -Uri "$baseUrl/api/tenants/$tenantId/users/$userId/role" `
|
||||||
|
-Method Post `
|
||||||
|
-Headers $headers `
|
||||||
|
-Body $assignRoleRequest
|
||||||
|
|
||||||
|
Write-Host "✓ Role updated successfully to TenantMember" -ForegroundColor Green
|
||||||
|
Write-Host " Check API console for log: 'User {$userId} assigned role TenantMember. Previous role: TenantOwner...'" -ForegroundColor Magenta
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "=== Test Complete ===" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Expected Logs in API Console:" -ForegroundColor Yellow
|
||||||
|
Write-Host " 1. Tenant {guid} created with name 'Event Test Tenant' and slug '$tenantSlug'"
|
||||||
|
Write-Host " 2. User {guid} logged in to tenant {guid} from IP 127.0.0.1 or ::1"
|
||||||
|
Write-Host " 3. User {guid} assigned role TenantMember in tenant {guid}. Previous role: TenantOwner. Assigned by: {guid}"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "If you see these logs in the API console, Domain Events are working correctly!" -ForegroundColor Green
|
||||||
@@ -3,6 +3,7 @@ using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
|
|||||||
using ColaFlow.Modules.Identity.Infrastructure.Persistence;
|
using ColaFlow.Modules.Identity.Infrastructure.Persistence;
|
||||||
using ColaFlow.Modules.Identity.Infrastructure.Services;
|
using ColaFlow.Modules.Identity.Infrastructure.Services;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
|
using MediatR;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Moq;
|
using Moq;
|
||||||
|
|
||||||
@@ -11,6 +12,7 @@ namespace ColaFlow.Modules.Identity.Infrastructure.Tests.Persistence;
|
|||||||
public class GlobalQueryFilterTests : IDisposable
|
public class GlobalQueryFilterTests : IDisposable
|
||||||
{
|
{
|
||||||
private readonly Mock<ITenantContext> _mockTenantContext;
|
private readonly Mock<ITenantContext> _mockTenantContext;
|
||||||
|
private readonly Mock<IMediator> _mockMediator;
|
||||||
private readonly IdentityDbContext _context;
|
private readonly IdentityDbContext _context;
|
||||||
|
|
||||||
public GlobalQueryFilterTests()
|
public GlobalQueryFilterTests()
|
||||||
@@ -20,7 +22,8 @@ public class GlobalQueryFilterTests : IDisposable
|
|||||||
.Options;
|
.Options;
|
||||||
|
|
||||||
_mockTenantContext = new Mock<ITenantContext>();
|
_mockTenantContext = new Mock<ITenantContext>();
|
||||||
_context = new IdentityDbContext(options, _mockTenantContext.Object);
|
_mockMediator = new Mock<IMediator>();
|
||||||
|
_context = new IdentityDbContext(options, _mockTenantContext.Object, _mockMediator.Object);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -39,7 +42,8 @@ public class GlobalQueryFilterTests : IDisposable
|
|||||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||||
.Options;
|
.Options;
|
||||||
|
|
||||||
using var context = new IdentityDbContext(options, mockTenantContext.Object);
|
var mockMediator = new Mock<IMediator>();
|
||||||
|
using var context = new IdentityDbContext(options, mockTenantContext.Object, mockMediator.Object);
|
||||||
|
|
||||||
var user1 = User.CreateLocal(
|
var user1 = User.CreateLocal(
|
||||||
tenant1Id,
|
tenant1Id,
|
||||||
@@ -81,7 +85,8 @@ public class GlobalQueryFilterTests : IDisposable
|
|||||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||||
.Options;
|
.Options;
|
||||||
|
|
||||||
using var context = new IdentityDbContext(options, mockTenantContext.Object);
|
var mockMediator = new Mock<IMediator>();
|
||||||
|
using var context = new IdentityDbContext(options, mockTenantContext.Object, mockMediator.Object);
|
||||||
|
|
||||||
var user1 = User.CreateLocal(tenant1Id, Email.Create("admin@tenant1.com"), "pass", FullName.Create("Admin One"));
|
var user1 = User.CreateLocal(tenant1Id, Email.Create("admin@tenant1.com"), "pass", FullName.Create("Admin One"));
|
||||||
var user2 = User.CreateLocal(tenant2Id, Email.Create("admin@tenant2.com"), "pass", FullName.Create("Admin Two"));
|
var user2 = User.CreateLocal(tenant2Id, Email.Create("admin@tenant2.com"), "pass", FullName.Create("Admin Two"));
|
||||||
@@ -111,7 +116,8 @@ public class GlobalQueryFilterTests : IDisposable
|
|||||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||||
.Options;
|
.Options;
|
||||||
|
|
||||||
using var context = new IdentityDbContext(options, mockTenantContext.Object);
|
var mockMediator = new Mock<IMediator>();
|
||||||
|
using var context = new IdentityDbContext(options, mockTenantContext.Object, mockMediator.Object);
|
||||||
|
|
||||||
var user1 = User.CreateLocal(tenant1Id, Email.Create("user1@test.com"), "pass", FullName.Create("User One"));
|
var user1 = User.CreateLocal(tenant1Id, Email.Create("user1@test.com"), "pass", FullName.Create("User One"));
|
||||||
var user2 = User.CreateLocal(tenant2Id, Email.Create("user2@test.com"), "pass", FullName.Create("User Two"));
|
var user2 = User.CreateLocal(tenant2Id, Email.Create("user2@test.com"), "pass", FullName.Create("User Two"));
|
||||||
@@ -142,7 +148,8 @@ public class GlobalQueryFilterTests : IDisposable
|
|||||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||||
.Options;
|
.Options;
|
||||||
|
|
||||||
using var context = new IdentityDbContext(options, mockTenantContext.Object);
|
var mockMediator = new Mock<IMediator>();
|
||||||
|
using var context = new IdentityDbContext(options, mockTenantContext.Object, mockMediator.Object);
|
||||||
|
|
||||||
var user1 = User.CreateLocal(tenant1Id, Email.Create("john@tenant1.com"), "pass", FullName.Create("John Doe"));
|
var user1 = User.CreateLocal(tenant1Id, Email.Create("john@tenant1.com"), "pass", FullName.Create("John Doe"));
|
||||||
var user2 = User.CreateLocal(tenant2Id, Email.Create("jane@tenant2.com"), "pass", FullName.Create("Jane Doe"));
|
var user2 = User.CreateLocal(tenant2Id, Email.Create("jane@tenant2.com"), "pass", FullName.Create("Jane Doe"));
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using ColaFlow.Modules.Identity.Infrastructure.Persistence;
|
|||||||
using ColaFlow.Modules.Identity.Infrastructure.Persistence.Repositories;
|
using ColaFlow.Modules.Identity.Infrastructure.Persistence.Repositories;
|
||||||
using ColaFlow.Modules.Identity.Infrastructure.Services;
|
using ColaFlow.Modules.Identity.Infrastructure.Services;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
|
using MediatR;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Moq;
|
using Moq;
|
||||||
|
|
||||||
@@ -22,7 +23,9 @@ public class TenantRepositoryTests : IDisposable
|
|||||||
var mockTenantContext = new Mock<ITenantContext>();
|
var mockTenantContext = new Mock<ITenantContext>();
|
||||||
mockTenantContext.Setup(x => x.IsSet).Returns(false);
|
mockTenantContext.Setup(x => x.IsSet).Returns(false);
|
||||||
|
|
||||||
_context = new IdentityDbContext(options, mockTenantContext.Object);
|
var mockMediator = new Mock<IMediator>();
|
||||||
|
|
||||||
|
_context = new IdentityDbContext(options, mockTenantContext.Object, mockMediator.Object);
|
||||||
_repository = new TenantRepository(_context);
|
_repository = new TenantRepository(_context);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,14 +10,9 @@ namespace ColaFlow.Modules.Identity.IntegrationTests.Identity;
|
|||||||
/// Integration tests for basic Authentication functionality (Day 4 Regression Tests)
|
/// Integration tests for basic Authentication functionality (Day 4 Regression Tests)
|
||||||
/// Tests registration, login, password validation, and protected endpoints
|
/// Tests registration, login, password validation, and protected endpoints
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class AuthenticationTests : IClassFixture<DatabaseFixture>
|
public class AuthenticationTests(DatabaseFixture fixture) : IClassFixture<DatabaseFixture>
|
||||||
{
|
{
|
||||||
private readonly HttpClient _client;
|
private readonly HttpClient _client = fixture.Client;
|
||||||
|
|
||||||
public AuthenticationTests(DatabaseFixture fixture)
|
|
||||||
{
|
|
||||||
_client = fixture.Client;
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RegisterTenant_WithValidData_ShouldSucceed()
|
public async Task RegisterTenant_WithValidData_ShouldSucceed()
|
||||||
|
|||||||
@@ -11,14 +11,9 @@ namespace ColaFlow.Modules.Identity.IntegrationTests.Identity;
|
|||||||
/// Integration tests for Role-Based Access Control (RBAC) functionality (Day 5 - Phase 2)
|
/// Integration tests for Role-Based Access Control (RBAC) functionality (Day 5 - Phase 2)
|
||||||
/// Tests role assignment, JWT claims, and role persistence across authentication flows
|
/// Tests role assignment, JWT claims, and role persistence across authentication flows
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class RbacTests : IClassFixture<DatabaseFixture>
|
public class RbacTests(DatabaseFixture fixture) : IClassFixture<DatabaseFixture>
|
||||||
{
|
{
|
||||||
private readonly HttpClient _client;
|
private readonly HttpClient _client = fixture.Client;
|
||||||
|
|
||||||
public RbacTests(DatabaseFixture fixture)
|
|
||||||
{
|
|
||||||
_client = fixture.Client;
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RegisterTenant_ShouldAssignTenantOwnerRole()
|
public async Task RegisterTenant_ShouldAssignTenantOwnerRole()
|
||||||
|
|||||||
@@ -9,14 +9,9 @@ namespace ColaFlow.Modules.Identity.IntegrationTests.Identity;
|
|||||||
/// Integration tests for Refresh Token functionality (Day 5 - Phase 1)
|
/// Integration tests for Refresh Token functionality (Day 5 - Phase 1)
|
||||||
/// Tests token refresh flow, token rotation, and refresh token revocation
|
/// Tests token refresh flow, token rotation, and refresh token revocation
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class RefreshTokenTests : IClassFixture<DatabaseFixture>
|
public class RefreshTokenTests(DatabaseFixture fixture) : IClassFixture<DatabaseFixture>
|
||||||
{
|
{
|
||||||
private readonly HttpClient _client;
|
private readonly HttpClient _client = fixture.Client;
|
||||||
|
|
||||||
public RefreshTokenTests(DatabaseFixture fixture)
|
|
||||||
{
|
|
||||||
_client = fixture.Client;
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RegisterTenant_ShouldReturnAccessAndRefreshTokens()
|
public async Task RegisterTenant_ShouldReturnAccessAndRefreshTokens()
|
||||||
|
|||||||
@@ -12,14 +12,9 @@ namespace ColaFlow.Modules.Identity.IntegrationTests.Identity;
|
|||||||
/// Integration tests for Role Management API (Day 6)
|
/// Integration tests for Role Management API (Day 6)
|
||||||
/// Tests role assignment, user listing, user removal, and authorization policies
|
/// Tests role assignment, user listing, user removal, and authorization policies
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class RoleManagementTests : IClassFixture<DatabaseFixture>
|
public class RoleManagementTests(DatabaseFixture fixture) : IClassFixture<DatabaseFixture>
|
||||||
{
|
{
|
||||||
private readonly HttpClient _client;
|
private readonly HttpClient _client = fixture.Client;
|
||||||
|
|
||||||
public RoleManagementTests(DatabaseFixture fixture)
|
|
||||||
{
|
|
||||||
_client = fixture.Client;
|
|
||||||
}
|
|
||||||
|
|
||||||
#region Category 1: List Users Tests (3 tests)
|
#region Category 1: List Users Tests (3 tests)
|
||||||
|
|
||||||
|
|||||||
@@ -14,16 +14,10 @@ namespace ColaFlow.Modules.Identity.IntegrationTests.Infrastructure;
|
|||||||
/// Custom WebApplicationFactory for ColaFlow Integration Tests
|
/// Custom WebApplicationFactory for ColaFlow Integration Tests
|
||||||
/// Supports both In-Memory and Real PostgreSQL databases
|
/// Supports both In-Memory and Real PostgreSQL databases
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ColaFlowWebApplicationFactory : WebApplicationFactory<Program>
|
public class ColaFlowWebApplicationFactory(bool useInMemoryDatabase = true, string? testDatabaseName = null)
|
||||||
|
: WebApplicationFactory<Program>
|
||||||
{
|
{
|
||||||
private readonly bool _useInMemoryDatabase;
|
private readonly string? _testDatabaseName = testDatabaseName ?? $"TestDb_{Guid.NewGuid()}";
|
||||||
private readonly string? _testDatabaseName;
|
|
||||||
|
|
||||||
public ColaFlowWebApplicationFactory(bool useInMemoryDatabase = true, string? testDatabaseName = null)
|
|
||||||
{
|
|
||||||
_useInMemoryDatabase = useInMemoryDatabase;
|
|
||||||
_testDatabaseName = testDatabaseName ?? $"TestDb_{Guid.NewGuid()}";
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||||
{
|
{
|
||||||
@@ -52,7 +46,7 @@ public class ColaFlowWebApplicationFactory : WebApplicationFactory<Program>
|
|||||||
builder.ConfigureServices(services =>
|
builder.ConfigureServices(services =>
|
||||||
{
|
{
|
||||||
// Register test databases (modules won't register PostgreSQL due to Testing environment)
|
// Register test databases (modules won't register PostgreSQL due to Testing environment)
|
||||||
if (_useInMemoryDatabase)
|
if (useInMemoryDatabase)
|
||||||
{
|
{
|
||||||
// Use In-Memory Database for fast, isolated tests
|
// Use In-Memory Database for fast, isolated tests
|
||||||
// IMPORTANT: Share the same database name for cross-context data consistency
|
// IMPORTANT: Share the same database name for cross-context data consistency
|
||||||
@@ -101,7 +95,7 @@ public class ColaFlowWebApplicationFactory : WebApplicationFactory<Program>
|
|||||||
{
|
{
|
||||||
// Initialize Identity database
|
// Initialize Identity database
|
||||||
var identityDb = services.GetRequiredService<IdentityDbContext>();
|
var identityDb = services.GetRequiredService<IdentityDbContext>();
|
||||||
if (_useInMemoryDatabase)
|
if (useInMemoryDatabase)
|
||||||
{
|
{
|
||||||
identityDb.Database.EnsureCreated();
|
identityDb.Database.EnsureCreated();
|
||||||
}
|
}
|
||||||
@@ -112,7 +106,7 @@ public class ColaFlowWebApplicationFactory : WebApplicationFactory<Program>
|
|||||||
|
|
||||||
// Initialize ProjectManagement database
|
// Initialize ProjectManagement database
|
||||||
var pmDb = services.GetRequiredService<PMDbContext>();
|
var pmDb = services.GetRequiredService<PMDbContext>();
|
||||||
if (_useInMemoryDatabase)
|
if (useInMemoryDatabase)
|
||||||
{
|
{
|
||||||
pmDb.Database.EnsureCreated();
|
pmDb.Database.EnsureCreated();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,17 +7,13 @@ namespace ColaFlow.Modules.Identity.IntegrationTests.Infrastructure;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class DatabaseFixture : IDisposable
|
public class DatabaseFixture : IDisposable
|
||||||
{
|
{
|
||||||
public ColaFlowWebApplicationFactory Factory { get; }
|
public ColaFlowWebApplicationFactory Factory { get; } = new(useInMemoryDatabase: true);
|
||||||
|
|
||||||
// Note: Client property is kept for backward compatibility but creates new instances
|
// Note: Client property is kept for backward compatibility but creates new instances
|
||||||
// Tests should call CreateClient() for isolation to avoid shared state issues
|
// Tests should call CreateClient() for isolation to avoid shared state issues
|
||||||
public HttpClient Client => CreateClient();
|
public HttpClient Client => CreateClient();
|
||||||
|
|
||||||
public DatabaseFixture()
|
|
||||||
{
|
|
||||||
// Use In-Memory Database for fast, isolated tests
|
// Use In-Memory Database for fast, isolated tests
|
||||||
Factory = new ColaFlowWebApplicationFactory(useInMemoryDatabase: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a new HttpClient for each test to ensure test isolation
|
/// Creates a new HttpClient for each test to ensure test isolation
|
||||||
|
|||||||
Reference in New Issue
Block a user