--- 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 { 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 { 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 { private readonly ISprintRepository _repository; private readonly IProjectRepository _projectRepository; private readonly ITenantContext _tenantContext; private readonly ILogger _logger; public CreateSprintCommandHandler( ISprintRepository repository, IProjectRepository projectRepository, ITenantContext tenantContext, ILogger logger) { _repository = repository; _projectRepository = projectRepository; _tenantContext = tenantContext; _logger = logger; } public async Task 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 { 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 { 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 { 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; 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 { private readonly ISprintRepository _repository; private readonly IMapper _mapper; public async Task 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(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 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 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 DeleteSprint([FromRoute] Guid id) { await _mediator.Send(new DeleteSprintCommand(id)); return NoContent(); } [HttpGet("{id}")] [ProducesResponseType(typeof(SprintDto), StatusCodes.Status200OK)] public async Task GetSprintById([FromRoute] Guid id) { var sprint = await _mediator.Send(new GetSprintByIdQuery(id)); return Ok(sprint); } [HttpGet] [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] public async Task 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 StartSprint([FromRoute] Guid id) { await _mediator.Send(new StartSprintCommand(id)); return NoContent(); } [HttpPost("{id}/complete")] [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task 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 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