feat(backend): Implement Sprint CQRS Commands and Queries (Task 3)
Some checks failed
Code Coverage / Generate Coverage Report (push) Has been cancelled
Tests / Run Tests (9.0.x) (push) Has been cancelled
Tests / Docker Build Test (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled

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:
Yaojia Wang
2025-11-05 00:25:23 +01:00
parent ee73d56759
commit 58e08f9fa7
33 changed files with 1398 additions and 2 deletions

View File

@@ -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>

View File

@@ -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>;

View File

@@ -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;
}
}

View File

@@ -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>;

View File

@@ -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;
}
}

View File

@@ -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; }
}

View File

@@ -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
};
}
}

View File

@@ -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");
}
}

View File

@@ -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>;

View File

@@ -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;
}
}

View File

@@ -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>;

View File

@@ -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;
}
}

View File

@@ -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>;

View File

@@ -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;
}
}

View File

@@ -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; }
}

View File

@@ -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;
}
}

View File

@@ -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");
}
}

View File

@@ -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);
}

View File

@@ -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; }
}

View File

@@ -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>>;

View File

@@ -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();
}
}

View File

@@ -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?>;

View File

@@ -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
};
}
}

View File

@@ -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>>;

View File

@@ -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();
}
}

View File

@@ -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();
}

View File

@@ -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;

View File

@@ -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

View File

@@ -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>();