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>
11 KiB
11 KiB
task_id, story, status, estimated_hours, created_date, assignee
| task_id | story | status | estimated_hours | created_date | assignee |
|---|---|---|---|---|---|
| sprint_2_story_3_task_3 | sprint_2_story_3 | not_started | 6 | 2025-11-05 | 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:
- CreateSprintCommand
- UpdateSprintCommand
- DeleteSprintCommand
- StartSprintCommand
- CompleteSprintCommand
- AddTaskToSprintCommand (Bonus)
- RemoveTaskFromSprintCommand (Bonus)
Queries to Implement:
- GetSprintByIdQuery
- GetSprintsByProjectIdQuery
- GetActiveSprintsQuery
- GetSprintBurndownQuery
Files to Create:
- Create Sprint:
colaflow-api/src/ColaFlow.Application/Sprints/Commands/CreateSprint/
// 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;
}
}
- Update Sprint:
colaflow-api/src/ColaFlow.Application/Sprints/Commands/UpdateSprint/
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);
}
}
- Start/Complete Sprint:
colaflow-api/src/ColaFlow.Application/Sprints/Commands/
// 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);
}
}
- Get Sprint Query:
colaflow-api/src/ColaFlow.Application/Sprints/Queries/GetSprintById/
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;
}
}
- Controller:
colaflow-api/src/ColaFlow.API/Controllers/SprintsController.cs
[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