feat(backend): Implement Sprint CQRS Commands and Queries (Task 3)
Implemented comprehensive CQRS pattern for Sprint module: Commands: - UpdateSprintCommand: Update sprint details with validation - DeleteSprintCommand: Delete sprints (business rule: cannot delete active sprints) - StartSprintCommand: Transition sprint from Planned to Active - CompleteSprintCommand: Transition sprint from Active to Completed - AddTaskToSprintCommand: Add tasks to sprint with validation - RemoveTaskFromSprintCommand: Remove tasks from sprint Queries: - GetSprintByIdQuery: Get sprint by ID with DTO mapping - GetSprintsByProjectIdQuery: Get all sprints for a project - GetActiveSprintsQuery: Get all active sprints across projects Infrastructure: - Created IApplicationDbContext interface for Application layer DB access - Registered IApplicationDbContext in DI container - Added Microsoft.EntityFrameworkCore package to Application layer - Updated UnitOfWork to expose GetDbContext() method API: - Created SprintsController with all CRUD and lifecycle endpoints - Implemented proper HTTP methods (POST, PUT, DELETE, GET) - Added sprint status transition endpoints (start, complete) - Added task management endpoints (add/remove tasks) All tests passing. Ready for Tasks 4-6. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
137
colaflow-api/src/ColaFlow.API/Controllers/SprintsController.cs
Normal file
137
colaflow-api/src/ColaFlow.API/Controllers/SprintsController.cs
Normal file
@@ -0,0 +1,137 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.Commands.CreateSprint;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.Commands.UpdateSprint;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.Commands.DeleteSprint;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.Commands.StartSprint;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.Commands.CompleteSprint;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.Commands.AddTaskToSprint;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.Commands.RemoveTaskFromSprint;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.Queries.GetSprintById;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.Queries.GetSprintsByProjectId;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.Queries.GetActiveSprints;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.DTOs;
|
||||
|
||||
namespace ColaFlow.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Sprint management endpoints
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/sprints")]
|
||||
[Authorize]
|
||||
public class SprintsController : ControllerBase
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
|
||||
public SprintsController(IMediator mediator)
|
||||
{
|
||||
_mediator = mediator;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a new sprint
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<SprintDto>> Create([FromBody] CreateSprintCommand command)
|
||||
{
|
||||
var result = await _mediator.Send(command);
|
||||
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update an existing sprint
|
||||
/// </summary>
|
||||
[HttpPut("{id}")]
|
||||
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateSprintCommand command)
|
||||
{
|
||||
if (id != command.SprintId)
|
||||
return BadRequest("Sprint ID mismatch");
|
||||
|
||||
await _mediator.Send(command);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete a sprint
|
||||
/// </summary>
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> Delete(Guid id)
|
||||
{
|
||||
await _mediator.Send(new DeleteSprintCommand(id));
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get sprint by ID
|
||||
/// </summary>
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<SprintDto>> GetById(Guid id)
|
||||
{
|
||||
var result = await _mediator.Send(new GetSprintByIdQuery(id));
|
||||
if (result == null)
|
||||
return NotFound();
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all sprints for a project
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<IReadOnlyList<SprintDto>>> GetByProject([FromQuery] Guid projectId)
|
||||
{
|
||||
var result = await _mediator.Send(new GetSprintsByProjectIdQuery(projectId));
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all active sprints
|
||||
/// </summary>
|
||||
[HttpGet("active")]
|
||||
public async Task<ActionResult<IReadOnlyList<SprintDto>>> GetActive()
|
||||
{
|
||||
var result = await _mediator.Send(new GetActiveSprintsQuery());
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Start a sprint (Planned to Active)
|
||||
/// </summary>
|
||||
[HttpPost("{id}/start")]
|
||||
public async Task<IActionResult> Start(Guid id)
|
||||
{
|
||||
await _mediator.Send(new StartSprintCommand(id));
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Complete a sprint (Active to Completed)
|
||||
/// </summary>
|
||||
[HttpPost("{id}/complete")]
|
||||
public async Task<IActionResult> Complete(Guid id)
|
||||
{
|
||||
await _mediator.Send(new CompleteSprintCommand(id));
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add a task to a sprint
|
||||
/// </summary>
|
||||
[HttpPost("{id}/tasks/{taskId}")]
|
||||
public async Task<IActionResult> AddTask(Guid id, Guid taskId)
|
||||
{
|
||||
await _mediator.Send(new AddTaskToSprintCommand(id, taskId));
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remove a task from a sprint
|
||||
/// </summary>
|
||||
[HttpDelete("{id}/tasks/{taskId}")]
|
||||
public async Task<IActionResult> RemoveTask(Guid id, Guid taskId)
|
||||
{
|
||||
await _mediator.Send(new RemoveTaskFromSprintCommand(id, taskId));
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
<PackageReference Include="MediatR" Version="13.1.0" />
|
||||
<PackageReference Include="FluentValidation" Version="11.10.0" />
|
||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.10.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
using MediatR;
|
||||
|
||||
namespace ColaFlow.Modules.ProjectManagement.Application.Commands.AddTaskToSprint;
|
||||
|
||||
/// <summary>
|
||||
/// Command to add a task to a sprint
|
||||
/// </summary>
|
||||
public sealed record AddTaskToSprintCommand(Guid SprintId, Guid TaskId) : IRequest<Unit>;
|
||||
@@ -0,0 +1,47 @@
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Exceptions;
|
||||
|
||||
namespace ColaFlow.Modules.ProjectManagement.Application.Commands.AddTaskToSprint;
|
||||
|
||||
/// <summary>
|
||||
/// Handler for AddTaskToSprintCommand
|
||||
/// </summary>
|
||||
public sealed class AddTaskToSprintCommandHandler(
|
||||
IApplicationDbContext context,
|
||||
IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<AddTaskToSprintCommand, Unit>
|
||||
{
|
||||
private readonly IApplicationDbContext _context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
private readonly IUnitOfWork _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
|
||||
|
||||
public async Task<Unit> Handle(AddTaskToSprintCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Get sprint with tracking
|
||||
var sprintId = SprintId.From(request.SprintId);
|
||||
var sprint = await _context.Sprints
|
||||
.FirstOrDefaultAsync(s => s.Id == sprintId, cancellationToken);
|
||||
|
||||
if (sprint == null)
|
||||
throw new NotFoundException("Sprint", request.SprintId);
|
||||
|
||||
// Verify task exists
|
||||
var taskId = TaskId.From(request.TaskId);
|
||||
var taskExists = await _context.Tasks
|
||||
.AnyAsync(t => t.Id == taskId, cancellationToken);
|
||||
|
||||
if (!taskExists)
|
||||
throw new NotFoundException("Task", request.TaskId);
|
||||
|
||||
// Add task to sprint
|
||||
sprint.AddTask(taskId);
|
||||
|
||||
// Save changes
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Unit.Value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using MediatR;
|
||||
|
||||
namespace ColaFlow.Modules.ProjectManagement.Application.Commands.CompleteSprint;
|
||||
|
||||
/// <summary>
|
||||
/// Command to complete a Sprint (Active → Completed)
|
||||
/// </summary>
|
||||
public sealed record CompleteSprintCommand(Guid SprintId) : IRequest<Unit>;
|
||||
@@ -0,0 +1,39 @@
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Exceptions;
|
||||
|
||||
namespace ColaFlow.Modules.ProjectManagement.Application.Commands.CompleteSprint;
|
||||
|
||||
/// <summary>
|
||||
/// Handler for CompleteSprintCommand
|
||||
/// </summary>
|
||||
public sealed class CompleteSprintCommandHandler(
|
||||
IApplicationDbContext context,
|
||||
IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<CompleteSprintCommand, Unit>
|
||||
{
|
||||
private readonly IApplicationDbContext _context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
private readonly IUnitOfWork _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
|
||||
|
||||
public async Task<Unit> Handle(CompleteSprintCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Get sprint with tracking
|
||||
var sprintId = SprintId.From(request.SprintId);
|
||||
var sprint = await _context.Sprints
|
||||
.FirstOrDefaultAsync(s => s.Id == sprintId, cancellationToken);
|
||||
|
||||
if (sprint == null)
|
||||
throw new NotFoundException("Sprint", request.SprintId);
|
||||
|
||||
// Complete sprint (business rules enforced in domain)
|
||||
sprint.Complete();
|
||||
|
||||
// Save changes
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Unit.Value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using MediatR;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.DTOs;
|
||||
|
||||
namespace ColaFlow.Modules.ProjectManagement.Application.Commands.CreateSprint;
|
||||
|
||||
/// <summary>
|
||||
/// Command to create a new Sprint
|
||||
/// </summary>
|
||||
public sealed record CreateSprintCommand : IRequest<SprintDto>
|
||||
{
|
||||
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; }
|
||||
public Guid CreatedBy { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using MediatR;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.DTOs;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Exceptions;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Events;
|
||||
|
||||
namespace ColaFlow.Modules.ProjectManagement.Application.Commands.CreateSprint;
|
||||
|
||||
/// <summary>
|
||||
/// Handler for CreateSprintCommand
|
||||
/// </summary>
|
||||
public sealed class CreateSprintCommandHandler(
|
||||
IProjectRepository projectRepository,
|
||||
IApplicationDbContext context,
|
||||
IUnitOfWork unitOfWork,
|
||||
IMediator mediator)
|
||||
: IRequestHandler<CreateSprintCommand, SprintDto>
|
||||
{
|
||||
private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
||||
private readonly IApplicationDbContext _context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
private readonly IUnitOfWork _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
|
||||
private readonly IMediator _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
||||
|
||||
public async Task<SprintDto> Handle(CreateSprintCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Verify project exists (Global Query Filter ensures tenant isolation)
|
||||
var projectId = ProjectId.From(request.ProjectId);
|
||||
var project = await _projectRepository.GetByIdAsync(projectId, cancellationToken);
|
||||
|
||||
if (project == null)
|
||||
throw new NotFoundException("Project", request.ProjectId);
|
||||
|
||||
// Create sprint
|
||||
var createdById = UserId.From(request.CreatedBy);
|
||||
var sprint = Sprint.Create(
|
||||
project.TenantId,
|
||||
projectId,
|
||||
request.Name,
|
||||
request.Goal,
|
||||
request.StartDate,
|
||||
request.EndDate,
|
||||
createdById
|
||||
);
|
||||
|
||||
// Add to context
|
||||
await _context.Sprints.AddAsync(sprint, cancellationToken);
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Publish domain event
|
||||
await _mediator.Publish(new SprintCreatedEvent(sprint.Id.Value, sprint.Name, projectId.Value), cancellationToken);
|
||||
|
||||
// Map to DTO
|
||||
return new SprintDto
|
||||
{
|
||||
Id = sprint.Id.Value,
|
||||
ProjectId = sprint.ProjectId.Value,
|
||||
ProjectName = project.Name,
|
||||
Name = sprint.Name,
|
||||
Goal = sprint.Goal,
|
||||
StartDate = sprint.StartDate,
|
||||
EndDate = sprint.EndDate,
|
||||
Status = sprint.Status.Name,
|
||||
TotalTasks = 0,
|
||||
CompletedTasks = 0,
|
||||
TotalStoryPoints = 0,
|
||||
RemainingStoryPoints = 0,
|
||||
TaskIds = new List<Guid>(),
|
||||
CreatedAt = sprint.CreatedAt,
|
||||
CreatedBy = sprint.CreatedBy.Value,
|
||||
UpdatedAt = sprint.UpdatedAt
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace ColaFlow.Modules.ProjectManagement.Application.Commands.CreateSprint;
|
||||
|
||||
/// <summary>
|
||||
/// Validator for CreateSprintCommand
|
||||
/// </summary>
|
||||
public sealed 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(200).WithMessage("Name must not exceed 200 characters");
|
||||
|
||||
RuleFor(x => x.Goal)
|
||||
.MaximumLength(1000).WithMessage("Goal must not exceed 1000 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 >= 1)
|
||||
.WithMessage("Sprint duration must be at least 1 day")
|
||||
.Must(x => (x.EndDate - x.StartDate).TotalDays <= 30)
|
||||
.WithMessage("Sprint duration cannot exceed 30 days");
|
||||
|
||||
RuleFor(x => x.CreatedBy)
|
||||
.NotEmpty().WithMessage("CreatedBy is required");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using MediatR;
|
||||
|
||||
namespace ColaFlow.Modules.ProjectManagement.Application.Commands.DeleteSprint;
|
||||
|
||||
/// <summary>
|
||||
/// Command to delete a Sprint
|
||||
/// </summary>
|
||||
public sealed record DeleteSprintCommand(Guid SprintId) : IRequest<Unit>;
|
||||
@@ -0,0 +1,44 @@
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Exceptions;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Enums;
|
||||
|
||||
namespace ColaFlow.Modules.ProjectManagement.Application.Commands.DeleteSprint;
|
||||
|
||||
/// <summary>
|
||||
/// Handler for DeleteSprintCommand
|
||||
/// </summary>
|
||||
public sealed class DeleteSprintCommandHandler(
|
||||
IApplicationDbContext context,
|
||||
IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<DeleteSprintCommand, Unit>
|
||||
{
|
||||
private readonly IApplicationDbContext _context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
private readonly IUnitOfWork _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
|
||||
|
||||
public async Task<Unit> Handle(DeleteSprintCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Get sprint with tracking
|
||||
var sprintId = SprintId.From(request.SprintId);
|
||||
var sprint = await _context.Sprints
|
||||
.FirstOrDefaultAsync(s => s.Id == sprintId, cancellationToken);
|
||||
|
||||
if (sprint == null)
|
||||
throw new NotFoundException("Sprint", request.SprintId);
|
||||
|
||||
// Business rule: Cannot delete Active sprints
|
||||
if (sprint.Status.Name == SprintStatus.Active.Name)
|
||||
throw new DomainException("Cannot delete an active sprint. Please complete it first.");
|
||||
|
||||
// Remove sprint
|
||||
_context.Sprints.Remove(sprint);
|
||||
|
||||
// Save changes
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Unit.Value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using MediatR;
|
||||
|
||||
namespace ColaFlow.Modules.ProjectManagement.Application.Commands.RemoveTaskFromSprint;
|
||||
|
||||
/// <summary>
|
||||
/// Command to remove a task from a sprint
|
||||
/// </summary>
|
||||
public sealed record RemoveTaskFromSprintCommand(Guid SprintId, Guid TaskId) : IRequest<Unit>;
|
||||
@@ -0,0 +1,40 @@
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Exceptions;
|
||||
|
||||
namespace ColaFlow.Modules.ProjectManagement.Application.Commands.RemoveTaskFromSprint;
|
||||
|
||||
/// <summary>
|
||||
/// Handler for RemoveTaskFromSprintCommand
|
||||
/// </summary>
|
||||
public sealed class RemoveTaskFromSprintCommandHandler(
|
||||
IApplicationDbContext context,
|
||||
IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<RemoveTaskFromSprintCommand, Unit>
|
||||
{
|
||||
private readonly IApplicationDbContext _context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
private readonly IUnitOfWork _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
|
||||
|
||||
public async Task<Unit> Handle(RemoveTaskFromSprintCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Get sprint with tracking
|
||||
var sprintId = SprintId.From(request.SprintId);
|
||||
var sprint = await _context.Sprints
|
||||
.FirstOrDefaultAsync(s => s.Id == sprintId, cancellationToken);
|
||||
|
||||
if (sprint == null)
|
||||
throw new NotFoundException("Sprint", request.SprintId);
|
||||
|
||||
// Remove task from sprint
|
||||
var taskId = TaskId.From(request.TaskId);
|
||||
sprint.RemoveTask(taskId);
|
||||
|
||||
// Save changes
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Unit.Value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using MediatR;
|
||||
|
||||
namespace ColaFlow.Modules.ProjectManagement.Application.Commands.StartSprint;
|
||||
|
||||
/// <summary>
|
||||
/// Command to start a Sprint (Planned → Active)
|
||||
/// </summary>
|
||||
public sealed record StartSprintCommand(Guid SprintId) : IRequest<Unit>;
|
||||
@@ -0,0 +1,39 @@
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Exceptions;
|
||||
|
||||
namespace ColaFlow.Modules.ProjectManagement.Application.Commands.StartSprint;
|
||||
|
||||
/// <summary>
|
||||
/// Handler for StartSprintCommand
|
||||
/// </summary>
|
||||
public sealed class StartSprintCommandHandler(
|
||||
IApplicationDbContext context,
|
||||
IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<StartSprintCommand, Unit>
|
||||
{
|
||||
private readonly IApplicationDbContext _context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
private readonly IUnitOfWork _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
|
||||
|
||||
public async Task<Unit> Handle(StartSprintCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Get sprint with tracking
|
||||
var sprintId = SprintId.From(request.SprintId);
|
||||
var sprint = await _context.Sprints
|
||||
.FirstOrDefaultAsync(s => s.Id == sprintId, cancellationToken);
|
||||
|
||||
if (sprint == null)
|
||||
throw new NotFoundException("Sprint", request.SprintId);
|
||||
|
||||
// Start sprint (business rules enforced in domain)
|
||||
sprint.Start();
|
||||
|
||||
// Save changes
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Unit.Value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using MediatR;
|
||||
|
||||
namespace ColaFlow.Modules.ProjectManagement.Application.Commands.UpdateSprint;
|
||||
|
||||
/// <summary>
|
||||
/// Command to update an existing Sprint
|
||||
/// </summary>
|
||||
public sealed record UpdateSprintCommand : IRequest<Unit>
|
||||
{
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Exceptions;
|
||||
|
||||
namespace ColaFlow.Modules.ProjectManagement.Application.Commands.UpdateSprint;
|
||||
|
||||
/// <summary>
|
||||
/// Handler for UpdateSprintCommand
|
||||
/// </summary>
|
||||
public sealed class UpdateSprintCommandHandler(
|
||||
IApplicationDbContext context,
|
||||
IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<UpdateSprintCommand, Unit>
|
||||
{
|
||||
private readonly IApplicationDbContext _context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
private readonly IUnitOfWork _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
|
||||
|
||||
public async Task<Unit> Handle(UpdateSprintCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Get sprint with tracking
|
||||
var sprintId = SprintId.From(request.SprintId);
|
||||
var sprint = await _context.Sprints
|
||||
.FirstOrDefaultAsync(s => s.Id == sprintId, cancellationToken);
|
||||
|
||||
if (sprint == null)
|
||||
throw new NotFoundException("Sprint", request.SprintId);
|
||||
|
||||
// Update sprint details
|
||||
sprint.UpdateDetails(
|
||||
request.Name,
|
||||
request.Goal,
|
||||
request.StartDate,
|
||||
request.EndDate
|
||||
);
|
||||
|
||||
// Save changes
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Unit.Value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace ColaFlow.Modules.ProjectManagement.Application.Commands.UpdateSprint;
|
||||
|
||||
/// <summary>
|
||||
/// Validator for UpdateSprintCommand
|
||||
/// </summary>
|
||||
public sealed class UpdateSprintCommandValidator : AbstractValidator<UpdateSprintCommand>
|
||||
{
|
||||
public UpdateSprintCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.SprintId)
|
||||
.NotEmpty()
|
||||
.WithMessage("Sprint ID is required");
|
||||
|
||||
RuleFor(x => x.Name)
|
||||
.NotEmpty()
|
||||
.WithMessage("Sprint name is required")
|
||||
.MaximumLength(200)
|
||||
.WithMessage("Sprint name cannot exceed 200 characters");
|
||||
|
||||
RuleFor(x => x.StartDate)
|
||||
.NotEmpty()
|
||||
.WithMessage("Start date is required");
|
||||
|
||||
RuleFor(x => x.EndDate)
|
||||
.NotEmpty()
|
||||
.WithMessage("End date is required")
|
||||
.GreaterThan(x => x.StartDate)
|
||||
.WithMessage("End date must be after start date");
|
||||
|
||||
RuleFor(x => x)
|
||||
.Must(x => (x.EndDate - x.StartDate).Days <= 30)
|
||||
.WithMessage("Sprint duration cannot exceed 30 days")
|
||||
.Must(x => (x.EndDate - x.StartDate).Days >= 1)
|
||||
.WithMessage("Sprint duration must be at least 1 day");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Entities;
|
||||
|
||||
namespace ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Application database context interface for direct access to DbSets
|
||||
/// </summary>
|
||||
public interface IApplicationDbContext
|
||||
{
|
||||
DbSet<Project> Projects { get; }
|
||||
DbSet<Epic> Epics { get; }
|
||||
DbSet<Story> Stories { get; }
|
||||
DbSet<WorkTask> Tasks { get; }
|
||||
DbSet<Sprint> Sprints { get; }
|
||||
DbSet<AuditLog> AuditLogs { get; }
|
||||
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace ColaFlow.Modules.ProjectManagement.Application.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// Sprint Data Transfer Object
|
||||
/// </summary>
|
||||
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 List<Guid> TaskIds { get; set; } = new();
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public Guid CreatedBy { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using MediatR;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.DTOs;
|
||||
|
||||
namespace ColaFlow.Modules.ProjectManagement.Application.Queries.GetActiveSprints;
|
||||
|
||||
/// <summary>
|
||||
/// Query to get all active sprints
|
||||
/// </summary>
|
||||
public sealed record GetActiveSprintsQuery : IRequest<IReadOnlyList<SprintDto>>;
|
||||
@@ -0,0 +1,39 @@
|
||||
using MediatR;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.DTOs;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
|
||||
|
||||
namespace ColaFlow.Modules.ProjectManagement.Application.Queries.GetActiveSprints;
|
||||
|
||||
/// <summary>
|
||||
/// Handler for GetActiveSprintsQuery
|
||||
/// </summary>
|
||||
public sealed class GetActiveSprintsQueryHandler(IProjectRepository projectRepository)
|
||||
: IRequestHandler<GetActiveSprintsQuery, IReadOnlyList<SprintDto>>
|
||||
{
|
||||
private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
||||
|
||||
public async Task<IReadOnlyList<SprintDto>> Handle(GetActiveSprintsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var sprints = await _projectRepository.GetActiveSprintsAsync(cancellationToken);
|
||||
|
||||
return sprints.Select(sprint => new SprintDto
|
||||
{
|
||||
Id = sprint.Id.Value,
|
||||
ProjectId = sprint.ProjectId.Value,
|
||||
ProjectName = string.Empty, // Could join with project if needed
|
||||
Name = sprint.Name,
|
||||
Goal = sprint.Goal,
|
||||
StartDate = sprint.StartDate,
|
||||
EndDate = sprint.EndDate,
|
||||
Status = sprint.Status.Name,
|
||||
TotalTasks = sprint.TaskIds.Count,
|
||||
CompletedTasks = 0, // TODO: Calculate from tasks
|
||||
TotalStoryPoints = 0, // TODO: Calculate from tasks
|
||||
RemainingStoryPoints = 0, // TODO: Calculate from tasks
|
||||
TaskIds = sprint.TaskIds.Select(t => t.Value).ToList(),
|
||||
CreatedAt = sprint.CreatedAt,
|
||||
CreatedBy = sprint.CreatedBy.Value,
|
||||
UpdatedAt = sprint.UpdatedAt
|
||||
}).ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using MediatR;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.DTOs;
|
||||
|
||||
namespace ColaFlow.Modules.ProjectManagement.Application.Queries.GetSprintById;
|
||||
|
||||
/// <summary>
|
||||
/// Query to get a sprint by ID
|
||||
/// </summary>
|
||||
public sealed record GetSprintByIdQuery(Guid SprintId) : IRequest<SprintDto?>;
|
||||
@@ -0,0 +1,47 @@
|
||||
using MediatR;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.DTOs;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
|
||||
|
||||
namespace ColaFlow.Modules.ProjectManagement.Application.Queries.GetSprintById;
|
||||
|
||||
/// <summary>
|
||||
/// Handler for GetSprintByIdQuery
|
||||
/// </summary>
|
||||
public sealed class GetSprintByIdQueryHandler(IProjectRepository projectRepository)
|
||||
: IRequestHandler<GetSprintByIdQuery, SprintDto?>
|
||||
{
|
||||
private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
||||
|
||||
public async Task<SprintDto?> Handle(GetSprintByIdQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var sprintId = SprintId.From(request.SprintId);
|
||||
var sprint = await _projectRepository.GetSprintByIdReadOnlyAsync(sprintId, cancellationToken);
|
||||
|
||||
if (sprint == null)
|
||||
return null;
|
||||
|
||||
// Get project name
|
||||
var project = await _projectRepository.GetByIdAsync(sprint.ProjectId, cancellationToken);
|
||||
|
||||
return new SprintDto
|
||||
{
|
||||
Id = sprint.Id.Value,
|
||||
ProjectId = sprint.ProjectId.Value,
|
||||
ProjectName = project?.Name ?? string.Empty,
|
||||
Name = sprint.Name,
|
||||
Goal = sprint.Goal,
|
||||
StartDate = sprint.StartDate,
|
||||
EndDate = sprint.EndDate,
|
||||
Status = sprint.Status.Name,
|
||||
TotalTasks = sprint.TaskIds.Count,
|
||||
CompletedTasks = 0, // TODO: Calculate from tasks
|
||||
TotalStoryPoints = 0, // TODO: Calculate from tasks
|
||||
RemainingStoryPoints = 0, // TODO: Calculate from tasks
|
||||
TaskIds = sprint.TaskIds.Select(t => t.Value).ToList(),
|
||||
CreatedAt = sprint.CreatedAt,
|
||||
CreatedBy = sprint.CreatedBy.Value,
|
||||
UpdatedAt = sprint.UpdatedAt
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using MediatR;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.DTOs;
|
||||
|
||||
namespace ColaFlow.Modules.ProjectManagement.Application.Queries.GetSprintsByProjectId;
|
||||
|
||||
/// <summary>
|
||||
/// Query to get all sprints for a project
|
||||
/// </summary>
|
||||
public sealed record GetSprintsByProjectIdQuery(Guid ProjectId) : IRequest<IReadOnlyList<SprintDto>>;
|
||||
@@ -0,0 +1,45 @@
|
||||
using MediatR;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.DTOs;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
|
||||
|
||||
namespace ColaFlow.Modules.ProjectManagement.Application.Queries.GetSprintsByProjectId;
|
||||
|
||||
/// <summary>
|
||||
/// Handler for GetSprintsByProjectIdQuery
|
||||
/// </summary>
|
||||
public sealed class GetSprintsByProjectIdQueryHandler(IProjectRepository projectRepository)
|
||||
: IRequestHandler<GetSprintsByProjectIdQuery, IReadOnlyList<SprintDto>>
|
||||
{
|
||||
private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
||||
|
||||
public async Task<IReadOnlyList<SprintDto>> Handle(GetSprintsByProjectIdQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var projectId = ProjectId.From(request.ProjectId);
|
||||
var sprints = await _projectRepository.GetSprintsByProjectIdAsync(projectId, cancellationToken);
|
||||
|
||||
// Get project name
|
||||
var project = await _projectRepository.GetByIdAsync(projectId, cancellationToken);
|
||||
var projectName = project?.Name ?? string.Empty;
|
||||
|
||||
return sprints.Select(sprint => new SprintDto
|
||||
{
|
||||
Id = sprint.Id.Value,
|
||||
ProjectId = sprint.ProjectId.Value,
|
||||
ProjectName = projectName,
|
||||
Name = sprint.Name,
|
||||
Goal = sprint.Goal,
|
||||
StartDate = sprint.StartDate,
|
||||
EndDate = sprint.EndDate,
|
||||
Status = sprint.Status.Name,
|
||||
TotalTasks = sprint.TaskIds.Count,
|
||||
CompletedTasks = 0, // TODO: Calculate from tasks
|
||||
TotalStoryPoints = 0, // TODO: Calculate from tasks
|
||||
RemainingStoryPoints = 0, // TODO: Calculate from tasks
|
||||
TaskIds = sprint.TaskIds.Select(t => t.Value).ToList(),
|
||||
CreatedAt = sprint.CreatedAt,
|
||||
CreatedBy = sprint.CreatedBy.Value,
|
||||
UpdatedAt = sprint.UpdatedAt
|
||||
}).ToList();
|
||||
}
|
||||
}
|
||||
@@ -12,4 +12,12 @@ public interface IUnitOfWork
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>The number of entities written to the database</returns>
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the DbContext for direct access to DbSets
|
||||
/// Note: Returns object to avoid EF Core dependency in Domain layer
|
||||
/// Cast to concrete DbContext type in Application layer
|
||||
/// </summary>
|
||||
/// <returns>The DbContext instance</returns>
|
||||
object GetDbContext();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Entities;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
|
||||
@@ -10,7 +11,7 @@ namespace ColaFlow.Modules.ProjectManagement.Infrastructure.Persistence;
|
||||
/// <summary>
|
||||
/// Project Management Module DbContext
|
||||
/// </summary>
|
||||
public class PMDbContext : DbContext
|
||||
public class PMDbContext : DbContext, IApplicationDbContext
|
||||
{
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
|
||||
using ColaFlow.Shared.Kernel.Common;
|
||||
|
||||
@@ -19,6 +20,11 @@ public class UnitOfWork(PMDbContext context) : IUnitOfWork
|
||||
return await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public object GetDbContext()
|
||||
{
|
||||
return _context;
|
||||
}
|
||||
|
||||
private async Task DispatchDomainEventsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Get all entities with domain events
|
||||
|
||||
@@ -41,6 +41,9 @@ public class ProjectManagementModule : IModule
|
||||
.AddInterceptors(auditInterceptor);
|
||||
});
|
||||
|
||||
// Register IApplicationDbContext
|
||||
services.AddScoped<IApplicationDbContext>(sp => sp.GetRequiredService<PMDbContext>());
|
||||
|
||||
// Register repositories
|
||||
services.AddScoped<IProjectRepository, ProjectRepository>();
|
||||
services.AddScoped<IUnitOfWork, UnitOfWork>();
|
||||
|
||||
Reference in New Issue
Block a user