diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetEpicById/GetEpicByIdQueryHandler.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetEpicById/GetEpicByIdQueryHandler.cs index aafa8e6..81bf1d7 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetEpicById/GetEpicByIdQueryHandler.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetEpicById/GetEpicByIdQueryHandler.cs @@ -18,14 +18,10 @@ public sealed class GetEpicByIdQueryHandler( public async Task Handle(GetEpicByIdQuery request, CancellationToken cancellationToken) { - // Get the project containing the epic (Global Query Filter ensures tenant isolation) + // Use read-only method for query (AsNoTracking for better performance) var epicId = EpicId.From(request.EpicId); - var project = await _projectRepository.GetProjectWithEpicAsync(epicId, cancellationToken); + var epic = await _projectRepository.GetEpicByIdReadOnlyAsync(epicId, cancellationToken); - if (project == null) - throw new NotFoundException("Epic", request.EpicId); - - var epic = project.Epics.FirstOrDefault(e => e.Id == epicId); if (epic == null) throw new NotFoundException("Epic", request.EpicId); @@ -54,7 +50,21 @@ public sealed class GetEpicByIdQueryHandler( CreatedBy = s.CreatedBy.Value, CreatedAt = s.CreatedAt, UpdatedAt = s.UpdatedAt, - Tasks = new List() + Tasks = s.Tasks.Select(t => new TaskDto + { + Id = t.Id.Value, + Title = t.Title, + Description = t.Description, + StoryId = t.StoryId.Value, + Status = t.Status.Name, + Priority = t.Priority.Name, + AssigneeId = t.AssigneeId?.Value, + EstimatedHours = t.EstimatedHours, + ActualHours = t.ActualHours, + CreatedBy = t.CreatedBy.Value, + CreatedAt = t.CreatedAt, + UpdatedAt = t.UpdatedAt + }).ToList() }).ToList() }; } diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetEpicsByProjectId/GetEpicsByProjectIdQueryHandler.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetEpicsByProjectId/GetEpicsByProjectIdQueryHandler.cs index 1ada6ea..de2be7e 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetEpicsByProjectId/GetEpicsByProjectIdQueryHandler.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetEpicsByProjectId/GetEpicsByProjectIdQueryHandler.cs @@ -16,13 +16,11 @@ public sealed class GetEpicsByProjectIdQueryHandler(IProjectRepository projectRe public async Task> Handle(GetEpicsByProjectIdQuery request, CancellationToken cancellationToken) { + // Use read-only method for query (AsNoTracking for better performance) var projectId = ProjectId.From(request.ProjectId); - var project = await _projectRepository.GetByIdAsync(projectId, cancellationToken); + var epics = await _projectRepository.GetEpicsByProjectIdAsync(projectId, cancellationToken); - if (project == null) - throw new NotFoundException("Project", request.ProjectId); - - return project.Epics.Select(epic => new EpicDto + return epics.Select(epic => new EpicDto { Id = epic.Id.Value, Name = epic.Name, diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetStoriesByEpicId/GetStoriesByEpicIdQueryHandler.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetStoriesByEpicId/GetStoriesByEpicIdQueryHandler.cs index af57e6b..ef99333 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetStoriesByEpicId/GetStoriesByEpicIdQueryHandler.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetStoriesByEpicId/GetStoriesByEpicIdQueryHandler.cs @@ -16,20 +16,12 @@ public sealed class GetStoriesByEpicIdQueryHandler(IProjectRepository projectRep public async Task> Handle(GetStoriesByEpicIdQuery request, CancellationToken cancellationToken) { - // Get the project with epic + // Use read-only method for query (AsNoTracking for better performance) var epicId = EpicId.From(request.EpicId); - var project = await _projectRepository.GetProjectWithEpicAsync(epicId, cancellationToken); - - if (project == null) - throw new NotFoundException("Epic", request.EpicId); - - // Find the epic - var epic = project.Epics.FirstOrDefault(e => e.Id.Value == request.EpicId); - if (epic == null) - throw new NotFoundException("Epic", request.EpicId); + var stories = await _projectRepository.GetStoriesByEpicIdAsync(epicId, cancellationToken); // Map stories to DTOs - return epic.Stories.Select(story => new StoryDto + return stories.Select(story => new StoryDto { Id = story.Id.Value, Title = story.Title, diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetStoryById/GetStoryByIdQueryHandler.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetStoryById/GetStoryByIdQueryHandler.cs index 7365a39..dfcb534 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetStoryById/GetStoryByIdQueryHandler.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetStoryById/GetStoryByIdQueryHandler.cs @@ -16,17 +16,9 @@ public sealed class GetStoryByIdQueryHandler(IProjectRepository projectRepositor public async Task Handle(GetStoryByIdQuery request, CancellationToken cancellationToken) { - // Get the project with story + // Use read-only method for query (AsNoTracking for better performance) var storyId = StoryId.From(request.StoryId); - var project = await _projectRepository.GetProjectWithStoryAsync(storyId, cancellationToken); - - if (project == null) - throw new NotFoundException("Story", request.StoryId); - - // Find the story - var story = project.Epics - .SelectMany(e => e.Stories) - .FirstOrDefault(s => s.Id.Value == request.StoryId); + var story = await _projectRepository.GetStoryByIdReadOnlyAsync(storyId, cancellationToken); if (story == null) throw new NotFoundException("Story", request.StoryId); diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetTaskById/GetTaskByIdQueryHandler.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetTaskById/GetTaskByIdQueryHandler.cs index a600bef..c14a9eb 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetTaskById/GetTaskByIdQueryHandler.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetTaskById/GetTaskByIdQueryHandler.cs @@ -17,26 +17,9 @@ public sealed class GetTaskByIdQueryHandler(IProjectRepository projectRepository public async Task Handle(GetTaskByIdQuery request, CancellationToken cancellationToken) { - // Get the project containing the task + // Use read-only method for query (AsNoTracking for better performance) var taskId = TaskId.From(request.TaskId); - var project = await _projectRepository.GetProjectWithTaskAsync(taskId, cancellationToken); - - if (project == null) - throw new NotFoundException("Task", request.TaskId); - - // Find the task within the project aggregate - WorkTask? task = null; - foreach (var epic in project.Epics) - { - foreach (var story in epic.Stories) - { - task = story.Tasks.FirstOrDefault(t => t.Id.Value == request.TaskId); - if (task != null) - break; - } - if (task != null) - break; - } + var task = await _projectRepository.GetTaskByIdReadOnlyAsync(taskId, cancellationToken); if (task == null) throw new NotFoundException("Task", request.TaskId); diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetTasksByStoryId/GetTasksByStoryIdQueryHandler.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetTasksByStoryId/GetTasksByStoryIdQueryHandler.cs index 8bb6283..d2ab76c 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetTasksByStoryId/GetTasksByStoryIdQueryHandler.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetTasksByStoryId/GetTasksByStoryIdQueryHandler.cs @@ -16,23 +16,12 @@ public sealed class GetTasksByStoryIdQueryHandler(IProjectRepository projectRepo public async Task> Handle(GetTasksByStoryIdQuery request, CancellationToken cancellationToken) { - // Get the project containing the story + // Use read-only method for query (AsNoTracking for better performance) var storyId = StoryId.From(request.StoryId); - var project = await _projectRepository.GetProjectWithStoryAsync(storyId, cancellationToken); - - if (project == null) - throw new NotFoundException("Story", request.StoryId); - - // Find the story within the project aggregate - var story = project.Epics - .SelectMany(e => e.Stories) - .FirstOrDefault(s => s.Id.Value == request.StoryId); - - if (story == null) - throw new NotFoundException("Story", request.StoryId); + var tasks = await _projectRepository.GetTasksByStoryIdAsync(storyId, cancellationToken); // Map tasks to DTOs - return story.Tasks.Select(task => new TaskDto + return tasks.Select(task => new TaskDto { Id = task.Id.Value, Title = task.Title, diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Repositories/IProjectRepository.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Repositories/IProjectRepository.cs index d4190d9..d7d7da8 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Repositories/IProjectRepository.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Repositories/IProjectRepository.cs @@ -8,6 +8,8 @@ namespace ColaFlow.Modules.ProjectManagement.Domain.Repositories; /// public interface IProjectRepository { + // ========== Basic CRUD Operations ========== + /// /// Gets a project by its ID /// @@ -23,21 +25,6 @@ public interface IProjectRepository /// Task> GetAllAsync(CancellationToken cancellationToken = default); - /// - /// Gets project containing specific epic - /// - Task GetProjectWithEpicAsync(EpicId epicId, CancellationToken cancellationToken = default); - - /// - /// Gets project containing specific story - /// - Task GetProjectWithStoryAsync(StoryId storyId, CancellationToken cancellationToken = default); - - /// - /// Gets project containing specific task - /// - Task GetProjectWithTaskAsync(TaskId taskId, CancellationToken cancellationToken = default); - /// /// Adds a new project /// @@ -52,4 +39,58 @@ public interface IProjectRepository /// Deletes a project /// void Delete(Project project); + + // ========== Aggregate Root Loading (for Command Handlers - with tracking) ========== + + /// + /// Gets project containing specific epic (with tracking, for modification) + /// + Task GetProjectWithEpicAsync(EpicId epicId, CancellationToken cancellationToken = default); + + /// + /// Gets project containing specific story (with tracking, for modification) + /// + Task GetProjectWithStoryAsync(StoryId storyId, CancellationToken cancellationToken = default); + + /// + /// Gets project containing specific task (with tracking, for modification) + /// + Task GetProjectWithTaskAsync(TaskId taskId, CancellationToken cancellationToken = default); + + /// + /// Gets project with all epics (without stories and tasks) + /// + Task GetProjectWithEpicsAsync(ProjectId projectId, CancellationToken cancellationToken = default); + + // ========== Read-Only Queries (for Query Handlers - AsNoTracking) ========== + + /// + /// Gets epic by ID (read-only, AsNoTracking) + /// + Task GetEpicByIdReadOnlyAsync(EpicId epicId, CancellationToken cancellationToken = default); + + /// + /// Gets all epics for a project (read-only, AsNoTracking) + /// + Task> GetEpicsByProjectIdAsync(ProjectId projectId, CancellationToken cancellationToken = default); + + /// + /// Gets story by ID (read-only, AsNoTracking) + /// + Task GetStoryByIdReadOnlyAsync(StoryId storyId, CancellationToken cancellationToken = default); + + /// + /// Gets all stories for an epic (read-only, AsNoTracking) + /// + Task> GetStoriesByEpicIdAsync(EpicId epicId, CancellationToken cancellationToken = default); + + /// + /// Gets task by ID (read-only, AsNoTracking) + /// + Task GetTaskByIdReadOnlyAsync(TaskId taskId, CancellationToken cancellationToken = default); + + /// + /// Gets all tasks for a story (read-only, AsNoTracking) + /// + Task> GetTasksByStoryIdAsync(StoryId storyId, CancellationToken cancellationToken = default); } diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Repositories/ProjectRepository.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Repositories/ProjectRepository.cs index 0d84d7b..dfdaea8 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Repositories/ProjectRepository.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Repositories/ProjectRepository.cs @@ -13,6 +13,8 @@ public class ProjectRepository(PMDbContext context) : IProjectRepository { private readonly PMDbContext _context = context ?? throw new ArgumentNullException(nameof(context)); + // ========== Basic CRUD Operations ========== + public async Task GetByIdAsync(ProjectId id, CancellationToken cancellationToken = default) { return await _context.Projects @@ -33,35 +35,6 @@ public class ProjectRepository(PMDbContext context) : IProjectRepository .ToListAsync(cancellationToken); } - public async Task GetProjectWithEpicAsync(EpicId epicId, CancellationToken cancellationToken = default) - { - return await _context.Projects - .Include(p => p.Epics) - .ThenInclude(e => e.Stories) - .Where(p => p.Epics.Any(e => e.Id == epicId)) - .FirstOrDefaultAsync(cancellationToken); - } - - public async Task GetProjectWithStoryAsync(StoryId storyId, CancellationToken cancellationToken = default) - { - return await _context.Projects - .Include(p => p.Epics) - .ThenInclude(e => e.Stories) - .ThenInclude(s => s.Tasks) - .Where(p => p.Epics.Any(e => e.Stories.Any(s => s.Id == storyId))) - .FirstOrDefaultAsync(cancellationToken); - } - - public async Task GetProjectWithTaskAsync(TaskId taskId, CancellationToken cancellationToken = default) - { - return await _context.Projects - .Include(p => p.Epics) - .ThenInclude(e => e.Stories) - .ThenInclude(s => s.Tasks) - .Where(p => p.Epics.Any(e => e.Stories.Any(s => s.Tasks.Any(t => t.Id == taskId)))) - .FirstOrDefaultAsync(cancellationToken); - } - public async Task AddAsync(Project project, CancellationToken cancellationToken = default) { await _context.Projects.AddAsync(project, cancellationToken); @@ -76,4 +49,100 @@ public class ProjectRepository(PMDbContext context) : IProjectRepository { _context.Projects.Remove(project); } + + // ========== Aggregate Root Loading (for Command Handlers - with tracking) ========== + + public async Task GetProjectWithEpicAsync(EpicId epicId, CancellationToken cancellationToken = default) + { + // Load only the specific Epic with its Stories (filtered Include) + return await _context.Projects + .Include(p => p.Epics.Where(e => e.Id == epicId)) + .ThenInclude(e => e.Stories) + .Where(p => p.Epics.Any(e => e.Id == epicId)) + .FirstOrDefaultAsync(cancellationToken); + } + + public async Task GetProjectWithStoryAsync(StoryId storyId, CancellationToken cancellationToken = default) + { + // Load the Epic containing the Story, with that Story and its Tasks + return await _context.Projects + .Include(p => p.Epics) + .ThenInclude(e => e.Stories.Where(s => s.Id == storyId)) + .ThenInclude(s => s.Tasks) + .Where(p => p.Epics.Any(e => e.Stories.Any(s => s.Id == storyId))) + .FirstOrDefaultAsync(cancellationToken); + } + + public async Task GetProjectWithTaskAsync(TaskId taskId, CancellationToken cancellationToken = default) + { + // Load the Epic and Story containing the Task, with that Task + return await _context.Projects + .Include(p => p.Epics) + .ThenInclude(e => e.Stories) + .ThenInclude(s => s.Tasks.Where(t => t.Id == taskId)) + .Where(p => p.Epics.Any(e => e.Stories.Any(s => s.Tasks.Any(t => t.Id == taskId)))) + .FirstOrDefaultAsync(cancellationToken); + } + + public async Task GetProjectWithEpicsAsync(ProjectId projectId, CancellationToken cancellationToken = default) + { + // Load Project with all Epics, but without Stories and Tasks + return await _context.Projects + .Include(p => p.Epics) + .FirstOrDefaultAsync(p => p.Id == projectId, cancellationToken); + } + + // ========== Read-Only Queries (for Query Handlers - AsNoTracking) ========== + + public async Task GetEpicByIdReadOnlyAsync(EpicId epicId, CancellationToken cancellationToken = default) + { + return await _context.Set() + .AsNoTracking() + .Include(e => e.Stories) + .ThenInclude(s => s.Tasks) + .FirstOrDefaultAsync(e => e.Id == epicId, cancellationToken); + } + + public async Task> GetEpicsByProjectIdAsync(ProjectId projectId, CancellationToken cancellationToken = default) + { + return await _context.Set() + .AsNoTracking() + .Where(e => e.ProjectId == projectId) + .OrderBy(e => e.CreatedAt) + .ToListAsync(cancellationToken); + } + + public async Task GetStoryByIdReadOnlyAsync(StoryId storyId, CancellationToken cancellationToken = default) + { + return await _context.Set() + .AsNoTracking() + .Include(s => s.Tasks) + .FirstOrDefaultAsync(s => s.Id == storyId, cancellationToken); + } + + public async Task> GetStoriesByEpicIdAsync(EpicId epicId, CancellationToken cancellationToken = default) + { + return await _context.Set() + .AsNoTracking() + .Include(s => s.Tasks) + .Where(s => s.EpicId == epicId) + .OrderBy(s => s.CreatedAt) + .ToListAsync(cancellationToken); + } + + public async Task GetTaskByIdReadOnlyAsync(TaskId taskId, CancellationToken cancellationToken = default) + { + return await _context.Set() + .AsNoTracking() + .FirstOrDefaultAsync(t => t.Id == taskId, cancellationToken); + } + + public async Task> GetTasksByStoryIdAsync(StoryId storyId, CancellationToken cancellationToken = default) + { + return await _context.Set() + .AsNoTracking() + .Where(t => t.StoryId == storyId) + .OrderBy(t => t.CreatedAt) + .ToListAsync(cancellationToken); + } } diff --git a/colaflow-api/tests/ColaFlow.Application.Tests/Queries/GetStoryById/GetStoryByIdQueryHandlerTests.cs b/colaflow-api/tests/ColaFlow.Application.Tests/Queries/GetStoryById/GetStoryByIdQueryHandlerTests.cs index 166502b..76604d9 100644 --- a/colaflow-api/tests/ColaFlow.Application.Tests/Queries/GetStoryById/GetStoryByIdQueryHandlerTests.cs +++ b/colaflow-api/tests/ColaFlow.Application.Tests/Queries/GetStoryById/GetStoryByIdQueryHandlerTests.cs @@ -31,8 +31,8 @@ public class GetStoryByIdQueryHandlerTests var task2 = story.CreateTask("Task 2", "Description 2", TaskPriority.Low, userId); _projectRepositoryMock - .Setup(x => x.GetProjectWithStoryAsync(story.Id, It.IsAny())) - .ReturnsAsync(project); + .Setup(x => x.GetStoryByIdReadOnlyAsync(story.Id, It.IsAny())) + .ReturnsAsync(story); var query = new GetStoryByIdQuery(story.Id.Value); @@ -56,8 +56,8 @@ public class GetStoryByIdQueryHandlerTests // Arrange var storyId = StoryId.Create(); _projectRepositoryMock - .Setup(x => x.GetProjectWithStoryAsync(storyId, It.IsAny())) - .ReturnsAsync((Project?)null); + .Setup(x => x.GetStoryByIdReadOnlyAsync(storyId, It.IsAny())) + .ReturnsAsync((Story?)null); var query = new GetStoryByIdQuery(storyId.Value); diff --git a/colaflow-api/tests/ColaFlow.Application.Tests/Queries/GetTaskById/GetTaskByIdQueryHandlerTests.cs b/colaflow-api/tests/ColaFlow.Application.Tests/Queries/GetTaskById/GetTaskByIdQueryHandlerTests.cs index a43014b..1d5a58b 100644 --- a/colaflow-api/tests/ColaFlow.Application.Tests/Queries/GetTaskById/GetTaskByIdQueryHandlerTests.cs +++ b/colaflow-api/tests/ColaFlow.Application.Tests/Queries/GetTaskById/GetTaskByIdQueryHandlerTests.cs @@ -30,8 +30,8 @@ public class GetTaskByIdQueryHandlerTests var task = story.CreateTask("Test Task", "Task Description", TaskPriority.High, userId); _projectRepositoryMock - .Setup(x => x.GetProjectWithTaskAsync(task.Id, It.IsAny())) - .ReturnsAsync(project); + .Setup(x => x.GetTaskByIdReadOnlyAsync(task.Id, It.IsAny())) + .ReturnsAsync(task); var query = new GetTaskByIdQuery(task.Id.Value); @@ -54,8 +54,8 @@ public class GetTaskByIdQueryHandlerTests // Arrange var taskId = TaskId.Create(); _projectRepositoryMock - .Setup(x => x.GetProjectWithTaskAsync(taskId, It.IsAny())) - .ReturnsAsync((Project?)null); + .Setup(x => x.GetTaskByIdReadOnlyAsync(taskId, It.IsAny())) + .ReturnsAsync((WorkTask?)null); var query = new GetTaskByIdQuery(taskId.Value);