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:
Yaojia Wang
2025-11-04 22:56:31 +01:00
parent d6cf86a4da
commit ebb56cc9f8
19 changed files with 4030 additions and 0 deletions

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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