Files
ColaFlow/docs/plans/sprint_2_story_3_task_3.md
Yaojia Wang ebb56cc9f8 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>
2025-11-04 22:56:31 +01:00

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:

  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/
// 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;
    }
}
  1. 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);
    }
}
  1. 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);
    }
}
  1. 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;
    }
}
  1. 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