feat(backend): Create Sprint 2 backend Stories and Tasks
Created detailed implementation plans for Sprint 2 backend work: Story 1: Audit Log Foundation (Phase 1) - Task 1: Design AuditLog database schema and create migration - Task 2: Create AuditLog entity and Repository - Task 3: Implement EF Core SaveChangesInterceptor - Task 4: Write unit tests for audit logging - Task 5: Integrate with ProjectManagement Module Story 2: Audit Log Core Features (Phase 2) - Task 1: Implement Changed Fields Detection (JSON Diff) - Task 2: Integrate User Context Tracking - Task 3: Add Multi-Tenant Isolation - Task 4: Implement Audit Query API - Task 5: Write Integration Tests Story 3: Sprint Management Module - Task 1: Create Sprint Aggregate Root and Domain Events - Task 2: Implement Sprint Repository and EF Core Configuration - Task 3: Create CQRS Commands and Queries - Task 4: Implement Burndown Chart Calculation - Task 5: Add SignalR Real-Time Notifications - Task 6: Write Integration Tests Total: 3 Stories, 16 Tasks, 24 Story Points (8+8+8) Estimated Duration: 10-12 days All tasks include: - Detailed technical implementation guidance - Code examples and file paths - Testing requirements (>= 90% coverage) - Performance benchmarks (< 5ms audit overhead) - Multi-tenant security validation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
77
docs/plans/sprint_2_story_1.md
Normal file
77
docs/plans/sprint_2_story_1.md
Normal file
@@ -0,0 +1,77 @@
|
||||
---
|
||||
story_id: sprint_2_story_1
|
||||
sprint: sprint_2
|
||||
priority: P0
|
||||
status: not_started
|
||||
story_points: 8
|
||||
estimated_days: 3-4
|
||||
created_date: 2025-11-05
|
||||
assignee: Backend Team
|
||||
---
|
||||
|
||||
# Story 1: Audit Log Foundation (Phase 1)
|
||||
|
||||
**Sprint**: Sprint 2
|
||||
**Priority**: P0 (Must Have)
|
||||
**Estimated**: 3-4 days (Day 23-26)
|
||||
**Owner**: Backend Team
|
||||
|
||||
## Description
|
||||
|
||||
Implement the foundation for audit logging system to support compliance and debugging requirements. This phase focuses on building the core infrastructure including database schema, EF Core interceptor, and basic tracking for Create/Update/Delete operations.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] AuditLog database table created with proper schema and indexes
|
||||
- [ ] EF Core SaveChangesInterceptor implemented for automatic audit logging
|
||||
- [ ] Basic CREATE/UPDATE/DELETE operations are automatically tracked
|
||||
- [ ] Unit tests with >= 90% coverage
|
||||
- [ ] Performance benchmark: < 5ms overhead per save operation
|
||||
- [ ] All tests passing
|
||||
|
||||
## Technical Requirements
|
||||
|
||||
**Database Schema**:
|
||||
- Table: `AuditLogs`
|
||||
- Columns: `Id`, `TenantId`, `EntityType`, `EntityId`, `Action`, `UserId`, `Timestamp`, `OldValues`, `NewValues`
|
||||
- Indexes: Composite index on `(TenantId, EntityType, EntityId)`, `Timestamp`, `UserId`
|
||||
- Storage: PostgreSQL JSONB for `OldValues`/`NewValues`
|
||||
|
||||
**Technology Stack**:
|
||||
- EF Core 9.0 SaveChangesInterceptor API
|
||||
- PostgreSQL JSONB
|
||||
- MediatR (optional for domain events)
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] [Task 1](sprint_2_story_1_task_1.md) - Design AuditLog database schema and create migration
|
||||
- [ ] [Task 2](sprint_2_story_1_task_2.md) - Create AuditLog entity and Repository
|
||||
- [ ] [Task 3](sprint_2_story_1_task_3.md) - Implement EF Core SaveChangesInterceptor
|
||||
- [ ] [Task 4](sprint_2_story_1_task_4.md) - Write unit tests for audit logging
|
||||
- [ ] [Task 5](sprint_2_story_1_task_5.md) - Integrate with ProjectManagement Module
|
||||
|
||||
**Progress**: 0/5 tasks completed
|
||||
|
||||
## Dependencies
|
||||
|
||||
**Prerequisites**:
|
||||
- ✅ ProjectManagement Module 95% Production Ready (Day 16)
|
||||
- ✅ Multi-Tenant Security Complete (Day 15)
|
||||
- ✅ EF Core 9.0 infrastructure
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- All 5 tasks completed
|
||||
- All tests passing (>= 90% coverage)
|
||||
- Performance benchmark met (< 5ms overhead)
|
||||
- Code reviewed and approved
|
||||
- Git commit created
|
||||
|
||||
## Notes
|
||||
|
||||
**Performance Target**: < 5ms overhead per SaveChanges operation
|
||||
**Scope**: Phase 1 focuses on foundation only - Changed Fields tracking will be in Story 2 (Phase 2)
|
||||
|
||||
---
|
||||
|
||||
**Created**: 2025-11-05 by Backend Agent
|
||||
69
docs/plans/sprint_2_story_1_task_1.md
Normal file
69
docs/plans/sprint_2_story_1_task_1.md
Normal file
@@ -0,0 +1,69 @@
|
||||
---
|
||||
task_id: sprint_2_story_1_task_1
|
||||
story: sprint_2_story_1
|
||||
status: not_started
|
||||
estimated_hours: 4
|
||||
created_date: 2025-11-05
|
||||
assignee: Backend Team
|
||||
---
|
||||
|
||||
# Task 1: Design AuditLog Database Schema and Create Migration
|
||||
|
||||
**Story**: Story 1 - Audit Log Foundation (Phase 1)
|
||||
**Estimated**: 4 hours
|
||||
|
||||
## Description
|
||||
|
||||
Design and implement the AuditLog database table schema with proper columns, data types, and indexes to support efficient audit querying and multi-tenant isolation.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Database migration created with AuditLog table
|
||||
- [ ] All required columns defined with correct data types
|
||||
- [ ] Composite indexes created for query optimization
|
||||
- [ ] Multi-tenant isolation enforced (TenantId column)
|
||||
- [ ] Migration applied successfully
|
||||
|
||||
## Implementation Details
|
||||
|
||||
**Table Schema**:
|
||||
```sql
|
||||
CREATE TABLE AuditLogs (
|
||||
Id UUID PRIMARY KEY,
|
||||
TenantId UUID NOT NULL,
|
||||
EntityType VARCHAR(100) NOT NULL,
|
||||
EntityId UUID NOT NULL,
|
||||
Action VARCHAR(20) NOT NULL, -- 'Create', 'Update', 'Delete'
|
||||
UserId UUID NULL,
|
||||
Timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
OldValues JSONB NULL,
|
||||
NewValues JSONB NULL,
|
||||
|
||||
CONSTRAINT FK_AuditLogs_Tenants FOREIGN KEY (TenantId) REFERENCES Tenants(Id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Indexes for query performance
|
||||
CREATE INDEX IX_AuditLogs_TenantId_EntityType_EntityId ON AuditLogs(TenantId, EntityType, EntityId);
|
||||
CREATE INDEX IX_AuditLogs_Timestamp ON AuditLogs(Timestamp DESC);
|
||||
CREATE INDEX IX_AuditLogs_UserId ON AuditLogs(UserId);
|
||||
```
|
||||
|
||||
**Files to Modify**:
|
||||
- Create: `colaflow-api/src/ColaFlow.Infrastructure/Data/Migrations/{timestamp}_AddAuditLogTable.cs`
|
||||
|
||||
## Technical Notes
|
||||
|
||||
- Use PostgreSQL JSONB for `OldValues`/`NewValues` (flexible schema, indexed queries)
|
||||
- Composite index on `(TenantId, EntityType, EntityId)` for efficient entity history queries
|
||||
- `Timestamp` index with DESC order for recent logs queries
|
||||
- Consider table partitioning for future scalability (Phase 4)
|
||||
|
||||
## Testing
|
||||
|
||||
- Verify migration runs without errors
|
||||
- Check indexes are created
|
||||
- Verify foreign key constraints work
|
||||
|
||||
---
|
||||
|
||||
**Created**: 2025-11-05 by Backend Agent
|
||||
122
docs/plans/sprint_2_story_1_task_2.md
Normal file
122
docs/plans/sprint_2_story_1_task_2.md
Normal file
@@ -0,0 +1,122 @@
|
||||
---
|
||||
task_id: sprint_2_story_1_task_2
|
||||
story: sprint_2_story_1
|
||||
status: not_started
|
||||
estimated_hours: 4
|
||||
created_date: 2025-11-05
|
||||
assignee: Backend Team
|
||||
---
|
||||
|
||||
# Task 2: Create AuditLog Entity and Repository
|
||||
|
||||
**Story**: Story 1 - Audit Log Foundation (Phase 1)
|
||||
**Estimated**: 4 hours
|
||||
|
||||
## Description
|
||||
|
||||
Create the AuditLog domain entity and repository pattern implementation to support CRUD operations and querying audit logs with proper multi-tenant isolation.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] AuditLog entity created in Domain layer
|
||||
- [ ] IAuditLogRepository interface defined
|
||||
- [ ] AuditLogRepository implementation with EF Core
|
||||
- [ ] Multi-tenant query filtering applied
|
||||
- [ ] Unit tests for repository methods
|
||||
|
||||
## Implementation Details
|
||||
|
||||
**Files to Create**:
|
||||
|
||||
1. **Domain Entity**: `colaflow-api/src/ColaFlow.Domain/Entities/AuditLog.cs`
|
||||
```csharp
|
||||
public class AuditLog
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid TenantId { get; set; }
|
||||
public string EntityType { get; set; } = string.Empty;
|
||||
public Guid EntityId { get; set; }
|
||||
public AuditAction Action { get; set; } // Enum: Create, Update, Delete
|
||||
public Guid? UserId { get; set; }
|
||||
public DateTime Timestamp { get; set; }
|
||||
public string? OldValues { get; set; } // JSONB stored as string
|
||||
public string? NewValues { get; set; } // JSONB stored as string
|
||||
}
|
||||
|
||||
public enum AuditAction
|
||||
{
|
||||
Create,
|
||||
Update,
|
||||
Delete
|
||||
}
|
||||
```
|
||||
|
||||
2. **Repository Interface**: `colaflow-api/src/ColaFlow.Domain/Repositories/IAuditLogRepository.cs`
|
||||
```csharp
|
||||
public interface IAuditLogRepository
|
||||
{
|
||||
Task<AuditLog?> GetByIdAsync(Guid id);
|
||||
Task<List<AuditLog>> GetByEntityAsync(string entityType, Guid entityId);
|
||||
Task AddAsync(AuditLog auditLog);
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
```
|
||||
|
||||
3. **Repository Implementation**: `colaflow-api/src/ColaFlow.Infrastructure/Repositories/AuditLogRepository.cs`
|
||||
```csharp
|
||||
public class AuditLogRepository : IAuditLogRepository
|
||||
{
|
||||
private readonly ColaFlowDbContext _context;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
|
||||
public AuditLogRepository(ColaFlowDbContext context, ITenantContext tenantContext)
|
||||
{
|
||||
_context = context;
|
||||
_tenantContext = tenantContext;
|
||||
}
|
||||
|
||||
public async Task<List<AuditLog>> GetByEntityAsync(string entityType, Guid entityId)
|
||||
{
|
||||
var tenantId = _tenantContext.TenantId;
|
||||
return await _context.AuditLogs
|
||||
.Where(a => a.TenantId == tenantId && a.EntityType == entityType && a.EntityId == entityId)
|
||||
.OrderByDescending(a => a.Timestamp)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
// ... other methods
|
||||
}
|
||||
```
|
||||
|
||||
4. **EF Core Configuration**: `colaflow-api/src/ColaFlow.Infrastructure/Data/Configurations/AuditLogConfiguration.cs`
|
||||
```csharp
|
||||
public class AuditLogConfiguration : IEntityTypeConfiguration<AuditLog>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<AuditLog> builder)
|
||||
{
|
||||
builder.ToTable("AuditLogs");
|
||||
builder.HasKey(a => a.Id);
|
||||
|
||||
builder.Property(a => a.EntityType).IsRequired().HasMaxLength(100);
|
||||
builder.Property(a => a.Action).IsRequired();
|
||||
builder.Property(a => a.Timestamp).IsRequired();
|
||||
|
||||
// JSONB columns
|
||||
builder.Property(a => a.OldValues).HasColumnType("jsonb");
|
||||
builder.Property(a => a.NewValues).HasColumnType("jsonb");
|
||||
|
||||
// Multi-tenant global query filter
|
||||
builder.HasQueryFilter(a => a.TenantId == Guid.Empty); // Will be replaced by TenantContext
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit tests for repository methods
|
||||
- Verify multi-tenant filtering works
|
||||
- Test JSONB serialization/deserialization
|
||||
|
||||
---
|
||||
|
||||
**Created**: 2025-11-05 by Backend Agent
|
||||
163
docs/plans/sprint_2_story_1_task_3.md
Normal file
163
docs/plans/sprint_2_story_1_task_3.md
Normal file
@@ -0,0 +1,163 @@
|
||||
---
|
||||
task_id: sprint_2_story_1_task_3
|
||||
story: sprint_2_story_1
|
||||
status: not_started
|
||||
estimated_hours: 6
|
||||
created_date: 2025-11-05
|
||||
assignee: Backend Team
|
||||
---
|
||||
|
||||
# Task 3: Implement EF Core SaveChangesInterceptor
|
||||
|
||||
**Story**: Story 1 - Audit Log Foundation (Phase 1)
|
||||
**Estimated**: 6 hours
|
||||
|
||||
## Description
|
||||
|
||||
Implement EF Core SaveChangesInterceptor to automatically capture and log all Create/Update/Delete operations on auditable entities. This is the core mechanism for transparent audit logging.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] AuditLogInterceptor class created
|
||||
- [ ] Automatic detection of Create/Update/Delete operations
|
||||
- [ ] Audit log entries created for each operation
|
||||
- [ ] TenantId and UserId automatically captured
|
||||
- [ ] Interceptor registered in DI container
|
||||
- [ ] Performance overhead < 5ms per SaveChanges
|
||||
|
||||
## Implementation Details
|
||||
|
||||
**Files to Create**:
|
||||
|
||||
1. **Interceptor**: `colaflow-api/src/ColaFlow.Infrastructure/Interceptors/AuditLogInterceptor.cs`
|
||||
```csharp
|
||||
public class AuditLogInterceptor : SaveChangesInterceptor
|
||||
{
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
|
||||
public AuditLogInterceptor(ITenantContext tenantContext, IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
_tenantContext = tenantContext;
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
}
|
||||
|
||||
public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
|
||||
DbContextEventData eventData,
|
||||
InterceptionResult<int> result,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (eventData.Context is null) return result;
|
||||
|
||||
var auditEntries = CreateAuditEntries(eventData.Context);
|
||||
|
||||
// Add audit logs to context
|
||||
foreach (var auditEntry in auditEntries)
|
||||
{
|
||||
eventData.Context.Add(auditEntry);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<AuditLog> CreateAuditEntries(DbContext context)
|
||||
{
|
||||
var auditLogs = new List<AuditLog>();
|
||||
var entries = context.ChangeTracker.Entries()
|
||||
.Where(e => e.State == EntityState.Added ||
|
||||
e.State == EntityState.Modified ||
|
||||
e.State == EntityState.Deleted)
|
||||
.Where(e => IsAuditable(e.Entity))
|
||||
.ToList();
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
var auditLog = new AuditLog
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantContext.TenantId,
|
||||
EntityType = entry.Entity.GetType().Name,
|
||||
EntityId = GetEntityId(entry),
|
||||
Action = entry.State switch
|
||||
{
|
||||
EntityState.Added => AuditAction.Create,
|
||||
EntityState.Modified => AuditAction.Update,
|
||||
EntityState.Deleted => AuditAction.Delete,
|
||||
_ => throw new InvalidOperationException()
|
||||
},
|
||||
UserId = GetCurrentUserId(),
|
||||
Timestamp = DateTime.UtcNow,
|
||||
OldValues = entry.State == EntityState.Modified ? SerializeOldValues(entry) : null,
|
||||
NewValues = entry.State != EntityState.Deleted ? SerializeNewValues(entry) : null
|
||||
};
|
||||
|
||||
auditLogs.Add(auditLog);
|
||||
}
|
||||
|
||||
return auditLogs;
|
||||
}
|
||||
|
||||
private bool IsAuditable(object entity)
|
||||
{
|
||||
// Check if entity implements IAuditable interface or is in auditable types list
|
||||
return entity is Project || entity is Epic || entity is Story || entity is WorkTask;
|
||||
}
|
||||
|
||||
private Guid GetEntityId(EntityEntry entry)
|
||||
{
|
||||
var keyValue = entry.Properties.FirstOrDefault(p => p.Metadata.IsPrimaryKey())?.CurrentValue;
|
||||
return keyValue is Guid id ? id : Guid.Empty;
|
||||
}
|
||||
|
||||
private Guid? GetCurrentUserId()
|
||||
{
|
||||
var userIdClaim = _httpContextAccessor.HttpContext?.User?.FindFirst(ClaimTypes.NameIdentifier);
|
||||
return userIdClaim != null ? Guid.Parse(userIdClaim.Value) : null;
|
||||
}
|
||||
|
||||
private string? SerializeOldValues(EntityEntry entry)
|
||||
{
|
||||
var oldValues = entry.Properties
|
||||
.Where(p => p.IsModified)
|
||||
.ToDictionary(p => p.Metadata.Name, p => p.OriginalValue);
|
||||
return JsonSerializer.Serialize(oldValues);
|
||||
}
|
||||
|
||||
private string? SerializeNewValues(EntityEntry entry)
|
||||
{
|
||||
var newValues = entry.Properties
|
||||
.Where(p => !p.Metadata.IsPrimaryKey())
|
||||
.ToDictionary(p => p.Metadata.Name, p => p.CurrentValue);
|
||||
return JsonSerializer.Serialize(newValues);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **DI Registration**: Update `colaflow-api/src/ColaFlow.Infrastructure/DependencyInjection.cs`
|
||||
```csharp
|
||||
services.AddDbContext<ColaFlowDbContext>((serviceProvider, options) =>
|
||||
{
|
||||
options.UseNpgsql(connectionString);
|
||||
options.AddInterceptors(serviceProvider.GetRequiredService<AuditLogInterceptor>());
|
||||
});
|
||||
|
||||
services.AddScoped<AuditLogInterceptor>();
|
||||
```
|
||||
|
||||
## Technical Notes
|
||||
|
||||
- Phase 1 captures basic Create/Update/Delete operations
|
||||
- Changed Fields tracking (old vs new values diff) will be enhanced in Phase 2
|
||||
- Performance optimization: Consider async operations and batching
|
||||
- Use System.Text.Json for serialization (faster than Newtonsoft.Json)
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit tests for interceptor logic
|
||||
- Integration tests for automatic audit logging
|
||||
- Performance benchmark: < 5ms overhead
|
||||
- Verify TenantId and UserId are captured correctly
|
||||
|
||||
---
|
||||
|
||||
**Created**: 2025-11-05 by Backend Agent
|
||||
175
docs/plans/sprint_2_story_1_task_4.md
Normal file
175
docs/plans/sprint_2_story_1_task_4.md
Normal file
@@ -0,0 +1,175 @@
|
||||
---
|
||||
task_id: sprint_2_story_1_task_4
|
||||
story: sprint_2_story_1
|
||||
status: not_started
|
||||
estimated_hours: 4
|
||||
created_date: 2025-11-05
|
||||
assignee: Backend Team
|
||||
---
|
||||
|
||||
# Task 4: Write Unit Tests for Audit Logging
|
||||
|
||||
**Story**: Story 1 - Audit Log Foundation (Phase 1)
|
||||
**Estimated**: 4 hours
|
||||
|
||||
## Description
|
||||
|
||||
Create comprehensive unit tests for audit logging functionality to ensure correctness, multi-tenant isolation, and performance. Target >= 90% code coverage.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Unit tests for AuditLogRepository created
|
||||
- [ ] Unit tests for AuditLogInterceptor created
|
||||
- [ ] Test coverage >= 90%
|
||||
- [ ] All tests passing
|
||||
- [ ] Performance tests verify < 5ms overhead
|
||||
|
||||
## Implementation Details
|
||||
|
||||
**Files to Create**:
|
||||
|
||||
1. **Repository Tests**: `colaflow-api/tests/ColaFlow.Infrastructure.Tests/Repositories/AuditLogRepositoryTests.cs`
|
||||
```csharp
|
||||
public class AuditLogRepositoryTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetByEntityAsync_ShouldReturnAuditLogs_WhenEntityExists()
|
||||
{
|
||||
// Arrange
|
||||
var tenantId = Guid.NewGuid();
|
||||
var entityId = Guid.NewGuid();
|
||||
var options = CreateDbContextOptions();
|
||||
|
||||
await using var context = new ColaFlowDbContext(options);
|
||||
var tenantContext = new Mock<ITenantContext>();
|
||||
tenantContext.Setup(t => t.TenantId).Returns(tenantId);
|
||||
|
||||
var auditLog = new AuditLog
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
EntityType = "Project",
|
||||
EntityId = entityId,
|
||||
Action = AuditAction.Create,
|
||||
Timestamp = DateTime.UtcNow
|
||||
};
|
||||
context.AuditLogs.Add(auditLog);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var repository = new AuditLogRepository(context, tenantContext.Object);
|
||||
|
||||
// Act
|
||||
var result = await repository.GetByEntityAsync("Project", entityId);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Equal(entityId, result[0].EntityId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByEntityAsync_ShouldNotReturnOtherTenantsLogs()
|
||||
{
|
||||
// Test multi-tenant isolation
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **Interceptor Tests**: `colaflow-api/tests/ColaFlow.Infrastructure.Tests/Interceptors/AuditLogInterceptorTests.cs`
|
||||
```csharp
|
||||
public class AuditLogInterceptorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SavingChangesAsync_ShouldCreateAuditLog_WhenEntityCreated()
|
||||
{
|
||||
// Arrange
|
||||
var tenantId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
var options = CreateDbContextOptions();
|
||||
|
||||
var tenantContext = new Mock<ITenantContext>();
|
||||
tenantContext.Setup(t => t.TenantId).Returns(tenantId);
|
||||
|
||||
var httpContextAccessor = CreateMockHttpContextAccessor(userId);
|
||||
|
||||
var interceptor = new AuditLogInterceptor(tenantContext.Object, httpContextAccessor);
|
||||
|
||||
await using var context = new ColaFlowDbContext(options);
|
||||
context.AddInterceptors(interceptor);
|
||||
|
||||
var project = new Project
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
Name = "Test Project",
|
||||
Key = "TEST"
|
||||
};
|
||||
|
||||
// Act
|
||||
context.Projects.Add(project);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
// Assert
|
||||
var auditLogs = await context.AuditLogs.ToListAsync();
|
||||
Assert.Single(auditLogs);
|
||||
Assert.Equal("Project", auditLogs[0].EntityType);
|
||||
Assert.Equal(AuditAction.Create, auditLogs[0].Action);
|
||||
Assert.Equal(userId, auditLogs[0].UserId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SavingChangesAsync_ShouldCaptureOldAndNewValues_WhenEntityUpdated()
|
||||
{
|
||||
// Test old vs new values capture
|
||||
// ...
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SavingChangesAsync_ShouldHaveLowPerformanceOverhead()
|
||||
{
|
||||
// Arrange
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
// Act
|
||||
// Create 100 entities and measure time
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
context.Projects.Add(new Project { /* ... */ });
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
// Assert
|
||||
var avgTime = stopwatch.ElapsedMilliseconds / 100.0;
|
||||
Assert.True(avgTime < 5, $"Average time {avgTime}ms exceeds 5ms target");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Test Cases to Cover**:
|
||||
1. Create operation audit logging
|
||||
2. Update operation audit logging
|
||||
3. Delete operation audit logging
|
||||
4. Multi-tenant isolation (audit logs filtered by TenantId)
|
||||
5. UserId capture from HTTP context
|
||||
6. Old/New values serialization
|
||||
7. Performance overhead < 5ms
|
||||
8. Null userId handling (system operations)
|
||||
|
||||
## Technical Notes
|
||||
|
||||
- Use in-memory database for fast unit tests
|
||||
- Mock ITenantContext and IHttpContextAccessor
|
||||
- Use Stopwatch for performance benchmarks
|
||||
- Target >= 90% code coverage
|
||||
|
||||
## Testing
|
||||
|
||||
- Run: `dotnet test --filter "FullyQualifiedName~AuditLog"`
|
||||
- Verify all tests pass
|
||||
- Check code coverage report
|
||||
|
||||
---
|
||||
|
||||
**Created**: 2025-11-05 by Backend Agent
|
||||
154
docs/plans/sprint_2_story_1_task_5.md
Normal file
154
docs/plans/sprint_2_story_1_task_5.md
Normal file
@@ -0,0 +1,154 @@
|
||||
---
|
||||
task_id: sprint_2_story_1_task_5
|
||||
story: sprint_2_story_1
|
||||
status: not_started
|
||||
estimated_hours: 3
|
||||
created_date: 2025-11-05
|
||||
assignee: Backend Team
|
||||
---
|
||||
|
||||
# Task 5: Integrate with ProjectManagement Module
|
||||
|
||||
**Story**: Story 1 - Audit Log Foundation (Phase 1)
|
||||
**Estimated**: 3 hours
|
||||
|
||||
## Description
|
||||
|
||||
Integrate the audit logging infrastructure with the existing ProjectManagement Module to ensure all Epic/Story/Task operations are automatically audited.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Audit logging works for Project CRUD operations
|
||||
- [ ] Audit logging works for Epic CRUD operations
|
||||
- [ ] Audit logging works for Story CRUD operations
|
||||
- [ ] Audit logging works for Task CRUD operations
|
||||
- [ ] Integration tests verify audit logs are created
|
||||
- [ ] No performance degradation (< 5ms overhead)
|
||||
|
||||
## Implementation Details
|
||||
|
||||
**No Code Changes Required** - The EF Core Interceptor automatically captures all changes!
|
||||
|
||||
**Verification Steps**:
|
||||
|
||||
1. **Run Integration Tests**:
|
||||
```bash
|
||||
cd colaflow-api
|
||||
dotnet test --filter "FullyQualifiedName~ProjectManagement.IntegrationTests"
|
||||
```
|
||||
|
||||
2. **Verify Audit Logs Created**:
|
||||
- Create a Project → Check AuditLog table for "Create" entry
|
||||
- Update an Epic → Check AuditLog table for "Update" entry
|
||||
- Delete a Story → Check AuditLog table for "Delete" entry
|
||||
|
||||
3. **Performance Benchmark**:
|
||||
```csharp
|
||||
// Add to integration tests
|
||||
[Fact]
|
||||
public async Task CreateProject_ShouldHaveMinimalPerformanceOverhead()
|
||||
{
|
||||
// Measure time with audit logging enabled
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
await Mediator.Send(new CreateProjectCommand { /* ... */ });
|
||||
stopwatch.Stop();
|
||||
|
||||
Assert.True(stopwatch.ElapsedMilliseconds < 100, "Project creation took too long");
|
||||
}
|
||||
```
|
||||
|
||||
**Files to Update**:
|
||||
|
||||
1. **Integration Test Verification**: `colaflow-api/tests/ColaFlow.Application.IntegrationTests/ProjectManagement/AuditLogIntegrationTests.cs`
|
||||
```csharp
|
||||
public class AuditLogIntegrationTests : IntegrationTestBase
|
||||
{
|
||||
[Fact]
|
||||
public async Task CreateProject_ShouldCreateAuditLog()
|
||||
{
|
||||
// Arrange
|
||||
var command = new CreateProjectCommand
|
||||
{
|
||||
Name = "Test Project",
|
||||
Key = "TEST",
|
||||
Description = "Test Description"
|
||||
};
|
||||
|
||||
// Act
|
||||
var projectId = await Mediator.Send(command);
|
||||
|
||||
// Assert
|
||||
var auditLogs = await Context.AuditLogs
|
||||
.Where(a => a.EntityType == "Project" && a.EntityId == projectId)
|
||||
.ToListAsync();
|
||||
|
||||
Assert.Single(auditLogs);
|
||||
Assert.Equal(AuditAction.Create, auditLogs[0].Action);
|
||||
Assert.Equal(TenantId, auditLogs[0].TenantId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateEpic_ShouldCreateAuditLog_WithOldAndNewValues()
|
||||
{
|
||||
// Create Epic first
|
||||
var epicId = await CreateTestEpic();
|
||||
|
||||
// Update Epic
|
||||
await Mediator.Send(new UpdateEpicCommand
|
||||
{
|
||||
EpicId = epicId,
|
||||
Title = "Updated Title"
|
||||
});
|
||||
|
||||
// Assert audit log created
|
||||
var auditLogs = await Context.AuditLogs
|
||||
.Where(a => a.EntityType == "Epic" && a.EntityId == epicId && a.Action == AuditAction.Update)
|
||||
.ToListAsync();
|
||||
|
||||
Assert.Single(auditLogs);
|
||||
Assert.NotNull(auditLogs[0].OldValues);
|
||||
Assert.NotNull(auditLogs[0].NewValues);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteTask_ShouldCreateAuditLog()
|
||||
{
|
||||
// Test delete operation audit logging
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Technical Notes
|
||||
|
||||
- The SaveChangesInterceptor automatically captures all EF Core operations
|
||||
- No changes to Command Handlers required
|
||||
- Verify multi-tenant isolation works correctly
|
||||
- Check audit logs in database after each operation
|
||||
|
||||
## Testing
|
||||
|
||||
**Integration Tests to Create**:
|
||||
1. Project Create/Update/Delete audit logging
|
||||
2. Epic Create/Update/Delete audit logging
|
||||
3. Story Create/Update/Delete audit logging
|
||||
4. Task Create/Update/Delete audit logging
|
||||
5. Multi-tenant isolation verification
|
||||
6. Performance overhead verification (< 5ms)
|
||||
|
||||
**Run Tests**:
|
||||
```bash
|
||||
dotnet test --filter "FullyQualifiedName~AuditLogIntegration"
|
||||
```
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- All 4 entity types (Project/Epic/Story/Task) are audited
|
||||
- Integration tests verify audit logs are created
|
||||
- Multi-tenant isolation verified
|
||||
- Performance benchmark met (< 5ms overhead)
|
||||
- Git commit created
|
||||
|
||||
---
|
||||
|
||||
**Created**: 2025-11-05 by Backend Agent
|
||||
82
docs/plans/sprint_2_story_2.md
Normal file
82
docs/plans/sprint_2_story_2.md
Normal file
@@ -0,0 +1,82 @@
|
||||
---
|
||||
story_id: sprint_2_story_2
|
||||
sprint: sprint_2
|
||||
priority: P0
|
||||
status: not_started
|
||||
story_points: 8
|
||||
estimated_days: 3-4
|
||||
created_date: 2025-11-05
|
||||
assignee: Backend Team
|
||||
---
|
||||
|
||||
# Story 2: Audit Log Core Features (Phase 2)
|
||||
|
||||
**Sprint**: Sprint 2
|
||||
**Priority**: P0 (Must Have)
|
||||
**Estimated**: 3-4 days (Day 27-30)
|
||||
**Owner**: Backend Team
|
||||
|
||||
## Description
|
||||
|
||||
Enhance audit logging with core features including changed fields detection (old vs new values JSON diff), user context tracking, multi-tenant isolation, query API, and integration tests.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Changed fields tracking implemented with JSON diff
|
||||
- [ ] User context (UserId) automatically captured
|
||||
- [ ] Multi-tenant isolation for audit logs enforced
|
||||
- [ ] Query API implemented for retrieving audit history
|
||||
- [ ] Integration tests with >= 90% coverage
|
||||
- [ ] Performance target met (< 5ms overhead)
|
||||
- [ ] All tests passing
|
||||
|
||||
## Technical Requirements
|
||||
|
||||
**Enhanced Features**:
|
||||
- Changed Fields Detection: Compare old vs new values, store only changed fields
|
||||
- User Context: Automatically capture current user from HTTP context
|
||||
- Multi-Tenant Isolation: Filter audit logs by TenantId
|
||||
- Query API: REST endpoints for audit history retrieval
|
||||
|
||||
**Technology Stack**:
|
||||
- System.Text.Json for JSON diffing
|
||||
- PostgreSQL JSONB for flexible storage
|
||||
- CQRS pattern for query handlers
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] [Task 1](sprint_2_story_2_task_1.md) - Implement Changed Fields Detection (JSON Diff)
|
||||
- [ ] [Task 2](sprint_2_story_2_task_2.md) - Integrate User Context Tracking
|
||||
- [ ] [Task 3](sprint_2_story_2_task_3.md) - Add Multi-Tenant Isolation
|
||||
- [ ] [Task 4](sprint_2_story_2_task_4.md) - Implement Audit Query API
|
||||
- [ ] [Task 5](sprint_2_story_2_task_5.md) - Write Integration Tests
|
||||
|
||||
**Progress**: 0/5 tasks completed
|
||||
|
||||
## Dependencies
|
||||
|
||||
**Prerequisites**:
|
||||
- ✅ Story 1 completed (Audit Log Foundation)
|
||||
- ✅ EF Core Interceptor implemented
|
||||
- ✅ AuditLog entity and repository ready
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- All 5 tasks completed
|
||||
- Changed fields detection working correctly
|
||||
- User context captured from HTTP context
|
||||
- Multi-tenant isolation verified
|
||||
- Query API endpoints working
|
||||
- All tests passing (>= 90% coverage)
|
||||
- Performance benchmark met (< 5ms overhead)
|
||||
- Code reviewed and approved
|
||||
- Git commit created
|
||||
|
||||
## Notes
|
||||
|
||||
**Performance Target**: < 5ms overhead per SaveChanges operation
|
||||
**JSON Diff**: Store only changed fields, not full entity snapshots (storage optimization)
|
||||
|
||||
---
|
||||
|
||||
**Created**: 2025-11-05 by Backend Agent
|
||||
191
docs/plans/sprint_2_story_2_task_1.md
Normal file
191
docs/plans/sprint_2_story_2_task_1.md
Normal file
@@ -0,0 +1,191 @@
|
||||
---
|
||||
task_id: sprint_2_story_2_task_1
|
||||
story: sprint_2_story_2
|
||||
status: not_started
|
||||
estimated_hours: 6
|
||||
created_date: 2025-11-05
|
||||
assignee: Backend Team
|
||||
---
|
||||
|
||||
# Task 1: Implement Changed Fields Detection (JSON Diff)
|
||||
|
||||
**Story**: Story 2 - Audit Log Core Features (Phase 2)
|
||||
**Estimated**: 6 hours
|
||||
|
||||
## Description
|
||||
|
||||
Enhance the audit logging to detect and store only the changed fields (JSON diff) instead of full entity snapshots. This optimizes storage and makes audit logs more readable.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] JSON diff algorithm implemented
|
||||
- [ ] Only changed fields stored in OldValues/NewValues
|
||||
- [ ] Nested object changes detected
|
||||
- [ ] Unit tests for diff algorithm
|
||||
- [ ] Storage size reduced by 50-70%
|
||||
|
||||
## Implementation Details
|
||||
|
||||
**Files to Update**:
|
||||
|
||||
1. **Diff Service**: `colaflow-api/src/ColaFlow.Infrastructure/Services/JsonDiffService.cs`
|
||||
```csharp
|
||||
public class JsonDiffService : IJsonDiffService
|
||||
{
|
||||
public ChangedFields GetChangedFields(EntityEntry entry)
|
||||
{
|
||||
var changedFields = new Dictionary<string, FieldChange>();
|
||||
|
||||
foreach (var property in entry.Properties.Where(p => p.IsModified && !p.Metadata.IsPrimaryKey()))
|
||||
{
|
||||
changedFields[property.Metadata.Name] = new FieldChange
|
||||
{
|
||||
OldValue = property.OriginalValue,
|
||||
NewValue = property.CurrentValue
|
||||
};
|
||||
}
|
||||
|
||||
return new ChangedFields { Fields = changedFields };
|
||||
}
|
||||
|
||||
public string SerializeChangedFields(ChangedFields changes)
|
||||
{
|
||||
return JsonSerializer.Serialize(changes, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public class ChangedFields
|
||||
{
|
||||
public Dictionary<string, FieldChange> Fields { get; set; } = new();
|
||||
}
|
||||
|
||||
public class FieldChange
|
||||
{
|
||||
public object? OldValue { get; set; }
|
||||
public object? NewValue { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
2. **Update Interceptor**: `colaflow-api/src/ColaFlow.Infrastructure/Interceptors/AuditLogInterceptor.cs`
|
||||
```csharp
|
||||
public class AuditLogInterceptor : SaveChangesInterceptor
|
||||
{
|
||||
private readonly IJsonDiffService _diffService;
|
||||
|
||||
public AuditLogInterceptor(
|
||||
ITenantContext tenantContext,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
IJsonDiffService diffService)
|
||||
{
|
||||
_tenantContext = tenantContext;
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
_diffService = diffService;
|
||||
}
|
||||
|
||||
private List<AuditLog> CreateAuditEntries(DbContext context)
|
||||
{
|
||||
// ... existing code ...
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
string? oldValues = null;
|
||||
string? newValues = null;
|
||||
|
||||
if (entry.State == EntityState.Modified)
|
||||
{
|
||||
// Use diff service to get only changed fields
|
||||
var changes = _diffService.GetChangedFields(entry);
|
||||
var diffJson = _diffService.SerializeChangedFields(changes);
|
||||
|
||||
// Store diff in both OldValues and NewValues
|
||||
// OldValues: { "Title": { "OldValue": "Old", "NewValue": "New" } }
|
||||
oldValues = diffJson;
|
||||
newValues = diffJson;
|
||||
}
|
||||
else if (entry.State == EntityState.Added)
|
||||
{
|
||||
// For Create, store all current values
|
||||
newValues = SerializeAllFields(entry);
|
||||
}
|
||||
else if (entry.State == EntityState.Deleted)
|
||||
{
|
||||
// For Delete, store all original values
|
||||
oldValues = SerializeAllFields(entry);
|
||||
}
|
||||
|
||||
var auditLog = new AuditLog
|
||||
{
|
||||
// ... existing fields ...
|
||||
OldValues = oldValues,
|
||||
NewValues = newValues
|
||||
};
|
||||
|
||||
auditLogs.Add(auditLog);
|
||||
}
|
||||
|
||||
return auditLogs;
|
||||
}
|
||||
|
||||
private string SerializeAllFields(EntityEntry entry)
|
||||
{
|
||||
var allFields = entry.Properties
|
||||
.Where(p => !p.Metadata.IsPrimaryKey())
|
||||
.ToDictionary(p => p.Metadata.Name, p => p.CurrentValue);
|
||||
return JsonSerializer.Serialize(allFields);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Example Output**:
|
||||
```json
|
||||
// Before (Full Snapshot - 500 bytes):
|
||||
{
|
||||
"OldValues": {"Id":"abc","Title":"Old Title","Description":"Long description...","Status":"InProgress","Priority":1},
|
||||
"NewValues": {"Id":"abc","Title":"New Title","Description":"Long description...","Status":"InProgress","Priority":1}
|
||||
}
|
||||
|
||||
// After (Diff Only - 80 bytes, 84% reduction):
|
||||
{
|
||||
"OldValues": {"Title":{"OldValue":"Old Title","NewValue":"New Title"}},
|
||||
"NewValues": {"Title":{"OldValue":"Old Title","NewValue":"New Title"}}
|
||||
}
|
||||
```
|
||||
|
||||
## Technical Notes
|
||||
|
||||
- Use System.Text.Json for performance
|
||||
- Store diff in both OldValues and NewValues for query flexibility
|
||||
- Consider nested object changes (e.g., Address.City)
|
||||
- Ignore computed properties and navigation properties
|
||||
|
||||
## Testing
|
||||
|
||||
**Unit Tests**:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void GetChangedFields_ShouldReturnOnlyModifiedFields()
|
||||
{
|
||||
// Arrange
|
||||
var entry = CreateMockEntry(
|
||||
original: new { Title = "Old", Status = "InProgress" },
|
||||
current: new { Title = "New", Status = "InProgress" }
|
||||
);
|
||||
|
||||
// Act
|
||||
var changes = _diffService.GetChangedFields(entry);
|
||||
|
||||
// Assert
|
||||
Assert.Single(changes.Fields);
|
||||
Assert.True(changes.Fields.ContainsKey("Title"));
|
||||
Assert.Equal("Old", changes.Fields["Title"].OldValue);
|
||||
Assert.Equal("New", changes.Fields["Title"].NewValue);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Created**: 2025-11-05 by Backend Agent
|
||||
162
docs/plans/sprint_2_story_2_task_2.md
Normal file
162
docs/plans/sprint_2_story_2_task_2.md
Normal file
@@ -0,0 +1,162 @@
|
||||
---
|
||||
task_id: sprint_2_story_2_task_2
|
||||
story: sprint_2_story_2
|
||||
status: not_started
|
||||
estimated_hours: 3
|
||||
created_date: 2025-11-05
|
||||
assignee: Backend Team
|
||||
---
|
||||
|
||||
# Task 2: Integrate User Context Tracking
|
||||
|
||||
**Story**: Story 2 - Audit Log Core Features (Phase 2)
|
||||
**Estimated**: 3 hours
|
||||
|
||||
## Description
|
||||
|
||||
Enhance audit logging to automatically capture the current user (UserId) from HTTP context for every operation. This provides accountability and traceability.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] UserId automatically captured from JWT token
|
||||
- [ ] System operations (null user) handled correctly
|
||||
- [ ] User information enriched in audit logs
|
||||
- [ ] Integration tests verify user tracking
|
||||
- [ ] Performance not impacted
|
||||
|
||||
## Implementation Details
|
||||
|
||||
**Already Implemented in Story 1!**
|
||||
|
||||
The `AuditLogInterceptor` already captures UserId from HTTP context:
|
||||
|
||||
```csharp
|
||||
private Guid? GetCurrentUserId()
|
||||
{
|
||||
var userIdClaim = _httpContextAccessor.HttpContext?.User?.FindFirst(ClaimTypes.NameIdentifier);
|
||||
return userIdClaim != null ? Guid.Parse(userIdClaim.Value) : null;
|
||||
}
|
||||
```
|
||||
|
||||
**This Task: Add User Information Enrichment**
|
||||
|
||||
1. **Add User Navigation Property**: `colaflow-api/src/ColaFlow.Domain/Entities/AuditLog.cs`
|
||||
```csharp
|
||||
public class AuditLog
|
||||
{
|
||||
// ... existing properties ...
|
||||
public Guid? UserId { get; set; }
|
||||
|
||||
// Navigation property
|
||||
public User? User { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
2. **Update EF Configuration**: `colaflow-api/src/ColaFlow.Infrastructure/Data/Configurations/AuditLogConfiguration.cs`
|
||||
```csharp
|
||||
public void Configure(EntityTypeBuilder<AuditLog> builder)
|
||||
{
|
||||
// ... existing configuration ...
|
||||
|
||||
// User relationship (optional foreign key)
|
||||
builder.HasOne(a => a.User)
|
||||
.WithMany()
|
||||
.HasForeignKey(a => a.UserId)
|
||||
.OnDelete(DeleteBehavior.SetNull); // Don't delete audit logs when user is deleted
|
||||
}
|
||||
```
|
||||
|
||||
3. **Enrich Query Results**: `colaflow-api/src/ColaFlow.Infrastructure/Repositories/AuditLogRepository.cs`
|
||||
```csharp
|
||||
public async Task<List<AuditLog>> GetByEntityAsync(string entityType, Guid entityId)
|
||||
{
|
||||
var tenantId = _tenantContext.TenantId;
|
||||
return await _context.AuditLogs
|
||||
.Include(a => a.User) // Include user info
|
||||
.Where(a => a.TenantId == tenantId && a.EntityType == entityType && a.EntityId == entityId)
|
||||
.OrderByDescending(a => a.Timestamp)
|
||||
.ToListAsync();
|
||||
}
|
||||
```
|
||||
|
||||
4. **Handle System Operations**: Update `AuditLogInterceptor` to handle null users gracefully:
|
||||
```csharp
|
||||
private Guid? GetCurrentUserId()
|
||||
{
|
||||
try
|
||||
{
|
||||
var userIdClaim = _httpContextAccessor.HttpContext?.User?.FindFirst(ClaimTypes.NameIdentifier);
|
||||
if (userIdClaim != null && Guid.TryParse(userIdClaim.Value, out var userId))
|
||||
{
|
||||
return userId;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to get current user ID for audit log");
|
||||
}
|
||||
|
||||
return null; // System operation or anonymous
|
||||
}
|
||||
```
|
||||
|
||||
**Example Audit Log with User Info**:
|
||||
```json
|
||||
{
|
||||
"Id": "abc-123",
|
||||
"EntityType": "Project",
|
||||
"Action": "Update",
|
||||
"UserId": "user-456",
|
||||
"User": {
|
||||
"Id": "user-456",
|
||||
"UserName": "john.doe@example.com",
|
||||
"DisplayName": "John Doe"
|
||||
},
|
||||
"Timestamp": "2025-11-05T10:30:00Z",
|
||||
"ChangedFields": {
|
||||
"Title": { "OldValue": "Old", "NewValue": "New" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Technical Notes
|
||||
|
||||
- Use `OnDelete(DeleteBehavior.SetNull)` to preserve audit logs when users are deleted
|
||||
- Handle null users gracefully (system operations, background jobs)
|
||||
- Use `Include(a => a.User)` for enriched query results
|
||||
- Consider caching user info for performance (future optimization)
|
||||
|
||||
## Testing
|
||||
|
||||
**Integration Tests**:
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task CreateProject_ShouldCaptureCurrentUser()
|
||||
{
|
||||
// Arrange
|
||||
var userId = Guid.NewGuid();
|
||||
SetCurrentUser(userId); // Helper to set HTTP context user
|
||||
|
||||
// Act
|
||||
var projectId = await Mediator.Send(new CreateProjectCommand { /* ... */ });
|
||||
|
||||
// Assert
|
||||
var auditLog = await Context.AuditLogs
|
||||
.Include(a => a.User)
|
||||
.FirstAsync(a => a.EntityId == projectId);
|
||||
|
||||
Assert.Equal(userId, auditLog.UserId);
|
||||
Assert.NotNull(auditLog.User);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SystemOperation_ShouldHaveNullUser()
|
||||
{
|
||||
// Test background job or system operation
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Created**: 2025-11-05 by Backend Agent
|
||||
201
docs/plans/sprint_2_story_2_task_3.md
Normal file
201
docs/plans/sprint_2_story_2_task_3.md
Normal file
@@ -0,0 +1,201 @@
|
||||
---
|
||||
task_id: sprint_2_story_2_task_3
|
||||
story: sprint_2_story_2
|
||||
status: not_started
|
||||
estimated_hours: 3
|
||||
created_date: 2025-11-05
|
||||
assignee: Backend Team
|
||||
---
|
||||
|
||||
# Task 3: Add Multi-Tenant Isolation
|
||||
|
||||
**Story**: Story 2 - Audit Log Core Features (Phase 2)
|
||||
**Estimated**: 3 hours
|
||||
|
||||
## Description
|
||||
|
||||
Ensure audit logs are properly isolated by TenantId to prevent cross-tenant data access. Implement global query filters and verify with comprehensive tests.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Global query filter applied to AuditLog entity
|
||||
- [ ] TenantId automatically set on audit log creation
|
||||
- [ ] Cross-tenant queries blocked
|
||||
- [ ] Integration tests verify isolation
|
||||
- [ ] Security audit passed
|
||||
|
||||
## Implementation Details
|
||||
|
||||
**Already Implemented in Story 1!**
|
||||
|
||||
The `AuditLogInterceptor` already sets TenantId:
|
||||
```csharp
|
||||
var auditLog = new AuditLog
|
||||
{
|
||||
TenantId = _tenantContext.TenantId,
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
**This Task: Add Global Query Filter and Comprehensive Testing**
|
||||
|
||||
1. **Update EF Configuration**: `colaflow-api/src/ColaFlow.Infrastructure/Data/Configurations/AuditLogConfiguration.cs`
|
||||
```csharp
|
||||
public void Configure(EntityTypeBuilder<AuditLog> builder)
|
||||
{
|
||||
// ... existing configuration ...
|
||||
|
||||
// Multi-tenant global query filter
|
||||
// This will be dynamically replaced by TenantContext at runtime
|
||||
builder.HasQueryFilter(a => a.TenantId == Guid.Empty);
|
||||
}
|
||||
```
|
||||
|
||||
2. **Apply Global Filter in DbContext**: `colaflow-api/src/ColaFlow.Infrastructure/Data/ColaFlowDbContext.cs`
|
||||
```csharp
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
// Apply tenant filter to AuditLog
|
||||
var tenantId = _tenantContext.TenantId;
|
||||
modelBuilder.Entity<AuditLog>().HasQueryFilter(a => a.TenantId == tenantId);
|
||||
}
|
||||
```
|
||||
|
||||
3. **Verify Repository Filtering**: `colaflow-api/src/ColaFlow.Infrastructure/Repositories/AuditLogRepository.cs`
|
||||
```csharp
|
||||
public async Task<List<AuditLog>> GetByEntityAsync(string entityType, Guid entityId)
|
||||
{
|
||||
var tenantId = _tenantContext.TenantId;
|
||||
|
||||
// Query automatically filtered by global query filter
|
||||
// Explicit TenantId check added for defense-in-depth
|
||||
return await _context.AuditLogs
|
||||
.Where(a => a.TenantId == tenantId && a.EntityType == entityType && a.EntityId == entityId)
|
||||
.OrderByDescending(a => a.Timestamp)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
}
|
||||
```
|
||||
|
||||
**Defense-in-Depth Strategy**:
|
||||
1. **Layer 1**: TenantId automatically set by Interceptor
|
||||
2. **Layer 2**: Global Query Filter in EF Core
|
||||
3. **Layer 3**: Explicit TenantId checks in Repository
|
||||
4. **Layer 4**: Integration tests verify isolation
|
||||
|
||||
## Testing
|
||||
|
||||
**Integration Tests**: `colaflow-api/tests/ColaFlow.Application.IntegrationTests/AuditLog/MultiTenantIsolationTests.cs`
|
||||
|
||||
```csharp
|
||||
public class AuditLogMultiTenantIsolationTests : IntegrationTestBase
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetByEntityAsync_ShouldOnlyReturnCurrentTenantAuditLogs()
|
||||
{
|
||||
// Arrange - Create audit logs for two tenants
|
||||
var tenant1Id = Guid.NewGuid();
|
||||
var tenant2Id = Guid.NewGuid();
|
||||
var entityId = Guid.NewGuid();
|
||||
|
||||
// Tenant 1 creates a project
|
||||
SetCurrentTenant(tenant1Id);
|
||||
var log1 = new AuditLog
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenant1Id,
|
||||
EntityType = "Project",
|
||||
EntityId = entityId,
|
||||
Action = AuditAction.Create,
|
||||
Timestamp = DateTime.UtcNow
|
||||
};
|
||||
await Context.AuditLogs.AddAsync(log1);
|
||||
await Context.SaveChangesAsync();
|
||||
|
||||
// Tenant 2 tries to access (should fail)
|
||||
SetCurrentTenant(tenant2Id);
|
||||
|
||||
var repository = new AuditLogRepository(Context, TenantContext);
|
||||
|
||||
// Act
|
||||
var result = await repository.GetByEntityAsync("Project", entityId);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result); // Tenant 2 should NOT see Tenant 1's audit logs
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateProject_ShouldSetTenantIdAutomatically()
|
||||
{
|
||||
// Arrange
|
||||
var tenantId = Guid.NewGuid();
|
||||
SetCurrentTenant(tenantId);
|
||||
|
||||
// Act
|
||||
var projectId = await Mediator.Send(new CreateProjectCommand
|
||||
{
|
||||
Name = "Test Project",
|
||||
Key = "TEST"
|
||||
});
|
||||
|
||||
// Assert
|
||||
var auditLogs = await Context.AuditLogs
|
||||
.IgnoreQueryFilters() // Bypass filter to check actual data
|
||||
.Where(a => a.EntityId == projectId)
|
||||
.ToListAsync();
|
||||
|
||||
Assert.All(auditLogs, log => Assert.Equal(tenantId, log.TenantId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DirectDatabaseQuery_ShouldRespectGlobalQueryFilter()
|
||||
{
|
||||
// Arrange
|
||||
var tenant1Id = Guid.NewGuid();
|
||||
var tenant2Id = Guid.NewGuid();
|
||||
|
||||
// Create logs for both tenants (using IgnoreQueryFilters)
|
||||
await Context.AuditLogs.AddAsync(new AuditLog { Id = Guid.NewGuid(), TenantId = tenant1Id, /* ... */ });
|
||||
await Context.AuditLogs.AddAsync(new AuditLog { Id = Guid.NewGuid(), TenantId = tenant2Id, /* ... */ });
|
||||
await Context.SaveChangesAsync();
|
||||
|
||||
// Act - Set current tenant to tenant1
|
||||
SetCurrentTenant(tenant1Id);
|
||||
var logs = await Context.AuditLogs.ToListAsync();
|
||||
|
||||
// Assert - Should only see tenant1 logs
|
||||
Assert.All(logs, log => Assert.Equal(tenant1Id, log.TenantId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BypassFilter_ShouldWorkWithIgnoreQueryFilters()
|
||||
{
|
||||
// For admin/system operations that need to see all tenants
|
||||
var allLogs = await Context.AuditLogs
|
||||
.IgnoreQueryFilters()
|
||||
.ToListAsync();
|
||||
|
||||
Assert.True(allLogs.Count > 0);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Security Test Cases**:
|
||||
1. Cross-tenant read blocked
|
||||
2. TenantId automatically set on creation
|
||||
3. Global query filter applied
|
||||
4. IgnoreQueryFilters works for admin operations
|
||||
5. Repository explicit filtering works
|
||||
|
||||
## Technical Notes
|
||||
|
||||
- Use `IgnoreQueryFilters()` for admin/system operations only
|
||||
- Always combine global filter with explicit TenantId checks (defense-in-depth)
|
||||
- Test with real database, not in-memory (to verify SQL generation)
|
||||
- Run security tests in CI/CD pipeline
|
||||
|
||||
---
|
||||
|
||||
**Created**: 2025-11-05 by Backend Agent
|
||||
246
docs/plans/sprint_2_story_2_task_4.md
Normal file
246
docs/plans/sprint_2_story_2_task_4.md
Normal file
@@ -0,0 +1,246 @@
|
||||
---
|
||||
task_id: sprint_2_story_2_task_4
|
||||
story: sprint_2_story_2
|
||||
status: not_started
|
||||
estimated_hours: 5
|
||||
created_date: 2025-11-05
|
||||
assignee: Backend Team
|
||||
---
|
||||
|
||||
# Task 4: Implement Audit Query API
|
||||
|
||||
**Story**: Story 2 - Audit Log Core Features (Phase 2)
|
||||
**Estimated**: 5 hours
|
||||
|
||||
## Description
|
||||
|
||||
Create REST API endpoints to query audit logs with CQRS pattern. Support filtering by entity type, entity ID, date range, and user.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] GetEntityAuditHistoryQuery implemented
|
||||
- [ ] GetAuditLogByIdQuery implemented
|
||||
- [ ] AuditLogsController with 2 endpoints created
|
||||
- [ ] Query handlers with proper filtering
|
||||
- [ ] Swagger documentation added
|
||||
- [ ] Integration tests for API endpoints
|
||||
|
||||
## Implementation Details
|
||||
|
||||
**Files to Create**:
|
||||
|
||||
1. **Query DTOs**: `colaflow-api/src/ColaFlow.Application/AuditLog/Queries/GetEntityAuditHistory/GetEntityAuditHistoryQuery.cs`
|
||||
```csharp
|
||||
public record GetEntityAuditHistoryQuery : IRequest<List<AuditLogDto>>
|
||||
{
|
||||
public string EntityType { get; init; } = string.Empty;
|
||||
public Guid EntityId { get; init; }
|
||||
public DateTime? FromDate { get; init; }
|
||||
public DateTime? ToDate { get; init; }
|
||||
public Guid? UserId { get; init; }
|
||||
public int? Limit { get; init; } = 50; // Default limit
|
||||
}
|
||||
|
||||
public class AuditLogDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string EntityType { get; set; } = string.Empty;
|
||||
public Guid EntityId { get; set; }
|
||||
public string Action { get; set; } = string.Empty;
|
||||
public Guid? UserId { get; set; }
|
||||
public string? UserName { get; set; }
|
||||
public DateTime Timestamp { get; set; }
|
||||
public Dictionary<string, FieldChangeDto>? ChangedFields { get; set; }
|
||||
}
|
||||
|
||||
public class FieldChangeDto
|
||||
{
|
||||
public object? OldValue { get; set; }
|
||||
public object? NewValue { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
2. **Query Handler**: `colaflow-api/src/ColaFlow.Application/AuditLog/Queries/GetEntityAuditHistory/GetEntityAuditHistoryQueryHandler.cs`
|
||||
```csharp
|
||||
public class GetEntityAuditHistoryQueryHandler : IRequestHandler<GetEntityAuditHistoryQuery, List<AuditLogDto>>
|
||||
{
|
||||
private readonly IAuditLogRepository _repository;
|
||||
private readonly IMapper _mapper;
|
||||
|
||||
public GetEntityAuditHistoryQueryHandler(IAuditLogRepository repository, IMapper mapper)
|
||||
{
|
||||
_repository = repository;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
public async Task<List<AuditLogDto>> Handle(GetEntityAuditHistoryQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var auditLogs = await _repository.GetByEntityAsync(request.EntityType, request.EntityId);
|
||||
|
||||
// Apply filters
|
||||
var filtered = auditLogs.AsQueryable();
|
||||
|
||||
if (request.FromDate.HasValue)
|
||||
filtered = filtered.Where(a => a.Timestamp >= request.FromDate.Value);
|
||||
|
||||
if (request.ToDate.HasValue)
|
||||
filtered = filtered.Where(a => a.Timestamp <= request.ToDate.Value);
|
||||
|
||||
if (request.UserId.HasValue)
|
||||
filtered = filtered.Where(a => a.UserId == request.UserId.Value);
|
||||
|
||||
if (request.Limit.HasValue)
|
||||
filtered = filtered.Take(request.Limit.Value);
|
||||
|
||||
var result = filtered.ToList();
|
||||
|
||||
return _mapper.Map<List<AuditLogDto>>(result);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **Controller**: `colaflow-api/src/ColaFlow.API/Controllers/AuditLogsController.cs`
|
||||
```csharp
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize]
|
||||
public class AuditLogsController : ControllerBase
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
|
||||
public AuditLogsController(IMediator mediator)
|
||||
{
|
||||
_mediator = mediator;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get audit history for a specific entity
|
||||
/// </summary>
|
||||
/// <param name="entityType">Entity type (e.g., "Project", "Epic", "Story")</param>
|
||||
/// <param name="entityId">Entity ID</param>
|
||||
/// <param name="fromDate">Optional start date filter</param>
|
||||
/// <param name="toDate">Optional end date filter</param>
|
||||
/// <param name="userId">Optional user filter</param>
|
||||
/// <param name="limit">Optional limit (default 50)</param>
|
||||
[HttpGet("entity/{entityType}/{entityId}")]
|
||||
[ProducesResponseType(typeof(List<AuditLogDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetEntityAuditHistory(
|
||||
[FromRoute] string entityType,
|
||||
[FromRoute] Guid entityId,
|
||||
[FromQuery] DateTime? fromDate = null,
|
||||
[FromQuery] DateTime? toDate = null,
|
||||
[FromQuery] Guid? userId = null,
|
||||
[FromQuery] int? limit = 50)
|
||||
{
|
||||
var query = new GetEntityAuditHistoryQuery
|
||||
{
|
||||
EntityType = entityType,
|
||||
EntityId = entityId,
|
||||
FromDate = fromDate,
|
||||
ToDate = toDate,
|
||||
UserId = userId,
|
||||
Limit = limit
|
||||
};
|
||||
|
||||
var result = await _mediator.Send(query);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get a specific audit log by ID
|
||||
/// </summary>
|
||||
[HttpGet("{id}")]
|
||||
[ProducesResponseType(typeof(AuditLogDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetAuditLogById([FromRoute] Guid id)
|
||||
{
|
||||
var query = new GetAuditLogByIdQuery { AuditLogId = id };
|
||||
var result = await _mediator.Send(query);
|
||||
|
||||
if (result == null)
|
||||
return NotFound();
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. **AutoMapper Profile**: `colaflow-api/src/ColaFlow.Application/AuditLog/MappingProfiles/AuditLogProfile.cs`
|
||||
```csharp
|
||||
public class AuditLogProfile : Profile
|
||||
{
|
||||
public AuditLogProfile()
|
||||
{
|
||||
CreateMap<AuditLog, AuditLogDto>()
|
||||
.ForMember(dest => dest.UserName, opt => opt.MapFrom(src => src.User != null ? src.User.UserName : null))
|
||||
.ForMember(dest => dest.ChangedFields, opt => opt.MapFrom(src => DeserializeChangedFields(src.NewValues)));
|
||||
}
|
||||
|
||||
private Dictionary<string, FieldChangeDto>? DeserializeChangedFields(string? json)
|
||||
{
|
||||
if (string.IsNullOrEmpty(json)) return null;
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<Dictionary<string, FieldChangeDto>>(json);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Example API Requests**:
|
||||
```bash
|
||||
# Get audit history for a project
|
||||
GET /api/auditlogs/entity/Project/abc-123?limit=20
|
||||
|
||||
# Get audit logs for last 7 days
|
||||
GET /api/auditlogs/entity/Epic/def-456?fromDate=2025-10-29
|
||||
|
||||
# Get audit logs by specific user
|
||||
GET /api/auditlogs/entity/Story/ghi-789?userId=user-123
|
||||
|
||||
# Get specific audit log
|
||||
GET /api/auditlogs/abc-123
|
||||
```
|
||||
|
||||
## Technical Notes
|
||||
|
||||
- Use `AsNoTracking()` for read-only queries (performance)
|
||||
- Implement pagination for large result sets
|
||||
- Cache frequently accessed audit logs (Redis - future)
|
||||
- Add rate limiting to prevent abuse
|
||||
|
||||
## Testing
|
||||
|
||||
**Integration Tests**:
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task GetEntityAuditHistory_ShouldReturnAuditLogs()
|
||||
{
|
||||
// Arrange
|
||||
var projectId = await CreateTestProject();
|
||||
|
||||
// Act
|
||||
var response = await Client.GetAsync($"/api/auditlogs/entity/Project/{projectId}");
|
||||
|
||||
// Assert
|
||||
response.EnsureSuccessStatusCode();
|
||||
var logs = await response.Content.ReadFromJsonAsync<List<AuditLogDto>>();
|
||||
Assert.NotEmpty(logs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEntityAuditHistory_ShouldFilterByDateRange()
|
||||
{
|
||||
// Test date range filtering
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Created**: 2025-11-05 by Backend Agent
|
||||
341
docs/plans/sprint_2_story_2_task_5.md
Normal file
341
docs/plans/sprint_2_story_2_task_5.md
Normal file
@@ -0,0 +1,341 @@
|
||||
---
|
||||
task_id: sprint_2_story_2_task_5
|
||||
story: sprint_2_story_2
|
||||
status: not_started
|
||||
estimated_hours: 5
|
||||
created_date: 2025-11-05
|
||||
assignee: Backend Team
|
||||
---
|
||||
|
||||
# Task 5: Write Integration Tests
|
||||
|
||||
**Story**: Story 2 - Audit Log Core Features (Phase 2)
|
||||
**Estimated**: 5 hours
|
||||
|
||||
## Description
|
||||
|
||||
Create comprehensive integration tests for all audit log features including changed fields tracking, user context, multi-tenant isolation, and query API. Target >= 90% code coverage.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Integration tests for changed fields detection
|
||||
- [ ] Integration tests for user context tracking
|
||||
- [ ] Integration tests for multi-tenant isolation
|
||||
- [ ] Integration tests for query API endpoints
|
||||
- [ ] Test coverage >= 90%
|
||||
- [ ] All tests passing
|
||||
- [ ] Performance tests verify < 5ms overhead
|
||||
|
||||
## Implementation Details
|
||||
|
||||
**Files to Create**:
|
||||
|
||||
1. **Integration Test Base**: `colaflow-api/tests/ColaFlow.Application.IntegrationTests/AuditLog/AuditLogIntegrationTestBase.cs`
|
||||
```csharp
|
||||
public class AuditLogIntegrationTestBase : IntegrationTestBase
|
||||
{
|
||||
protected async Task<Guid> CreateTestProjectAsync(string name = "Test Project")
|
||||
{
|
||||
var command = new CreateProjectCommand
|
||||
{
|
||||
Name = name,
|
||||
Key = name.ToUpper().Replace(" ", ""),
|
||||
Description = "Test Description"
|
||||
};
|
||||
|
||||
return await Mediator.Send(command);
|
||||
}
|
||||
|
||||
protected async Task<Guid> CreateTestEpicAsync(Guid projectId, string title = "Test Epic")
|
||||
{
|
||||
var command = new CreateEpicCommand
|
||||
{
|
||||
ProjectId = projectId,
|
||||
Title = title,
|
||||
Description = "Test Epic Description"
|
||||
};
|
||||
|
||||
return await Mediator.Send(command);
|
||||
}
|
||||
|
||||
protected async Task<List<AuditLog>> GetAuditLogsForEntityAsync(string entityType, Guid entityId)
|
||||
{
|
||||
return await Context.AuditLogs
|
||||
.Include(a => a.User)
|
||||
.Where(a => a.EntityType == entityType && a.EntityId == entityId)
|
||||
.OrderByDescending(a => a.Timestamp)
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **Changed Fields Tests**: `colaflow-api/tests/ColaFlow.Application.IntegrationTests/AuditLog/ChangedFieldsTests.cs`
|
||||
```csharp
|
||||
public class ChangedFieldsTests : AuditLogIntegrationTestBase
|
||||
{
|
||||
[Fact]
|
||||
public async Task UpdateProject_ShouldLogOnlyChangedFields()
|
||||
{
|
||||
// Arrange
|
||||
var projectId = await CreateTestProjectAsync("Original Name");
|
||||
|
||||
// Act - Update only the name
|
||||
await Mediator.Send(new UpdateProjectCommand
|
||||
{
|
||||
ProjectId = projectId,
|
||||
Name = "Updated Name"
|
||||
});
|
||||
|
||||
// Assert
|
||||
var auditLogs = await GetAuditLogsForEntityAsync("Project", projectId);
|
||||
var updateLog = auditLogs.First(a => a.Action == AuditAction.Update);
|
||||
|
||||
Assert.NotNull(updateLog.NewValues);
|
||||
|
||||
// Deserialize and verify only Name field was changed
|
||||
var changedFields = JsonSerializer.Deserialize<Dictionary<string, FieldChangeDto>>(updateLog.NewValues);
|
||||
Assert.NotNull(changedFields);
|
||||
Assert.Single(changedFields); // Only one field changed
|
||||
Assert.True(changedFields.ContainsKey("Name"));
|
||||
Assert.Equal("Original Name", changedFields["Name"].OldValue?.ToString());
|
||||
Assert.Equal("Updated Name", changedFields["Name"].NewValue?.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateMultipleFields_ShouldLogAllChangedFields()
|
||||
{
|
||||
// Arrange
|
||||
var projectId = await CreateTestProjectAsync();
|
||||
|
||||
// Act - Update name and description
|
||||
await Mediator.Send(new UpdateProjectCommand
|
||||
{
|
||||
ProjectId = projectId,
|
||||
Name = "New Name",
|
||||
Description = "New Description"
|
||||
});
|
||||
|
||||
// Assert
|
||||
var auditLogs = await GetAuditLogsForEntityAsync("Project", projectId);
|
||||
var updateLog = auditLogs.First(a => a.Action == AuditAction.Update);
|
||||
|
||||
var changedFields = JsonSerializer.Deserialize<Dictionary<string, FieldChangeDto>>(updateLog.NewValues);
|
||||
Assert.Equal(2, changedFields.Count); // Two fields changed
|
||||
Assert.True(changedFields.ContainsKey("Name"));
|
||||
Assert.True(changedFields.ContainsKey("Description"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateEntity_ShouldLogAllFields()
|
||||
{
|
||||
// Act
|
||||
var projectId = await CreateTestProjectAsync("Test Project");
|
||||
|
||||
// Assert
|
||||
var auditLogs = await GetAuditLogsForEntityAsync("Project", projectId);
|
||||
var createLog = auditLogs.First(a => a.Action == AuditAction.Create);
|
||||
|
||||
Assert.NotNull(createLog.NewValues);
|
||||
Assert.Null(createLog.OldValues); // No old values for Create
|
||||
|
||||
// Verify all fields are logged
|
||||
var fields = JsonSerializer.Deserialize<Dictionary<string, object>>(createLog.NewValues);
|
||||
Assert.True(fields.ContainsKey("Name"));
|
||||
Assert.True(fields.ContainsKey("Key"));
|
||||
Assert.True(fields.ContainsKey("Description"));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **User Context Tests**: `colaflow-api/tests/ColaFlow.Application.IntegrationTests/AuditLog/UserContextTests.cs`
|
||||
```csharp
|
||||
public class UserContextTests : AuditLogIntegrationTestBase
|
||||
{
|
||||
[Fact]
|
||||
public async Task CreateProject_ShouldCaptureCurrentUserId()
|
||||
{
|
||||
// Arrange
|
||||
var userId = Guid.NewGuid();
|
||||
SetCurrentUser(userId);
|
||||
|
||||
// Act
|
||||
var projectId = await CreateTestProjectAsync();
|
||||
|
||||
// Assert
|
||||
var auditLogs = await GetAuditLogsForEntityAsync("Project", projectId);
|
||||
var createLog = auditLogs.First(a => a.Action == AuditAction.Create);
|
||||
|
||||
Assert.Equal(userId, createLog.UserId);
|
||||
Assert.NotNull(createLog.User); // User navigation property loaded
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SystemOperation_ShouldAllowNullUserId()
|
||||
{
|
||||
// Arrange
|
||||
ClearCurrentUser(); // Simulate system operation
|
||||
|
||||
// Act
|
||||
var projectId = await CreateTestProjectAsync();
|
||||
|
||||
// Assert
|
||||
var auditLogs = await GetAuditLogsForEntityAsync("Project", projectId);
|
||||
var createLog = auditLogs.First(a => a.Action == AuditAction.Create);
|
||||
|
||||
Assert.Null(createLog.UserId); // System operation
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. **API Tests**: `colaflow-api/tests/ColaFlow.API.IntegrationTests/AuditLog/AuditLogsControllerTests.cs`
|
||||
```csharp
|
||||
public class AuditLogsControllerTests : ApiIntegrationTestBase
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetEntityAuditHistory_ShouldReturnAuditLogs()
|
||||
{
|
||||
// Arrange
|
||||
var projectId = await CreateTestProjectAsync();
|
||||
await UpdateTestProjectAsync(projectId, "Updated Name");
|
||||
|
||||
// Act
|
||||
var response = await Client.GetAsync($"/api/auditlogs/entity/Project/{projectId}");
|
||||
|
||||
// Assert
|
||||
response.EnsureSuccessStatusCode();
|
||||
var logs = await response.Content.ReadFromJsonAsync<List<AuditLogDto>>();
|
||||
|
||||
Assert.NotNull(logs);
|
||||
Assert.Equal(2, logs.Count); // Create + Update
|
||||
Assert.Contains(logs, l => l.Action == "Create");
|
||||
Assert.Contains(logs, l => l.Action == "Update");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEntityAuditHistory_WithDateFilter_ShouldReturnFilteredResults()
|
||||
{
|
||||
// Arrange
|
||||
var projectId = await CreateTestProjectAsync();
|
||||
await Task.Delay(100);
|
||||
var filterDate = DateTime.UtcNow;
|
||||
await Task.Delay(100);
|
||||
await UpdateTestProjectAsync(projectId, "Updated");
|
||||
|
||||
// Act
|
||||
var response = await Client.GetAsync($"/api/auditlogs/entity/Project/{projectId}?fromDate={filterDate:O}");
|
||||
|
||||
// Assert
|
||||
response.EnsureSuccessStatusCode();
|
||||
var logs = await response.Content.ReadFromJsonAsync<List<AuditLogDto>>();
|
||||
|
||||
Assert.Single(logs); // Only the update after filterDate
|
||||
Assert.Equal("Update", logs[0].Action);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEntityAuditHistory_WithLimit_ShouldRespectLimit()
|
||||
{
|
||||
// Arrange
|
||||
var projectId = await CreateTestProjectAsync();
|
||||
|
||||
// Make 10 updates
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
await UpdateTestProjectAsync(projectId, $"Update {i}");
|
||||
}
|
||||
|
||||
// Act
|
||||
var response = await Client.GetAsync($"/api/auditlogs/entity/Project/{projectId}?limit=5");
|
||||
|
||||
// Assert
|
||||
response.EnsureSuccessStatusCode();
|
||||
var logs = await response.Content.ReadFromJsonAsync<List<AuditLogDto>>();
|
||||
|
||||
Assert.Equal(5, logs.Count); // Respects limit
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEntityAuditHistory_DifferentTenant_ShouldReturnEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var tenant1Id = Guid.NewGuid();
|
||||
SetCurrentTenant(tenant1Id);
|
||||
var projectId = await CreateTestProjectAsync();
|
||||
|
||||
// Act - Switch to different tenant
|
||||
var tenant2Id = Guid.NewGuid();
|
||||
SetCurrentTenant(tenant2Id);
|
||||
var response = await Client.GetAsync($"/api/auditlogs/entity/Project/{projectId}");
|
||||
|
||||
// Assert
|
||||
response.EnsureSuccessStatusCode();
|
||||
var logs = await response.Content.ReadFromJsonAsync<List<AuditLogDto>>();
|
||||
|
||||
Assert.Empty(logs); // Different tenant should not see logs
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
5. **Performance Tests**: `colaflow-api/tests/ColaFlow.Performance.Tests/AuditLog/AuditLogPerformanceTests.cs`
|
||||
```csharp
|
||||
public class AuditLogPerformanceTests : IntegrationTestBase
|
||||
{
|
||||
[Fact]
|
||||
public async Task AuditLogging_ShouldHaveMinimalOverhead()
|
||||
{
|
||||
// Arrange
|
||||
var iterations = 100;
|
||||
var stopwatch = new Stopwatch();
|
||||
|
||||
// Act
|
||||
stopwatch.Start();
|
||||
for (int i = 0; i < iterations; i++)
|
||||
{
|
||||
await CreateTestProjectAsync($"Project {i}");
|
||||
}
|
||||
stopwatch.Stop();
|
||||
|
||||
// Assert
|
||||
var avgTime = stopwatch.ElapsedMilliseconds / (double)iterations;
|
||||
Assert.True(avgTime < 100, $"Average time {avgTime}ms exceeds 100ms target");
|
||||
|
||||
// Overhead should be < 5ms (audit logging overhead)
|
||||
// Total time includes DB write (50-70ms) + audit overhead (< 5ms)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Test Coverage Goals
|
||||
|
||||
| Component | Coverage Target |
|
||||
|-----------|----------------|
|
||||
| AuditLogInterceptor | >= 95% |
|
||||
| JsonDiffService | >= 95% |
|
||||
| AuditLogRepository | >= 90% |
|
||||
| Query Handlers | >= 90% |
|
||||
| Controllers | >= 85% |
|
||||
|
||||
## Testing Commands
|
||||
|
||||
```bash
|
||||
# Run all audit log tests
|
||||
dotnet test --filter "FullyQualifiedName~AuditLog"
|
||||
|
||||
# Run specific test file
|
||||
dotnet test --filter "FullyQualifiedName~ChangedFieldsTests"
|
||||
|
||||
# Run with coverage
|
||||
dotnet test --collect:"XPlat Code Coverage"
|
||||
```
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- All test categories implemented (Changed Fields, User Context, Multi-Tenant, API, Performance)
|
||||
- >= 90% code coverage achieved
|
||||
- All tests passing
|
||||
- Performance tests verify < 5ms overhead
|
||||
- Integration with CI/CD pipeline
|
||||
|
||||
---
|
||||
|
||||
**Created**: 2025-11-05 by Backend Agent
|
||||
105
docs/plans/sprint_2_story_3.md
Normal file
105
docs/plans/sprint_2_story_3.md
Normal file
@@ -0,0 +1,105 @@
|
||||
---
|
||||
story_id: sprint_2_story_3
|
||||
sprint: sprint_2
|
||||
priority: P1
|
||||
status: not_started
|
||||
story_points: 8
|
||||
estimated_days: 3-4
|
||||
created_date: 2025-11-05
|
||||
assignee: Backend Team
|
||||
---
|
||||
|
||||
# Story 3: Sprint Management Module
|
||||
|
||||
**Sprint**: Sprint 2
|
||||
**Priority**: P1 (Should Have)
|
||||
**Estimated**: 3-4 days (Day 31-34)
|
||||
**Owner**: Backend Team
|
||||
|
||||
## Description
|
||||
|
||||
Implement complete Sprint management functionality to support agile sprint planning, tracking, and burndown analytics. Enable teams to organize work into time-boxed iterations.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Sprint entity created with proper domain logic
|
||||
- [ ] 9 CQRS API endpoints implemented (Create, Update, Delete, Get, List, Start, Complete, AddTask, RemoveTask)
|
||||
- [ ] Burndown chart data calculation implemented
|
||||
- [ ] SignalR real-time notifications for Sprint events
|
||||
- [ ] Multi-tenant isolation enforced
|
||||
- [ ] Integration tests with >= 90% coverage
|
||||
- [ ] All tests passing
|
||||
|
||||
## Technical Requirements
|
||||
|
||||
**Sprint Entity**:
|
||||
- Fields: `SprintId`, `TenantId`, `ProjectId`, `Name`, `Goal`, `StartDate`, `EndDate`, `Status` (Planned/Active/Completed)
|
||||
- Relationships: `Project` (many-to-one), `Tasks` (one-to-many)
|
||||
- Business Logic: Validate dates, status transitions, capacity tracking
|
||||
|
||||
**API Endpoints**:
|
||||
1. POST /api/sprints - Create Sprint
|
||||
2. PUT /api/sprints/{id} - Update Sprint
|
||||
3. DELETE /api/sprints/{id} - Delete Sprint
|
||||
4. GET /api/sprints/{id} - Get Sprint by ID
|
||||
5. GET /api/sprints - List Sprints (with filters)
|
||||
6. POST /api/sprints/{id}/start - Start Sprint
|
||||
7. POST /api/sprints/{id}/complete - Complete Sprint
|
||||
8. POST /api/sprints/{id}/tasks/{taskId} - Add Task to Sprint
|
||||
9. DELETE /api/sprints/{id}/tasks/{taskId} - Remove Task from Sprint
|
||||
|
||||
**Burndown Chart**:
|
||||
- Calculate remaining story points per day
|
||||
- Track completed vs remaining work
|
||||
- Return data for chart visualization
|
||||
|
||||
**Technology Stack**:
|
||||
- Domain Layer: Sprint aggregate root + domain events
|
||||
- Application Layer: CQRS (5 commands + 4 queries)
|
||||
- Infrastructure Layer: PostgreSQL + EF Core
|
||||
- Real-time: SignalR SprintHub
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] [Task 1](sprint_2_story_3_task_1.md) - Create Sprint Aggregate Root and Domain Events
|
||||
- [ ] [Task 2](sprint_2_story_3_task_2.md) - Implement Sprint Repository and EF Core Configuration
|
||||
- [ ] [Task 3](sprint_2_story_3_task_3.md) - Create CQRS Commands and Queries
|
||||
- [ ] [Task 4](sprint_2_story_3_task_4.md) - Implement Burndown Chart Calculation
|
||||
- [ ] [Task 5](sprint_2_story_3_task_5.md) - Add SignalR Real-Time Notifications
|
||||
- [ ] [Task 6](sprint_2_story_3_task_6.md) - Write Integration Tests
|
||||
|
||||
**Progress**: 0/6 tasks completed
|
||||
|
||||
## Dependencies
|
||||
|
||||
**Prerequisites**:
|
||||
- ✅ ProjectManagement Module 95% Production Ready (Day 16)
|
||||
- ✅ SignalR Backend 100% Complete (Day 17)
|
||||
- ✅ Multi-Tenant Security Complete (Day 15)
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- All 6 tasks completed
|
||||
- 9 API endpoints working
|
||||
- Burndown chart data calculation implemented
|
||||
- SignalR notifications working
|
||||
- All tests passing (>= 90% coverage)
|
||||
- Multi-tenant isolation verified
|
||||
- Code reviewed and approved
|
||||
- Git commit created
|
||||
|
||||
## Notes
|
||||
|
||||
**Sprint Lifecycle**:
|
||||
1. Planned → Active (Start Sprint)
|
||||
2. Active → Completed (Complete Sprint)
|
||||
3. Cannot delete Active sprint (must complete first)
|
||||
|
||||
**Burndown Chart Formula**:
|
||||
- Total Story Points = Sum of all tasks in sprint
|
||||
- Remaining Story Points = Sum of incomplete tasks
|
||||
- Daily Burndown = [(Start Date, Total), ..., (Today, Remaining), ..., (End Date, 0 ideal)]
|
||||
|
||||
---
|
||||
|
||||
**Created**: 2025-11-05 by Backend Agent
|
||||
289
docs/plans/sprint_2_story_3_task_1.md
Normal file
289
docs/plans/sprint_2_story_3_task_1.md
Normal file
@@ -0,0 +1,289 @@
|
||||
---
|
||||
task_id: sprint_2_story_3_task_1
|
||||
story: sprint_2_story_3
|
||||
status: not_started
|
||||
estimated_hours: 5
|
||||
created_date: 2025-11-05
|
||||
assignee: Backend Team
|
||||
---
|
||||
|
||||
# Task 1: Create Sprint Aggregate Root and Domain Events
|
||||
|
||||
**Story**: Story 3 - Sprint Management Module
|
||||
**Estimated**: 5 hours
|
||||
|
||||
## Description
|
||||
|
||||
Design and implement the Sprint aggregate root with proper domain logic, business rules validation, and domain events for sprint lifecycle management.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Sprint entity created with all required properties
|
||||
- [ ] Domain events defined (SprintCreated, SprintUpdated, SprintStarted, SprintCompleted, SprintDeleted)
|
||||
- [ ] Business logic for status transitions implemented
|
||||
- [ ] Validation rules enforced (dates, status)
|
||||
- [ ] Unit tests for domain logic
|
||||
|
||||
## Implementation Details
|
||||
|
||||
**Files to Create**:
|
||||
|
||||
1. **Sprint Entity**: `colaflow-api/src/ColaFlow.Domain/Entities/Sprint.cs`
|
||||
```csharp
|
||||
public class Sprint
|
||||
{
|
||||
public Guid Id { get; private set; }
|
||||
public Guid TenantId { get; private set; }
|
||||
public Guid ProjectId { get; private set; }
|
||||
public string Name { get; private set; } = string.Empty;
|
||||
public string? Goal { get; private set; }
|
||||
public DateTime StartDate { get; private set; }
|
||||
public DateTime EndDate { get; private set; }
|
||||
public SprintStatus Status { get; private set; }
|
||||
public DateTime CreatedAt { get; private set; }
|
||||
public DateTime? UpdatedAt { get; private set; }
|
||||
|
||||
// Navigation properties
|
||||
public Project Project { get; private set; } = null!;
|
||||
public ICollection<WorkTask> Tasks { get; private set; } = new List<WorkTask>();
|
||||
|
||||
// Domain events
|
||||
private readonly List<DomainEvent> _domainEvents = new();
|
||||
public IReadOnlyList<DomainEvent> DomainEvents => _domainEvents.AsReadOnly();
|
||||
|
||||
// Factory method
|
||||
public static Sprint Create(Guid tenantId, Guid projectId, string name, string? goal,
|
||||
DateTime startDate, DateTime endDate)
|
||||
{
|
||||
ValidateDates(startDate, endDate);
|
||||
|
||||
var sprint = new Sprint
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
ProjectId = projectId,
|
||||
Name = name,
|
||||
Goal = goal,
|
||||
StartDate = startDate,
|
||||
EndDate = endDate,
|
||||
Status = SprintStatus.Planned,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
sprint.AddDomainEvent(new SprintCreatedEvent(sprint.Id, sprint.Name, sprint.ProjectId));
|
||||
return sprint;
|
||||
}
|
||||
|
||||
// Business logic methods
|
||||
public void Update(string name, string? goal, DateTime startDate, DateTime endDate)
|
||||
{
|
||||
ValidateDates(startDate, endDate);
|
||||
|
||||
if (Status == SprintStatus.Completed)
|
||||
throw new InvalidOperationException("Cannot update a completed sprint");
|
||||
|
||||
Name = name;
|
||||
Goal = goal;
|
||||
StartDate = startDate;
|
||||
EndDate = endDate;
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
AddDomainEvent(new SprintUpdatedEvent(Id, Name));
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (Status != SprintStatus.Planned)
|
||||
throw new InvalidOperationException($"Cannot start sprint in {Status} status");
|
||||
|
||||
if (DateTime.UtcNow < StartDate)
|
||||
throw new InvalidOperationException("Cannot start sprint before start date");
|
||||
|
||||
Status = SprintStatus.Active;
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
AddDomainEvent(new SprintStartedEvent(Id, Name));
|
||||
}
|
||||
|
||||
public void Complete()
|
||||
{
|
||||
if (Status != SprintStatus.Active)
|
||||
throw new InvalidOperationException($"Cannot complete sprint in {Status} status");
|
||||
|
||||
Status = SprintStatus.Completed;
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
AddDomainEvent(new SprintCompletedEvent(Id, Name, Tasks.Count));
|
||||
}
|
||||
|
||||
public void AddTask(WorkTask task)
|
||||
{
|
||||
if (Status == SprintStatus.Completed)
|
||||
throw new InvalidOperationException("Cannot add tasks to a completed sprint");
|
||||
|
||||
if (!Tasks.Contains(task))
|
||||
{
|
||||
Tasks.Add(task);
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
public void RemoveTask(WorkTask task)
|
||||
{
|
||||
if (Status == SprintStatus.Completed)
|
||||
throw new InvalidOperationException("Cannot remove tasks from a completed sprint");
|
||||
|
||||
Tasks.Remove(task);
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
private static void ValidateDates(DateTime startDate, DateTime endDate)
|
||||
{
|
||||
if (endDate <= startDate)
|
||||
throw new ArgumentException("End date must be after start date");
|
||||
|
||||
if ((endDate - startDate).TotalDays > 30)
|
||||
throw new ArgumentException("Sprint duration cannot exceed 30 days");
|
||||
}
|
||||
|
||||
private void AddDomainEvent(DomainEvent @event)
|
||||
{
|
||||
_domainEvents.Add(@event);
|
||||
}
|
||||
|
||||
public void ClearDomainEvents()
|
||||
{
|
||||
_domainEvents.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
public enum SprintStatus
|
||||
{
|
||||
Planned = 0,
|
||||
Active = 1,
|
||||
Completed = 2
|
||||
}
|
||||
```
|
||||
|
||||
2. **Domain Events**: `colaflow-api/src/ColaFlow.Domain/Events/Sprint/`
|
||||
```csharp
|
||||
// SprintCreatedEvent.cs
|
||||
public record SprintCreatedEvent(Guid SprintId, string SprintName, Guid ProjectId) : DomainEvent;
|
||||
|
||||
// SprintUpdatedEvent.cs
|
||||
public record SprintUpdatedEvent(Guid SprintId, string SprintName) : DomainEvent;
|
||||
|
||||
// SprintStartedEvent.cs
|
||||
public record SprintStartedEvent(Guid SprintId, string SprintName) : DomainEvent;
|
||||
|
||||
// SprintCompletedEvent.cs
|
||||
public record SprintCompletedEvent(Guid SprintId, string SprintName, int TaskCount) : DomainEvent;
|
||||
|
||||
// SprintDeletedEvent.cs
|
||||
public record SprintDeletedEvent(Guid SprintId, string SprintName) : DomainEvent;
|
||||
```
|
||||
|
||||
## Technical Notes
|
||||
|
||||
**Business Rules**:
|
||||
- Sprint duration: 1-30 days (typical Scrum 2-4 weeks)
|
||||
- Status transitions: Planned → Active → Completed (one-way)
|
||||
- Cannot update/delete completed sprints
|
||||
- Cannot add/remove tasks from completed sprints
|
||||
- End date must be after start date
|
||||
|
||||
**Validation**:
|
||||
- Name: Required, max 100 characters
|
||||
- Goal: Optional, max 500 characters
|
||||
- Dates: StartDate < EndDate, duration <= 30 days
|
||||
|
||||
## Testing
|
||||
|
||||
**Unit Tests**: `colaflow-api/tests/ColaFlow.Domain.Tests/Entities/SprintTests.cs`
|
||||
```csharp
|
||||
public class SprintTests
|
||||
{
|
||||
[Fact]
|
||||
public void Create_ShouldCreateValidSprint()
|
||||
{
|
||||
// Arrange
|
||||
var tenantId = Guid.NewGuid();
|
||||
var projectId = Guid.NewGuid();
|
||||
var startDate = DateTime.UtcNow;
|
||||
var endDate = startDate.AddDays(14);
|
||||
|
||||
// Act
|
||||
var sprint = Sprint.Create(tenantId, projectId, "Sprint 1", "Complete Feature X", startDate, endDate);
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(Guid.Empty, sprint.Id);
|
||||
Assert.Equal("Sprint 1", sprint.Name);
|
||||
Assert.Equal(SprintStatus.Planned, sprint.Status);
|
||||
Assert.Single(sprint.DomainEvents); // SprintCreatedEvent
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_ShouldThrowException_WhenEndDateBeforeStartDate()
|
||||
{
|
||||
// Arrange
|
||||
var startDate = DateTime.UtcNow;
|
||||
var endDate = startDate.AddDays(-1);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
Sprint.Create(Guid.NewGuid(), Guid.NewGuid(), "Sprint 1", null, startDate, endDate));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Start_ShouldChangeStatusToActive()
|
||||
{
|
||||
// Arrange
|
||||
var sprint = CreateTestSprint();
|
||||
|
||||
// Act
|
||||
sprint.Start();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(SprintStatus.Active, sprint.Status);
|
||||
Assert.Contains(sprint.DomainEvents, e => e is SprintStartedEvent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Complete_ShouldThrowException_WhenNotActive()
|
||||
{
|
||||
// Arrange
|
||||
var sprint = CreateTestSprint(); // Status = Planned
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<InvalidOperationException>(() => sprint.Complete());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddTask_ShouldThrowException_WhenSprintCompleted()
|
||||
{
|
||||
// Arrange
|
||||
var sprint = CreateTestSprint();
|
||||
sprint.Start();
|
||||
sprint.Complete();
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<InvalidOperationException>(() => sprint.AddTask(new WorkTask()));
|
||||
}
|
||||
|
||||
private Sprint CreateTestSprint()
|
||||
{
|
||||
return Sprint.Create(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
"Test Sprint",
|
||||
"Test Goal",
|
||||
DateTime.UtcNow,
|
||||
DateTime.UtcNow.AddDays(14)
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Created**: 2025-11-05 by Backend Agent
|
||||
277
docs/plans/sprint_2_story_3_task_2.md
Normal file
277
docs/plans/sprint_2_story_3_task_2.md
Normal file
@@ -0,0 +1,277 @@
|
||||
---
|
||||
task_id: sprint_2_story_3_task_2
|
||||
story: sprint_2_story_3
|
||||
status: not_started
|
||||
estimated_hours: 4
|
||||
created_date: 2025-11-05
|
||||
assignee: Backend Team
|
||||
---
|
||||
|
||||
# Task 2: Implement Sprint Repository and EF Core Configuration
|
||||
|
||||
**Story**: Story 3 - Sprint Management Module
|
||||
**Estimated**: 4 hours
|
||||
|
||||
## Description
|
||||
|
||||
Create the Sprint repository pattern implementation with EF Core configuration, including multi-tenant isolation, database migration, and indexes for query optimization.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] ISprintRepository interface defined
|
||||
- [ ] SprintRepository implementation with multi-tenant filtering
|
||||
- [ ] EF Core entity configuration created
|
||||
- [ ] Database migration generated and applied
|
||||
- [ ] Indexes created for performance
|
||||
- [ ] Unit tests for repository methods
|
||||
|
||||
## Implementation Details
|
||||
|
||||
**Files to Create**:
|
||||
|
||||
1. **Repository Interface**: `colaflow-api/src/ColaFlow.Domain/Repositories/ISprintRepository.cs`
|
||||
```csharp
|
||||
public interface ISprintRepository
|
||||
{
|
||||
Task<Sprint?> GetByIdAsync(Guid sprintId, CancellationToken cancellationToken = default);
|
||||
Task<List<Sprint>> GetByProjectIdAsync(Guid projectId, CancellationToken cancellationToken = default);
|
||||
Task<List<Sprint>> GetActiveSprintsAsync(CancellationToken cancellationToken = default);
|
||||
Task<Sprint?> GetActiveSprintForProjectAsync(Guid projectId, CancellationToken cancellationToken = default);
|
||||
Task AddAsync(Sprint sprint, CancellationToken cancellationToken = default);
|
||||
void Update(Sprint sprint);
|
||||
void Delete(Sprint sprint);
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
```
|
||||
|
||||
2. **Repository Implementation**: `colaflow-api/src/ColaFlow.Infrastructure/Repositories/SprintRepository.cs`
|
||||
```csharp
|
||||
public class SprintRepository : ISprintRepository
|
||||
{
|
||||
private readonly ColaFlowDbContext _context;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
|
||||
public SprintRepository(ColaFlowDbContext context, ITenantContext tenantContext)
|
||||
{
|
||||
_context = context;
|
||||
_tenantContext = tenantContext;
|
||||
}
|
||||
|
||||
public async Task<Sprint?> GetByIdAsync(Guid sprintId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tenantId = _tenantContext.TenantId;
|
||||
return await _context.Sprints
|
||||
.Include(s => s.Project)
|
||||
.Include(s => s.Tasks)
|
||||
.FirstOrDefaultAsync(s => s.TenantId == tenantId && s.Id == sprintId, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<List<Sprint>> GetByProjectIdAsync(Guid projectId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tenantId = _tenantContext.TenantId;
|
||||
return await _context.Sprints
|
||||
.Include(s => s.Tasks)
|
||||
.Where(s => s.TenantId == tenantId && s.ProjectId == projectId)
|
||||
.OrderByDescending(s => s.StartDate)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<List<Sprint>> GetActiveSprintsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tenantId = _tenantContext.TenantId;
|
||||
return await _context.Sprints
|
||||
.Include(s => s.Project)
|
||||
.Include(s => s.Tasks)
|
||||
.Where(s => s.TenantId == tenantId && s.Status == SprintStatus.Active)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<Sprint?> GetActiveSprintForProjectAsync(Guid projectId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tenantId = _tenantContext.TenantId;
|
||||
return await _context.Sprints
|
||||
.Include(s => s.Tasks)
|
||||
.FirstOrDefaultAsync(s => s.TenantId == tenantId &&
|
||||
s.ProjectId == projectId &&
|
||||
s.Status == SprintStatus.Active,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task AddAsync(Sprint sprint, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _context.Sprints.AddAsync(sprint, cancellationToken);
|
||||
}
|
||||
|
||||
public void Update(Sprint sprint)
|
||||
{
|
||||
_context.Sprints.Update(sprint);
|
||||
}
|
||||
|
||||
public void Delete(Sprint sprint)
|
||||
{
|
||||
_context.Sprints.Remove(sprint);
|
||||
}
|
||||
|
||||
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **EF Core Configuration**: `colaflow-api/src/ColaFlow.Infrastructure/Data/Configurations/SprintConfiguration.cs`
|
||||
```csharp
|
||||
public class SprintConfiguration : IEntityTypeConfiguration<Sprint>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Sprint> builder)
|
||||
{
|
||||
builder.ToTable("Sprints");
|
||||
|
||||
builder.HasKey(s => s.Id);
|
||||
|
||||
// Properties
|
||||
builder.Property(s => s.Name)
|
||||
.IsRequired()
|
||||
.HasMaxLength(100);
|
||||
|
||||
builder.Property(s => s.Goal)
|
||||
.HasMaxLength(500);
|
||||
|
||||
builder.Property(s => s.StartDate)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(s => s.EndDate)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(s => s.Status)
|
||||
.IsRequired()
|
||||
.HasConversion<string>(); // Store as string for readability
|
||||
|
||||
builder.Property(s => s.TenantId)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(s => s.CreatedAt)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(s => s.UpdatedAt);
|
||||
|
||||
// Relationships
|
||||
builder.HasOne(s => s.Project)
|
||||
.WithMany()
|
||||
.HasForeignKey(s => s.ProjectId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(s => s.Tasks)
|
||||
.WithOne()
|
||||
.HasForeignKey("SprintId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
// Indexes
|
||||
builder.HasIndex(s => new { s.TenantId, s.ProjectId });
|
||||
builder.HasIndex(s => new { s.TenantId, s.Status });
|
||||
builder.HasIndex(s => s.StartDate);
|
||||
|
||||
// Multi-tenant global query filter
|
||||
builder.HasQueryFilter(s => s.TenantId == Guid.Empty); // Replaced at runtime by TenantContext
|
||||
|
||||
// Ignore domain events (not persisted)
|
||||
builder.Ignore(s => s.DomainEvents);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. **Database Migration**: Create migration
|
||||
```bash
|
||||
cd colaflow-api/src/ColaFlow.Infrastructure
|
||||
dotnet ef migrations add AddSprintEntity --project ../ColaFlow.Infrastructure --startup-project ../../ColaFlow.API
|
||||
```
|
||||
|
||||
**Migration will create**:
|
||||
```sql
|
||||
CREATE TABLE Sprints (
|
||||
Id UUID PRIMARY KEY,
|
||||
TenantId UUID NOT NULL,
|
||||
ProjectId UUID NOT NULL,
|
||||
Name VARCHAR(100) NOT NULL,
|
||||
Goal VARCHAR(500),
|
||||
StartDate TIMESTAMP NOT NULL,
|
||||
EndDate TIMESTAMP NOT NULL,
|
||||
Status VARCHAR(20) NOT NULL,
|
||||
CreatedAt TIMESTAMP NOT NULL,
|
||||
UpdatedAt TIMESTAMP,
|
||||
|
||||
CONSTRAINT FK_Sprints_Projects FOREIGN KEY (ProjectId) REFERENCES Projects(Id) ON DELETE CASCADE,
|
||||
CONSTRAINT FK_Sprints_Tenants FOREIGN KEY (TenantId) REFERENCES Tenants(Id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IX_Sprints_TenantId_ProjectId ON Sprints(TenantId, ProjectId);
|
||||
CREATE INDEX IX_Sprints_TenantId_Status ON Sprints(TenantId, Status);
|
||||
CREATE INDEX IX_Sprints_StartDate ON Sprints(StartDate);
|
||||
```
|
||||
|
||||
5. **Update WorkTask**: Add SprintId foreign key
|
||||
```csharp
|
||||
// In WorkTask entity
|
||||
public Guid? SprintId { get; private set; }
|
||||
|
||||
public void AssignToSprint(Guid sprintId)
|
||||
{
|
||||
SprintId = sprintId;
|
||||
}
|
||||
|
||||
public void RemoveFromSprint()
|
||||
{
|
||||
SprintId = null;
|
||||
}
|
||||
```
|
||||
|
||||
6. **DI Registration**: Update `colaflow-api/src/ColaFlow.Infrastructure/DependencyInjection.cs`
|
||||
```csharp
|
||||
services.AddScoped<ISprintRepository, SprintRepository>();
|
||||
```
|
||||
|
||||
## Technical Notes
|
||||
|
||||
- Use composite indexes for multi-tenant queries
|
||||
- Store Status as string for readability in database
|
||||
- Use `AsNoTracking()` for read-only queries (performance)
|
||||
- Global query filter enforces multi-tenant isolation
|
||||
- Tasks relationship uses SetNull (tasks remain when sprint deleted)
|
||||
|
||||
## Testing
|
||||
|
||||
**Unit Tests**: `colaflow-api/tests/ColaFlow.Infrastructure.Tests/Repositories/SprintRepositoryTests.cs`
|
||||
```csharp
|
||||
public class SprintRepositoryTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_ShouldReturnSprint_WhenExists()
|
||||
{
|
||||
// Arrange
|
||||
var (context, tenantContext, repository) = CreateTestContext();
|
||||
var sprint = CreateTestSprint(tenantContext.TenantId);
|
||||
await context.Sprints.AddAsync(sprint);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
// Act
|
||||
var result = await repository.GetByIdAsync(sprint.Id);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(sprint.Id, result.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByProjectIdAsync_ShouldReturnOnlyCurrentTenantSprints()
|
||||
{
|
||||
// Test multi-tenant isolation
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Created**: 2025-11-05 by Backend Agent
|
||||
343
docs/plans/sprint_2_story_3_task_3.md
Normal file
343
docs/plans/sprint_2_story_3_task_3.md
Normal file
@@ -0,0 +1,343 @@
|
||||
---
|
||||
task_id: sprint_2_story_3_task_3
|
||||
story: sprint_2_story_3
|
||||
status: not_started
|
||||
estimated_hours: 6
|
||||
created_date: 2025-11-05
|
||||
assignee: Backend Team
|
||||
---
|
||||
|
||||
# Task 3: Create CQRS Commands and Queries
|
||||
|
||||
**Story**: Story 3 - Sprint Management Module
|
||||
**Estimated**: 6 hours
|
||||
|
||||
## Description
|
||||
|
||||
Implement CQRS commands and queries for Sprint management, including Create, Update, Delete, Start, Complete, AddTask, RemoveTask, Get, and List operations.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] 5 command handlers implemented
|
||||
- [ ] 4 query handlers implemented
|
||||
- [ ] SprintsController with 9 endpoints created
|
||||
- [ ] Input validation with FluentValidation
|
||||
- [ ] Unit tests for handlers
|
||||
- [ ] Swagger documentation
|
||||
|
||||
## Implementation Details
|
||||
|
||||
**Commands to Implement**:
|
||||
1. CreateSprintCommand
|
||||
2. UpdateSprintCommand
|
||||
3. DeleteSprintCommand
|
||||
4. StartSprintCommand
|
||||
5. CompleteSprintCommand
|
||||
6. AddTaskToSprintCommand (Bonus)
|
||||
7. RemoveTaskFromSprintCommand (Bonus)
|
||||
|
||||
**Queries to Implement**:
|
||||
1. GetSprintByIdQuery
|
||||
2. GetSprintsByProjectIdQuery
|
||||
3. GetActiveSprintsQuery
|
||||
4. GetSprintBurndownQuery
|
||||
|
||||
**Files to Create**:
|
||||
|
||||
1. **Create Sprint**: `colaflow-api/src/ColaFlow.Application/Sprints/Commands/CreateSprint/`
|
||||
```csharp
|
||||
// CreateSprintCommand.cs
|
||||
public record CreateSprintCommand : IRequest<Guid>
|
||||
{
|
||||
public Guid ProjectId { get; init; }
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public string? Goal { get; init; }
|
||||
public DateTime StartDate { get; init; }
|
||||
public DateTime EndDate { get; init; }
|
||||
}
|
||||
|
||||
// CreateSprintCommandValidator.cs
|
||||
public class CreateSprintCommandValidator : AbstractValidator<CreateSprintCommand>
|
||||
{
|
||||
public CreateSprintCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.ProjectId)
|
||||
.NotEmpty().WithMessage("ProjectId is required");
|
||||
|
||||
RuleFor(x => x.Name)
|
||||
.NotEmpty().WithMessage("Name is required")
|
||||
.MaximumLength(100).WithMessage("Name must not exceed 100 characters");
|
||||
|
||||
RuleFor(x => x.Goal)
|
||||
.MaximumLength(500).WithMessage("Goal must not exceed 500 characters");
|
||||
|
||||
RuleFor(x => x.StartDate)
|
||||
.NotEmpty().WithMessage("StartDate is required");
|
||||
|
||||
RuleFor(x => x.EndDate)
|
||||
.NotEmpty().WithMessage("EndDate is required")
|
||||
.GreaterThan(x => x.StartDate).WithMessage("EndDate must be after StartDate");
|
||||
|
||||
RuleFor(x => x)
|
||||
.Must(x => (x.EndDate - x.StartDate).TotalDays <= 30)
|
||||
.WithMessage("Sprint duration cannot exceed 30 days");
|
||||
}
|
||||
}
|
||||
|
||||
// CreateSprintCommandHandler.cs
|
||||
public class CreateSprintCommandHandler : IRequestHandler<CreateSprintCommand, Guid>
|
||||
{
|
||||
private readonly ISprintRepository _repository;
|
||||
private readonly IProjectRepository _projectRepository;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly ILogger<CreateSprintCommandHandler> _logger;
|
||||
|
||||
public CreateSprintCommandHandler(
|
||||
ISprintRepository repository,
|
||||
IProjectRepository projectRepository,
|
||||
ITenantContext tenantContext,
|
||||
ILogger<CreateSprintCommandHandler> logger)
|
||||
{
|
||||
_repository = repository;
|
||||
_projectRepository = projectRepository;
|
||||
_tenantContext = tenantContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Guid> Handle(CreateSprintCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Verify project exists and belongs to tenant
|
||||
var project = await _projectRepository.GetByIdAsync(request.ProjectId, cancellationToken);
|
||||
if (project == null)
|
||||
throw new NotFoundException(nameof(Project), request.ProjectId);
|
||||
|
||||
// Create sprint
|
||||
var sprint = Sprint.Create(
|
||||
_tenantContext.TenantId,
|
||||
request.ProjectId,
|
||||
request.Name,
|
||||
request.Goal,
|
||||
request.StartDate,
|
||||
request.EndDate
|
||||
);
|
||||
|
||||
await _repository.AddAsync(sprint, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("Sprint {SprintId} created for project {ProjectId}", sprint.Id, request.ProjectId);
|
||||
|
||||
return sprint.Id;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **Update Sprint**: `colaflow-api/src/ColaFlow.Application/Sprints/Commands/UpdateSprint/`
|
||||
```csharp
|
||||
public record UpdateSprintCommand : IRequest
|
||||
{
|
||||
public Guid SprintId { get; init; }
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public string? Goal { get; init; }
|
||||
public DateTime StartDate { get; init; }
|
||||
public DateTime EndDate { get; init; }
|
||||
}
|
||||
|
||||
public class UpdateSprintCommandHandler : IRequestHandler<UpdateSprintCommand>
|
||||
{
|
||||
private readonly ISprintRepository _repository;
|
||||
|
||||
public async Task Handle(UpdateSprintCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var sprint = await _repository.GetByIdAsync(request.SprintId, cancellationToken);
|
||||
if (sprint == null)
|
||||
throw new NotFoundException(nameof(Sprint), request.SprintId);
|
||||
|
||||
sprint.Update(request.Name, request.Goal, request.StartDate, request.EndDate);
|
||||
|
||||
_repository.Update(sprint);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **Start/Complete Sprint**: `colaflow-api/src/ColaFlow.Application/Sprints/Commands/`
|
||||
```csharp
|
||||
// StartSprintCommand.cs
|
||||
public record StartSprintCommand(Guid SprintId) : IRequest;
|
||||
|
||||
public class StartSprintCommandHandler : IRequestHandler<StartSprintCommand>
|
||||
{
|
||||
private readonly ISprintRepository _repository;
|
||||
|
||||
public async Task Handle(StartSprintCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var sprint = await _repository.GetByIdAsync(request.SprintId, cancellationToken);
|
||||
if (sprint == null)
|
||||
throw new NotFoundException(nameof(Sprint), request.SprintId);
|
||||
|
||||
sprint.Start();
|
||||
|
||||
_repository.Update(sprint);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
// CompleteSprintCommand.cs
|
||||
public record CompleteSprintCommand(Guid SprintId) : IRequest;
|
||||
|
||||
public class CompleteSprintCommandHandler : IRequestHandler<CompleteSprintCommand>
|
||||
{
|
||||
private readonly ISprintRepository _repository;
|
||||
|
||||
public async Task Handle(CompleteSprintCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var sprint = await _repository.GetByIdAsync(request.SprintId, cancellationToken);
|
||||
if (sprint == null)
|
||||
throw new NotFoundException(nameof(Sprint), request.SprintId);
|
||||
|
||||
sprint.Complete();
|
||||
|
||||
_repository.Update(sprint);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. **Get Sprint Query**: `colaflow-api/src/ColaFlow.Application/Sprints/Queries/GetSprintById/`
|
||||
```csharp
|
||||
public record GetSprintByIdQuery(Guid SprintId) : IRequest<SprintDto>;
|
||||
|
||||
public class SprintDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid ProjectId { get; set; }
|
||||
public string ProjectName { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Goal { get; set; }
|
||||
public DateTime StartDate { get; set; }
|
||||
public DateTime EndDate { get; set; }
|
||||
public string Status { get; set; } = string.Empty;
|
||||
public int TotalTasks { get; set; }
|
||||
public int CompletedTasks { get; set; }
|
||||
public int TotalStoryPoints { get; set; }
|
||||
public int RemainingStoryPoints { get; set; }
|
||||
}
|
||||
|
||||
public class GetSprintByIdQueryHandler : IRequestHandler<GetSprintByIdQuery, SprintDto>
|
||||
{
|
||||
private readonly ISprintRepository _repository;
|
||||
private readonly IMapper _mapper;
|
||||
|
||||
public async Task<SprintDto> Handle(GetSprintByIdQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var sprint = await _repository.GetByIdAsync(request.SprintId, cancellationToken);
|
||||
if (sprint == null)
|
||||
throw new NotFoundException(nameof(Sprint), request.SprintId);
|
||||
|
||||
var dto = _mapper.Map<SprintDto>(sprint);
|
||||
|
||||
// Calculate statistics
|
||||
dto.TotalTasks = sprint.Tasks.Count;
|
||||
dto.CompletedTasks = sprint.Tasks.Count(t => t.Status == WorkTaskStatus.Done);
|
||||
dto.TotalStoryPoints = sprint.Tasks.Sum(t => t.StoryPoints ?? 0);
|
||||
dto.RemainingStoryPoints = sprint.Tasks.Where(t => t.Status != WorkTaskStatus.Done).Sum(t => t.StoryPoints ?? 0);
|
||||
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
5. **Controller**: `colaflow-api/src/ColaFlow.API/Controllers/SprintsController.cs`
|
||||
```csharp
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize]
|
||||
public class SprintsController : ControllerBase
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
|
||||
public SprintsController(IMediator mediator)
|
||||
{
|
||||
_mediator = mediator;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(Guid), StatusCodes.Status201Created)]
|
||||
public async Task<IActionResult> CreateSprint([FromBody] CreateSprintCommand command)
|
||||
{
|
||||
var sprintId = await _mediator.Send(command);
|
||||
return CreatedAtAction(nameof(GetSprintById), new { id = sprintId }, sprintId);
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> UpdateSprint([FromRoute] Guid id, [FromBody] UpdateSprintCommand command)
|
||||
{
|
||||
if (id != command.SprintId)
|
||||
return BadRequest("SprintId mismatch");
|
||||
|
||||
await _mediator.Send(command);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> DeleteSprint([FromRoute] Guid id)
|
||||
{
|
||||
await _mediator.Send(new DeleteSprintCommand(id));
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
[ProducesResponseType(typeof(SprintDto), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetSprintById([FromRoute] Guid id)
|
||||
{
|
||||
var sprint = await _mediator.Send(new GetSprintByIdQuery(id));
|
||||
return Ok(sprint);
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(List<SprintDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetSprints([FromQuery] Guid? projectId = null)
|
||||
{
|
||||
var sprints = projectId.HasValue
|
||||
? await _mediator.Send(new GetSprintsByProjectIdQuery(projectId.Value))
|
||||
: await _mediator.Send(new GetActiveSprintsQuery());
|
||||
|
||||
return Ok(sprints);
|
||||
}
|
||||
|
||||
[HttpPost("{id}/start")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> StartSprint([FromRoute] Guid id)
|
||||
{
|
||||
await _mediator.Send(new StartSprintCommand(id));
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("{id}/complete")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> CompleteSprint([FromRoute] Guid id)
|
||||
{
|
||||
await _mediator.Send(new CompleteSprintCommand(id));
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Technical Notes
|
||||
|
||||
- Use FluentValidation for input validation
|
||||
- Use AutoMapper for entity-to-DTO mapping
|
||||
- Use IRequestHandler<TRequest, TResponse> for commands/queries
|
||||
- Publish domain events via MediatR after SaveChanges
|
||||
- Return 404 for not found entities
|
||||
- Use DTOs to avoid exposing domain entities
|
||||
|
||||
## Testing
|
||||
|
||||
**Unit Tests**: Test each command/query handler independently
|
||||
|
||||
---
|
||||
|
||||
**Created**: 2025-11-05 by Backend Agent
|
||||
317
docs/plans/sprint_2_story_3_task_4.md
Normal file
317
docs/plans/sprint_2_story_3_task_4.md
Normal file
@@ -0,0 +1,317 @@
|
||||
---
|
||||
task_id: sprint_2_story_3_task_4
|
||||
story: sprint_2_story_3
|
||||
status: not_started
|
||||
estimated_hours: 4
|
||||
created_date: 2025-11-05
|
||||
assignee: Backend Team
|
||||
---
|
||||
|
||||
# Task 4: Implement Burndown Chart Calculation
|
||||
|
||||
**Story**: Story 3 - Sprint Management Module
|
||||
**Estimated**: 4 hours
|
||||
|
||||
## Description
|
||||
|
||||
Implement burndown chart data calculation to track sprint progress. Calculate daily remaining story points and provide data for visualization.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] GetSprintBurndownQuery implemented
|
||||
- [ ] Daily remaining story points calculated
|
||||
- [ ] Ideal burndown line calculated
|
||||
- [ ] Actual burndown line calculated
|
||||
- [ ] BurndownDto with chart data created
|
||||
- [ ] Unit tests for calculation logic
|
||||
|
||||
## Implementation Details
|
||||
|
||||
**Files to Create**:
|
||||
|
||||
1. **Burndown Query**: `colaflow-api/src/ColaFlow.Application/Sprints/Queries/GetSprintBurndown/GetSprintBurndownQuery.cs`
|
||||
```csharp
|
||||
public record GetSprintBurndownQuery(Guid SprintId) : IRequest<BurndownDto>;
|
||||
|
||||
public class BurndownDto
|
||||
{
|
||||
public Guid SprintId { get; set; }
|
||||
public string SprintName { get; set; } = string.Empty;
|
||||
public DateTime StartDate { get; set; }
|
||||
public DateTime EndDate { get; set; }
|
||||
public int TotalStoryPoints { get; set; }
|
||||
public int RemainingStoryPoints { get; set; }
|
||||
public List<BurndownDataPoint> IdealBurndown { get; set; } = new();
|
||||
public List<BurndownDataPoint> ActualBurndown { get; set; } = new();
|
||||
public double CompletionPercentage { get; set; }
|
||||
}
|
||||
|
||||
public class BurndownDataPoint
|
||||
{
|
||||
public DateTime Date { get; set; }
|
||||
public int StoryPoints { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
2. **Query Handler**: `colaflow-api/src/ColaFlow.Application/Sprints/Queries/GetSprintBurndown/GetSprintBurndownQueryHandler.cs`
|
||||
```csharp
|
||||
public class GetSprintBurndownQueryHandler : IRequestHandler<GetSprintBurndownQuery, BurndownDto>
|
||||
{
|
||||
private readonly ISprintRepository _sprintRepository;
|
||||
private readonly IAuditLogRepository _auditLogRepository; // For historical data
|
||||
private readonly ILogger<GetSprintBurndownQueryHandler> _logger;
|
||||
|
||||
public GetSprintBurndownQueryHandler(
|
||||
ISprintRepository sprintRepository,
|
||||
IAuditLogRepository auditLogRepository,
|
||||
ILogger<GetSprintBurndownQueryHandler> logger)
|
||||
{
|
||||
_sprintRepository = sprintRepository;
|
||||
_auditLogRepository = auditLogRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<BurndownDto> Handle(GetSprintBurndownQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var sprint = await _sprintRepository.GetByIdAsync(request.SprintId, cancellationToken);
|
||||
if (sprint == null)
|
||||
throw new NotFoundException(nameof(Sprint), request.SprintId);
|
||||
|
||||
var totalStoryPoints = sprint.Tasks.Sum(t => t.StoryPoints ?? 0);
|
||||
var remainingStoryPoints = sprint.Tasks
|
||||
.Where(t => t.Status != WorkTaskStatus.Done)
|
||||
.Sum(t => t.StoryPoints ?? 0);
|
||||
|
||||
var burndownDto = new BurndownDto
|
||||
{
|
||||
SprintId = sprint.Id,
|
||||
SprintName = sprint.Name,
|
||||
StartDate = sprint.StartDate,
|
||||
EndDate = sprint.EndDate,
|
||||
TotalStoryPoints = totalStoryPoints,
|
||||
RemainingStoryPoints = remainingStoryPoints,
|
||||
CompletionPercentage = totalStoryPoints > 0
|
||||
? ((totalStoryPoints - remainingStoryPoints) / (double)totalStoryPoints) * 100
|
||||
: 0
|
||||
};
|
||||
|
||||
// Calculate ideal burndown
|
||||
burndownDto.IdealBurndown = CalculateIdealBurndown(
|
||||
sprint.StartDate,
|
||||
sprint.EndDate,
|
||||
totalStoryPoints
|
||||
);
|
||||
|
||||
// Calculate actual burndown
|
||||
burndownDto.ActualBurndown = await CalculateActualBurndownAsync(
|
||||
sprint,
|
||||
totalStoryPoints,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
return burndownDto;
|
||||
}
|
||||
|
||||
private List<BurndownDataPoint> CalculateIdealBurndown(
|
||||
DateTime startDate,
|
||||
DateTime endDate,
|
||||
int totalStoryPoints)
|
||||
{
|
||||
var idealBurndown = new List<BurndownDataPoint>();
|
||||
var sprintDuration = (endDate - startDate).Days;
|
||||
|
||||
if (sprintDuration <= 0)
|
||||
return idealBurndown;
|
||||
|
||||
var dailyBurnRate = totalStoryPoints / (double)sprintDuration;
|
||||
|
||||
for (int day = 0; day <= sprintDuration; day++)
|
||||
{
|
||||
var date = startDate.AddDays(day);
|
||||
var remainingPoints = (int)Math.Round(totalStoryPoints - (day * dailyBurnRate));
|
||||
|
||||
idealBurndown.Add(new BurndownDataPoint
|
||||
{
|
||||
Date = date,
|
||||
StoryPoints = Math.Max(0, remainingPoints)
|
||||
});
|
||||
}
|
||||
|
||||
return idealBurndown;
|
||||
}
|
||||
|
||||
private async Task<List<BurndownDataPoint>> CalculateActualBurndownAsync(
|
||||
Sprint sprint,
|
||||
int totalStoryPoints,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var actualBurndown = new List<BurndownDataPoint>();
|
||||
|
||||
// Start with total story points
|
||||
actualBurndown.Add(new BurndownDataPoint
|
||||
{
|
||||
Date = sprint.StartDate,
|
||||
StoryPoints = totalStoryPoints
|
||||
});
|
||||
|
||||
// Get task completion history from audit logs
|
||||
var taskCompletions = await GetTaskCompletionHistoryAsync(sprint.Id, cancellationToken);
|
||||
|
||||
var currentDate = sprint.StartDate.Date;
|
||||
var endDate = sprint.Status == SprintStatus.Completed
|
||||
? sprint.EndDate.Date
|
||||
: DateTime.UtcNow.Date;
|
||||
|
||||
var remainingPoints = totalStoryPoints;
|
||||
|
||||
while (currentDate <= endDate)
|
||||
{
|
||||
currentDate = currentDate.AddDays(1);
|
||||
|
||||
// Get tasks completed on this day
|
||||
var completedOnDay = taskCompletions
|
||||
.Where(tc => tc.CompletedDate.Date == currentDate)
|
||||
.Sum(tc => tc.StoryPoints);
|
||||
|
||||
remainingPoints -= completedOnDay;
|
||||
|
||||
actualBurndown.Add(new BurndownDataPoint
|
||||
{
|
||||
Date = currentDate,
|
||||
StoryPoints = Math.Max(0, remainingPoints)
|
||||
});
|
||||
}
|
||||
|
||||
return actualBurndown;
|
||||
}
|
||||
|
||||
private async Task<List<TaskCompletion>> GetTaskCompletionHistoryAsync(
|
||||
Guid sprintId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Query audit logs for task status changes to "Done"
|
||||
// This gives us historical completion data for accurate burndown
|
||||
|
||||
// For MVP, use current task status
|
||||
// TODO: Implement audit log query for historical data in Phase 2
|
||||
|
||||
var sprint = await _sprintRepository.GetByIdAsync(sprintId, cancellationToken);
|
||||
if (sprint == null) return new List<TaskCompletion>();
|
||||
|
||||
return sprint.Tasks
|
||||
.Where(t => t.Status == WorkTaskStatus.Done)
|
||||
.Select(t => new TaskCompletion
|
||||
{
|
||||
TaskId = t.Id,
|
||||
StoryPoints = t.StoryPoints ?? 0,
|
||||
CompletedDate = t.UpdatedAt ?? DateTime.UtcNow // Approximation for MVP
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private class TaskCompletion
|
||||
{
|
||||
public Guid TaskId { get; set; }
|
||||
public int StoryPoints { get; set; }
|
||||
public DateTime CompletedDate { get; set; }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **Add Controller Endpoint**: Update `SprintsController.cs`
|
||||
```csharp
|
||||
[HttpGet("{id}/burndown")]
|
||||
[ProducesResponseType(typeof(BurndownDto), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetSprintBurndown([FromRoute] Guid id)
|
||||
{
|
||||
var burndown = await _mediator.Send(new GetSprintBurndownQuery(id));
|
||||
return Ok(burndown);
|
||||
}
|
||||
```
|
||||
|
||||
**Example Response**:
|
||||
```json
|
||||
{
|
||||
"sprintId": "abc-123",
|
||||
"sprintName": "Sprint 1",
|
||||
"startDate": "2025-11-01",
|
||||
"endDate": "2025-11-14",
|
||||
"totalStoryPoints": 50,
|
||||
"remainingStoryPoints": 20,
|
||||
"completionPercentage": 60,
|
||||
"idealBurndown": [
|
||||
{ "date": "2025-11-01", "storyPoints": 50 },
|
||||
{ "date": "2025-11-02", "storyPoints": 46 },
|
||||
{ "date": "2025-11-03", "storyPoints": 42 },
|
||||
...
|
||||
{ "date": "2025-11-14", "storyPoints": 0 }
|
||||
],
|
||||
"actualBurndown": [
|
||||
{ "date": "2025-11-01", "storyPoints": 50 },
|
||||
{ "date": "2025-11-02", "storyPoints": 50 },
|
||||
{ "date": "2025-11-03", "storyPoints": 45 },
|
||||
{ "date": "2025-11-04", "storyPoints": 38 },
|
||||
...
|
||||
{ "date": "2025-11-05", "storyPoints": 20 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Technical Notes
|
||||
|
||||
**Ideal Burndown**:
|
||||
- Linear decrease from total story points to 0
|
||||
- Formula: `Remaining = Total - (Day * DailyBurnRate)`
|
||||
- DailyBurnRate = `TotalStoryPoints / SprintDuration`
|
||||
|
||||
**Actual Burndown**:
|
||||
- Based on real task completions
|
||||
- Uses audit logs for historical accuracy (Phase 2)
|
||||
- MVP uses task UpdatedAt timestamp (approximation)
|
||||
|
||||
**Future Enhancements** (Phase 2):
|
||||
- Query audit logs for exact task completion dates
|
||||
- Handle scope changes (tasks added/removed mid-sprint)
|
||||
- Velocity trend analysis
|
||||
- Sprint capacity planning
|
||||
|
||||
## Testing
|
||||
|
||||
**Unit Tests**:
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task GetSprintBurndown_ShouldCalculateIdealBurndown()
|
||||
{
|
||||
// Arrange
|
||||
var sprint = CreateTestSprint(
|
||||
startDate: new DateTime(2025, 11, 1),
|
||||
endDate: new DateTime(2025, 11, 14),
|
||||
totalStoryPoints: 50
|
||||
);
|
||||
|
||||
// Act
|
||||
var burndown = await _handler.Handle(new GetSprintBurndownQuery(sprint.Id), default);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(15, burndown.IdealBurndown.Count); // 14 days + 1
|
||||
Assert.Equal(50, burndown.IdealBurndown.First().StoryPoints);
|
||||
Assert.Equal(0, burndown.IdealBurndown.Last().StoryPoints);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSprintBurndown_ShouldCalculateCompletionPercentage()
|
||||
{
|
||||
// Arrange
|
||||
var sprint = CreateTestSprintWithTasks(totalPoints: 100, completedPoints: 60);
|
||||
|
||||
// Act
|
||||
var burndown = await _handler.Handle(new GetSprintBurndownQuery(sprint.Id), default);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(60, burndown.CompletionPercentage);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Created**: 2025-11-05 by Backend Agent
|
||||
301
docs/plans/sprint_2_story_3_task_5.md
Normal file
301
docs/plans/sprint_2_story_3_task_5.md
Normal file
@@ -0,0 +1,301 @@
|
||||
---
|
||||
task_id: sprint_2_story_3_task_5
|
||||
story: sprint_2_story_3
|
||||
status: not_started
|
||||
estimated_hours: 3
|
||||
created_date: 2025-11-05
|
||||
assignee: Backend Team
|
||||
---
|
||||
|
||||
# Task 5: Add SignalR Real-Time Notifications
|
||||
|
||||
**Story**: Story 3 - Sprint Management Module
|
||||
**Estimated**: 3 hours
|
||||
|
||||
## Description
|
||||
|
||||
Integrate SignalR to broadcast real-time notifications for Sprint events (Created, Updated, Started, Completed, Deleted) to connected clients.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Domain event handlers created for 5 Sprint events
|
||||
- [ ] IRealtimeNotificationService extended with Sprint methods
|
||||
- [ ] SignalR notifications sent to project groups
|
||||
- [ ] Integration tests verify notifications are sent
|
||||
- [ ] Frontend can receive Sprint notifications
|
||||
|
||||
## Implementation Details
|
||||
|
||||
**Files to Create**:
|
||||
|
||||
1. **Extend Notification Service**: `colaflow-api/src/ColaFlow.Application/Common/Interfaces/IRealtimeNotificationService.cs`
|
||||
```csharp
|
||||
public interface IRealtimeNotificationService
|
||||
{
|
||||
// ... existing methods ...
|
||||
|
||||
// Sprint notifications
|
||||
Task NotifySprintCreatedAsync(Guid sprintId, string sprintName, Guid projectId);
|
||||
Task NotifySprintUpdatedAsync(Guid sprintId, string sprintName, Guid projectId);
|
||||
Task NotifySprintStartedAsync(Guid sprintId, string sprintName, Guid projectId);
|
||||
Task NotifySprintCompletedAsync(Guid sprintId, string sprintName, Guid projectId);
|
||||
Task NotifySprintDeletedAsync(Guid sprintId, string sprintName, Guid projectId);
|
||||
}
|
||||
```
|
||||
|
||||
2. **Implement Service**: `colaflow-api/src/ColaFlow.Infrastructure/SignalR/RealtimeNotificationService.cs`
|
||||
```csharp
|
||||
public class RealtimeNotificationService : IRealtimeNotificationService
|
||||
{
|
||||
private readonly IHubContext<ProjectHub> _projectHubContext;
|
||||
|
||||
// ... existing code ...
|
||||
|
||||
public async Task NotifySprintCreatedAsync(Guid sprintId, string sprintName, Guid projectId)
|
||||
{
|
||||
await _projectHubContext.Clients
|
||||
.Group($"project-{projectId}")
|
||||
.SendAsync("SprintCreated", new
|
||||
{
|
||||
SprintId = sprintId,
|
||||
SprintName = sprintName,
|
||||
ProjectId = projectId,
|
||||
Timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
public async Task NotifySprintUpdatedAsync(Guid sprintId, string sprintName, Guid projectId)
|
||||
{
|
||||
await _projectHubContext.Clients
|
||||
.Group($"project-{projectId}")
|
||||
.SendAsync("SprintUpdated", new
|
||||
{
|
||||
SprintId = sprintId,
|
||||
SprintName = sprintName,
|
||||
ProjectId = projectId,
|
||||
Timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
public async Task NotifySprintStartedAsync(Guid sprintId, string sprintName, Guid projectId)
|
||||
{
|
||||
await _projectHubContext.Clients
|
||||
.Group($"project-{projectId}")
|
||||
.SendAsync("SprintStarted", new
|
||||
{
|
||||
SprintId = sprintId,
|
||||
SprintName = sprintName,
|
||||
ProjectId = projectId,
|
||||
Timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
public async Task NotifySprintCompletedAsync(Guid sprintId, string sprintName, Guid projectId)
|
||||
{
|
||||
await _projectHubContext.Clients
|
||||
.Group($"project-{projectId}")
|
||||
.SendAsync("SprintCompleted", new
|
||||
{
|
||||
SprintId = sprintId,
|
||||
SprintName = sprintName,
|
||||
ProjectId = projectId,
|
||||
Timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
public async Task NotifySprintDeletedAsync(Guid sprintId, string sprintName, Guid projectId)
|
||||
{
|
||||
await _projectHubContext.Clients
|
||||
.Group($"project-{projectId}")
|
||||
.SendAsync("SprintDeleted", new
|
||||
{
|
||||
SprintId = sprintId,
|
||||
SprintName = sprintName,
|
||||
ProjectId = projectId,
|
||||
Timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **Domain Event Handlers**: `colaflow-api/src/ColaFlow.Application/Sprints/EventHandlers/`
|
||||
|
||||
```csharp
|
||||
// SprintCreatedEventHandler.cs
|
||||
public class SprintCreatedEventHandler : INotificationHandler<SprintCreatedEvent>
|
||||
{
|
||||
private readonly IRealtimeNotificationService _notificationService;
|
||||
private readonly ILogger<SprintCreatedEventHandler> _logger;
|
||||
|
||||
public SprintCreatedEventHandler(
|
||||
IRealtimeNotificationService notificationService,
|
||||
ILogger<SprintCreatedEventHandler> logger)
|
||||
{
|
||||
_notificationService = notificationService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task Handle(SprintCreatedEvent notification, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _notificationService.NotifySprintCreatedAsync(
|
||||
notification.SprintId,
|
||||
notification.SprintName,
|
||||
notification.ProjectId
|
||||
);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Real-time notification sent for Sprint created: {SprintId}",
|
||||
notification.SprintId
|
||||
);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Failed to send real-time notification for Sprint created: {SprintId}",
|
||||
notification.SprintId
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SprintUpdatedEventHandler.cs
|
||||
public class SprintUpdatedEventHandler : INotificationHandler<SprintUpdatedEvent>
|
||||
{
|
||||
private readonly IRealtimeNotificationService _notificationService;
|
||||
|
||||
public async Task Handle(SprintUpdatedEvent notification, CancellationToken cancellationToken)
|
||||
{
|
||||
// Get ProjectId from repository
|
||||
await _notificationService.NotifySprintUpdatedAsync(
|
||||
notification.SprintId,
|
||||
notification.SprintName,
|
||||
projectId // Need to get from Sprint
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// SprintStartedEventHandler.cs
|
||||
public class SprintStartedEventHandler : INotificationHandler<SprintStartedEvent>
|
||||
{
|
||||
// Similar implementation
|
||||
}
|
||||
|
||||
// SprintCompletedEventHandler.cs
|
||||
public class SprintCompletedEventHandler : INotificationHandler<SprintCompletedEvent>
|
||||
{
|
||||
// Similar implementation
|
||||
}
|
||||
|
||||
// SprintDeletedEventHandler.cs
|
||||
public class SprintDeletedEventHandler : INotificationHandler<SprintDeletedEvent>
|
||||
{
|
||||
// Similar implementation
|
||||
}
|
||||
```
|
||||
|
||||
4. **Publish Domain Events**: Update Command Handlers to publish domain events
|
||||
|
||||
```csharp
|
||||
// In CreateSprintCommandHandler
|
||||
public async Task<Guid> Handle(CreateSprintCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var sprint = Sprint.Create(...);
|
||||
|
||||
await _repository.AddAsync(sprint, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Publish domain events
|
||||
foreach (var domainEvent in sprint.DomainEvents)
|
||||
{
|
||||
await _mediator.Publish(domainEvent, cancellationToken);
|
||||
}
|
||||
|
||||
sprint.ClearDomainEvents();
|
||||
|
||||
return sprint.Id;
|
||||
}
|
||||
```
|
||||
|
||||
**SignalR Event Types**:
|
||||
1. `SprintCreated` - New sprint created
|
||||
2. `SprintUpdated` - Sprint details updated
|
||||
3. `SprintStarted` - Sprint status changed to Active
|
||||
4. `SprintCompleted` - Sprint status changed to Completed
|
||||
5. `SprintDeleted` - Sprint deleted
|
||||
|
||||
**Frontend Integration**:
|
||||
```typescript
|
||||
// Frontend receives notifications
|
||||
connection.on('SprintCreated', (data) => {
|
||||
console.log('Sprint created:', data);
|
||||
// Update UI, refresh sprint list
|
||||
});
|
||||
|
||||
connection.on('SprintStarted', (data) => {
|
||||
console.log('Sprint started:', data);
|
||||
// Update sprint status in UI
|
||||
});
|
||||
```
|
||||
|
||||
## Technical Notes
|
||||
|
||||
- Use MediatR to publish domain events after SaveChanges
|
||||
- Event handlers are fire-and-forget (don't block command execution)
|
||||
- Log errors if SignalR notification fails
|
||||
- Use project groups for targeted notifications (`project-{projectId}`)
|
||||
- Include timestamp in all notifications
|
||||
|
||||
## Testing
|
||||
|
||||
**Integration Tests**: `colaflow-api/tests/ColaFlow.Application.IntegrationTests/Sprints/SprintSignalRTests.cs`
|
||||
|
||||
```csharp
|
||||
public class SprintSignalRTests : IntegrationTestBase
|
||||
{
|
||||
[Fact]
|
||||
public async Task CreateSprint_ShouldSendSprintCreatedNotification()
|
||||
{
|
||||
// Arrange
|
||||
var notificationService = GetMock<IRealtimeNotificationService>();
|
||||
var command = new CreateSprintCommand
|
||||
{
|
||||
ProjectId = TestProjectId,
|
||||
Name = "Sprint 1",
|
||||
StartDate = DateTime.UtcNow,
|
||||
EndDate = DateTime.UtcNow.AddDays(14)
|
||||
};
|
||||
|
||||
// Act
|
||||
var sprintId = await Mediator.Send(command);
|
||||
|
||||
// Assert
|
||||
notificationService.Verify(
|
||||
s => s.NotifySprintCreatedAsync(sprintId, "Sprint 1", TestProjectId),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartSprint_ShouldSendSprintStartedNotification()
|
||||
{
|
||||
// Arrange
|
||||
var sprintId = await CreateTestSprint();
|
||||
var notificationService = GetMock<IRealtimeNotificationService>();
|
||||
|
||||
// Act
|
||||
await Mediator.Send(new StartSprintCommand(sprintId));
|
||||
|
||||
// Assert
|
||||
notificationService.Verify(
|
||||
s => s.NotifySprintStartedAsync(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<Guid>()),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Created**: 2025-11-05 by Backend Agent
|
||||
415
docs/plans/sprint_2_story_3_task_6.md
Normal file
415
docs/plans/sprint_2_story_3_task_6.md
Normal file
@@ -0,0 +1,415 @@
|
||||
---
|
||||
task_id: sprint_2_story_3_task_6
|
||||
story: sprint_2_story_3
|
||||
status: not_started
|
||||
estimated_hours: 5
|
||||
created_date: 2025-11-05
|
||||
assignee: Backend Team
|
||||
---
|
||||
|
||||
# Task 6: Write Integration Tests
|
||||
|
||||
**Story**: Story 3 - Sprint Management Module
|
||||
**Estimated**: 5 hours
|
||||
|
||||
## Description
|
||||
|
||||
Create comprehensive integration tests for Sprint management functionality including CRUD operations, status transitions, burndown calculation, multi-tenant isolation, and SignalR notifications.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Integration tests for all 9 API endpoints
|
||||
- [ ] Tests for status transitions (Planned → Active → Completed)
|
||||
- [ ] Tests for business rule violations
|
||||
- [ ] Multi-tenant isolation tests
|
||||
- [ ] Burndown calculation tests
|
||||
- [ ] SignalR notification tests
|
||||
- [ ] Test coverage >= 90%
|
||||
- [ ] All tests passing
|
||||
|
||||
## Implementation Details
|
||||
|
||||
**Files to Create**:
|
||||
|
||||
1. **Integration Test Base**: `colaflow-api/tests/ColaFlow.Application.IntegrationTests/Sprints/SprintIntegrationTestBase.cs`
|
||||
```csharp
|
||||
public class SprintIntegrationTestBase : IntegrationTestBase
|
||||
{
|
||||
protected async Task<Guid> CreateTestSprintAsync(
|
||||
Guid? projectId = null,
|
||||
string name = "Test Sprint",
|
||||
int durationDays = 14)
|
||||
{
|
||||
var command = new CreateSprintCommand
|
||||
{
|
||||
ProjectId = projectId ?? TestProjectId,
|
||||
Name = name,
|
||||
Goal = "Test Goal",
|
||||
StartDate = DateTime.UtcNow,
|
||||
EndDate = DateTime.UtcNow.AddDays(durationDays)
|
||||
};
|
||||
|
||||
return await Mediator.Send(command);
|
||||
}
|
||||
|
||||
protected async Task<Sprint> GetSprintAsync(Guid sprintId)
|
||||
{
|
||||
return await Context.Sprints
|
||||
.Include(s => s.Project)
|
||||
.Include(s => s.Tasks)
|
||||
.FirstAsync(s => s.Id == sprintId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **CRUD Tests**: `colaflow-api/tests/ColaFlow.Application.IntegrationTests/Sprints/SprintCrudTests.cs`
|
||||
```csharp
|
||||
public class SprintCrudTests : SprintIntegrationTestBase
|
||||
{
|
||||
[Fact]
|
||||
public async Task CreateSprint_ShouldCreateValidSprint()
|
||||
{
|
||||
// Arrange
|
||||
var command = new CreateSprintCommand
|
||||
{
|
||||
ProjectId = TestProjectId,
|
||||
Name = "Sprint 1",
|
||||
Goal = "Complete Feature X",
|
||||
StartDate = DateTime.UtcNow,
|
||||
EndDate = DateTime.UtcNow.AddDays(14)
|
||||
};
|
||||
|
||||
// Act
|
||||
var sprintId = await Mediator.Send(command);
|
||||
|
||||
// Assert
|
||||
var sprint = await GetSprintAsync(sprintId);
|
||||
Assert.NotNull(sprint);
|
||||
Assert.Equal("Sprint 1", sprint.Name);
|
||||
Assert.Equal(SprintStatus.Planned, sprint.Status);
|
||||
Assert.Equal(TenantId, sprint.TenantId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSprint_ShouldThrowException_WhenEndDateBeforeStartDate()
|
||||
{
|
||||
// Arrange
|
||||
var command = new CreateSprintCommand
|
||||
{
|
||||
ProjectId = TestProjectId,
|
||||
Name = "Invalid Sprint",
|
||||
StartDate = DateTime.UtcNow,
|
||||
EndDate = DateTime.UtcNow.AddDays(-1)
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ValidationException>(() => Mediator.Send(command));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSprint_ShouldUpdateSprintDetails()
|
||||
{
|
||||
// Arrange
|
||||
var sprintId = await CreateTestSprintAsync(name: "Original Name");
|
||||
|
||||
var command = new UpdateSprintCommand
|
||||
{
|
||||
SprintId = sprintId,
|
||||
Name = "Updated Name",
|
||||
Goal = "Updated Goal",
|
||||
StartDate = DateTime.UtcNow,
|
||||
EndDate = DateTime.UtcNow.AddDays(21)
|
||||
};
|
||||
|
||||
// Act
|
||||
await Mediator.Send(command);
|
||||
|
||||
// Assert
|
||||
var sprint = await GetSprintAsync(sprintId);
|
||||
Assert.Equal("Updated Name", sprint.Name);
|
||||
Assert.Equal("Updated Goal", sprint.Goal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSprint_ShouldRemoveSprint()
|
||||
{
|
||||
// Arrange
|
||||
var sprintId = await CreateTestSprintAsync();
|
||||
|
||||
// Act
|
||||
await Mediator.Send(new DeleteSprintCommand(sprintId));
|
||||
|
||||
// Assert
|
||||
var sprint = await Context.Sprints.FindAsync(sprintId);
|
||||
Assert.Null(sprint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSprintById_ShouldReturnSprintWithStatistics()
|
||||
{
|
||||
// Arrange
|
||||
var sprintId = await CreateTestSprintAsync();
|
||||
|
||||
// Add some tasks
|
||||
await CreateTestTaskInSprintAsync(sprintId, storyPoints: 5);
|
||||
await CreateTestTaskInSprintAsync(sprintId, storyPoints: 8);
|
||||
|
||||
// Act
|
||||
var dto = await Mediator.Send(new GetSprintByIdQuery(sprintId));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(dto);
|
||||
Assert.Equal(sprintId, dto.Id);
|
||||
Assert.Equal(2, dto.TotalTasks);
|
||||
Assert.Equal(13, dto.TotalStoryPoints);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **Status Transition Tests**: `colaflow-api/tests/ColaFlow.Application.IntegrationTests/Sprints/SprintStatusTests.cs`
|
||||
```csharp
|
||||
public class SprintStatusTests : SprintIntegrationTestBase
|
||||
{
|
||||
[Fact]
|
||||
public async Task StartSprint_ShouldChangeStatusToActive()
|
||||
{
|
||||
// Arrange
|
||||
var sprintId = await CreateTestSprintAsync();
|
||||
|
||||
// Act
|
||||
await Mediator.Send(new StartSprintCommand(sprintId));
|
||||
|
||||
// Assert
|
||||
var sprint = await GetSprintAsync(sprintId);
|
||||
Assert.Equal(SprintStatus.Active, sprint.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartSprint_ShouldThrowException_WhenAlreadyStarted()
|
||||
{
|
||||
// Arrange
|
||||
var sprintId = await CreateTestSprintAsync();
|
||||
await Mediator.Send(new StartSprintCommand(sprintId));
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => Mediator.Send(new StartSprintCommand(sprintId))
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompleteSprint_ShouldChangeStatusToCompleted()
|
||||
{
|
||||
// Arrange
|
||||
var sprintId = await CreateTestSprintAsync();
|
||||
await Mediator.Send(new StartSprintCommand(sprintId));
|
||||
|
||||
// Act
|
||||
await Mediator.Send(new CompleteSprintCommand(sprintId));
|
||||
|
||||
// Assert
|
||||
var sprint = await GetSprintAsync(sprintId);
|
||||
Assert.Equal(SprintStatus.Completed, sprint.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompleteSprint_ShouldThrowException_WhenNotActive()
|
||||
{
|
||||
// Arrange
|
||||
var sprintId = await CreateTestSprintAsync(); // Status = Planned
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => Mediator.Send(new CompleteSprintCommand(sprintId))
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSprint_ShouldThrowException_WhenCompleted()
|
||||
{
|
||||
// Arrange
|
||||
var sprintId = await CreateTestSprintAsync();
|
||||
await Mediator.Send(new StartSprintCommand(sprintId));
|
||||
await Mediator.Send(new CompleteSprintCommand(sprintId));
|
||||
|
||||
var command = new UpdateSprintCommand
|
||||
{
|
||||
SprintId = sprintId,
|
||||
Name = "New Name",
|
||||
StartDate = DateTime.UtcNow,
|
||||
EndDate = DateTime.UtcNow.AddDays(14)
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => Mediator.Send(command));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. **Multi-Tenant Tests**: `colaflow-api/tests/ColaFlow.Application.IntegrationTests/Sprints/SprintMultiTenantTests.cs`
|
||||
```csharp
|
||||
public class SprintMultiTenantTests : SprintIntegrationTestBase
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetSprintById_ShouldOnlyReturnCurrentTenantSprint()
|
||||
{
|
||||
// Arrange
|
||||
var tenant1Id = Guid.NewGuid();
|
||||
var tenant2Id = Guid.NewGuid();
|
||||
|
||||
SetCurrentTenant(tenant1Id);
|
||||
var sprint1Id = await CreateTestSprintAsync();
|
||||
|
||||
SetCurrentTenant(tenant2Id);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<NotFoundException>(
|
||||
() => Mediator.Send(new GetSprintByIdQuery(sprint1Id))
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSprintsByProjectId_ShouldFilterByTenant()
|
||||
{
|
||||
// Arrange
|
||||
var tenant1Id = Guid.NewGuid();
|
||||
var tenant2Id = Guid.NewGuid();
|
||||
|
||||
SetCurrentTenant(tenant1Id);
|
||||
var project1Id = await CreateTestProjectAsync();
|
||||
await CreateTestSprintAsync(project1Id, "Tenant 1 Sprint");
|
||||
|
||||
SetCurrentTenant(tenant2Id);
|
||||
var project2Id = await CreateTestProjectAsync();
|
||||
await CreateTestSprintAsync(project2Id, "Tenant 2 Sprint");
|
||||
|
||||
// Act
|
||||
var sprints = await Mediator.Send(new GetSprintsByProjectIdQuery(project2Id));
|
||||
|
||||
// Assert
|
||||
Assert.Single(sprints);
|
||||
Assert.Equal("Tenant 2 Sprint", sprints[0].Name);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
5. **Burndown Tests**: `colaflow-api/tests/ColaFlow.Application.IntegrationTests/Sprints/SprintBurndownTests.cs`
|
||||
```csharp
|
||||
public class SprintBurndownTests : SprintIntegrationTestBase
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetSprintBurndown_ShouldCalculateIdealBurndown()
|
||||
{
|
||||
// Arrange
|
||||
var sprintId = await CreateTestSprintAsync(durationDays: 14);
|
||||
|
||||
// Act
|
||||
var burndown = await Mediator.Send(new GetSprintBurndownQuery(sprintId));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(burndown.IdealBurndown);
|
||||
Assert.Equal(15, burndown.IdealBurndown.Count); // 14 days + 1 (start day)
|
||||
Assert.True(burndown.IdealBurndown.First().StoryPoints >= burndown.IdealBurndown.Last().StoryPoints);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSprintBurndown_ShouldCalculateActualBurndown()
|
||||
{
|
||||
// Arrange
|
||||
var sprintId = await CreateTestSprintAsync();
|
||||
|
||||
// Add tasks
|
||||
await CreateTestTaskInSprintAsync(sprintId, storyPoints: 5, status: WorkTaskStatus.Done);
|
||||
await CreateTestTaskInSprintAsync(sprintId, storyPoints: 8, status: WorkTaskStatus.InProgress);
|
||||
await CreateTestTaskInSprintAsync(sprintId, storyPoints: 3, status: WorkTaskStatus.Todo);
|
||||
|
||||
// Act
|
||||
var burndown = await Mediator.Send(new GetSprintBurndownQuery(sprintId));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(16, burndown.TotalStoryPoints);
|
||||
Assert.Equal(11, burndown.RemainingStoryPoints); // 8 + 3
|
||||
Assert.Equal(31.25, burndown.CompletionPercentage, 2); // 5/16 = 31.25%
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
6. **API Tests**: `colaflow-api/tests/ColaFlow.API.IntegrationTests/Sprints/SprintsControllerTests.cs`
|
||||
```csharp
|
||||
public class SprintsControllerTests : ApiIntegrationTestBase
|
||||
{
|
||||
[Fact]
|
||||
public async Task CreateSprint_ShouldReturn201Created()
|
||||
{
|
||||
// Arrange
|
||||
var command = new CreateSprintCommand
|
||||
{
|
||||
ProjectId = TestProjectId,
|
||||
Name = "Sprint 1",
|
||||
StartDate = DateTime.UtcNow,
|
||||
EndDate = DateTime.UtcNow.AddDays(14)
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await Client.PostAsJsonAsync("/api/sprints", command);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Created);
|
||||
var sprintId = await response.Content.ReadFromJsonAsync<Guid>();
|
||||
Assert.NotEqual(Guid.Empty, sprintId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSprints_WithProjectIdFilter_ShouldReturnFilteredSprints()
|
||||
{
|
||||
// Arrange
|
||||
var projectId = await CreateTestProjectAsync();
|
||||
await CreateTestSprintAsync(projectId, "Sprint 1");
|
||||
await CreateTestSprintAsync(projectId, "Sprint 2");
|
||||
|
||||
// Act
|
||||
var response = await Client.GetAsync($"/api/sprints?projectId={projectId}");
|
||||
|
||||
// Assert
|
||||
response.EnsureSuccessStatusCode();
|
||||
var sprints = await response.Content.ReadFromJsonAsync<List<SprintDto>>();
|
||||
Assert.Equal(2, sprints.Count);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Test Coverage Goals
|
||||
|
||||
| Component | Coverage Target |
|
||||
|-----------|----------------|
|
||||
| Sprint Entity | >= 95% |
|
||||
| Sprint Repository | >= 90% |
|
||||
| Command Handlers | >= 90% |
|
||||
| Query Handlers | >= 90% |
|
||||
| Event Handlers | >= 85% |
|
||||
| Controllers | >= 85% |
|
||||
|
||||
## Testing Commands
|
||||
|
||||
```bash
|
||||
# Run all sprint tests
|
||||
dotnet test --filter "FullyQualifiedName~Sprint"
|
||||
|
||||
# Run specific test file
|
||||
dotnet test --filter "FullyQualifiedName~SprintCrudTests"
|
||||
|
||||
# Run with coverage
|
||||
dotnet test --collect:"XPlat Code Coverage"
|
||||
```
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- All test categories implemented (CRUD, Status, Multi-Tenant, Burndown, API)
|
||||
- >= 90% code coverage achieved
|
||||
- All tests passing
|
||||
- Integration with CI/CD pipeline
|
||||
- Performance tests verify acceptable response times
|
||||
|
||||
---
|
||||
|
||||
**Created**: 2025-11-05 by Backend Agent
|
||||
Reference in New Issue
Block a user