Project Init

🤖 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-02 23:55:18 +01:00
commit 014d62bcc2
169 changed files with 28867 additions and 0 deletions

View File

@@ -0,0 +1,46 @@
using FluentValidation;
using MediatR;
namespace ColaFlow.Modules.ProjectManagement.Application.Behaviors;
/// <summary>
/// Pipeline behavior for request validation using FluentValidation
/// </summary>
public sealed class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
{
_validators = validators;
}
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
if (!_validators.Any())
{
return await next();
}
var context = new ValidationContext<TRequest>(request);
var validationResults = await Task.WhenAll(
_validators.Select(v => v.ValidateAsync(context, cancellationToken)));
var failures = validationResults
.SelectMany(r => r.Errors)
.Where(f => f != null)
.ToList();
if (failures.Any())
{
throw new ValidationException(failures);
}
return await next();
}
}

View File

@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\ColaFlow.Modules.ProjectManagement.Domain\ColaFlow.Modules.ProjectManagement.Domain.csproj" />
<ProjectReference Include="..\ColaFlow.Modules.ProjectManagement.Contracts\ColaFlow.Modules.ProjectManagement.Contracts.csproj" />
<ProjectReference Include="..\..\..\Shared\ColaFlow.Shared.Kernel\ColaFlow.Shared.Kernel.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MediatR" Version="11.1.0" />
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="11.1.0" />
<PackageReference Include="FluentValidation" Version="11.10.0" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.10.0" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>ColaFlow.Modules.ProjectManagement.Application</AssemblyName>
<RootNamespace>ColaFlow.Modules.ProjectManagement.Application</RootNamespace>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,15 @@
using MediatR;
using ColaFlow.Modules.ProjectManagement.Application.DTOs;
namespace ColaFlow.Modules.ProjectManagement.Application.Commands.CreateEpic;
/// <summary>
/// Command to create a new Epic
/// </summary>
public sealed record CreateEpicCommand : IRequest<EpicDto>
{
public Guid ProjectId { get; init; }
public string Name { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public Guid CreatedBy { get; init; }
}

View File

@@ -0,0 +1,57 @@
using MediatR;
using ColaFlow.Modules.ProjectManagement.Application.DTOs;
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
using ColaFlow.Modules.ProjectManagement.Domain.Exceptions;
namespace ColaFlow.Modules.ProjectManagement.Application.Commands.CreateEpic;
/// <summary>
/// Handler for CreateEpicCommand
/// </summary>
public sealed class CreateEpicCommandHandler : IRequestHandler<CreateEpicCommand, EpicDto>
{
private readonly IProjectRepository _projectRepository;
private readonly IUnitOfWork _unitOfWork;
public CreateEpicCommandHandler(
IProjectRepository projectRepository,
IUnitOfWork unitOfWork)
{
_projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
_unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
}
public async Task<EpicDto> Handle(CreateEpicCommand request, CancellationToken cancellationToken)
{
// Get the project
var projectId = ProjectId.From(request.ProjectId);
var project = await _projectRepository.GetByIdAsync(projectId, cancellationToken);
if (project == null)
throw new NotFoundException("Project", request.ProjectId);
// Create epic through aggregate root
var createdById = UserId.From(request.CreatedBy);
var epic = project.CreateEpic(request.Name, request.Description, createdById);
// Update project (epic is part of aggregate)
_projectRepository.Update(project);
await _unitOfWork.SaveChangesAsync(cancellationToken);
// Map to DTO
return new EpicDto
{
Id = epic.Id.Value,
Name = epic.Name,
Description = epic.Description,
ProjectId = epic.ProjectId.Value,
Status = epic.Status.Value,
Priority = epic.Priority.Value,
CreatedBy = epic.CreatedBy.Value,
CreatedAt = epic.CreatedAt,
UpdatedAt = epic.UpdatedAt,
Stories = new List<StoryDto>()
};
}
}

View File

@@ -0,0 +1,22 @@
using FluentValidation;
namespace ColaFlow.Modules.ProjectManagement.Application.Commands.CreateEpic;
/// <summary>
/// Validator for CreateEpicCommand
/// </summary>
public sealed class CreateEpicCommandValidator : AbstractValidator<CreateEpicCommand>
{
public CreateEpicCommandValidator()
{
RuleFor(x => x.ProjectId)
.NotEmpty().WithMessage("Project ID is required");
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Epic name is required")
.MaximumLength(200).WithMessage("Epic name cannot exceed 200 characters");
RuleFor(x => x.CreatedBy)
.NotEmpty().WithMessage("Created by user ID is required");
}
}

View File

@@ -0,0 +1,15 @@
using MediatR;
using ColaFlow.Modules.ProjectManagement.Application.DTOs;
namespace ColaFlow.Modules.ProjectManagement.Application.Commands.CreateProject;
/// <summary>
/// Command to create a new project
/// </summary>
public sealed record CreateProjectCommand : IRequest<ProjectDto>
{
public string Name { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public string Key { get; init; } = string.Empty;
public Guid OwnerId { get; init; }
}

View File

@@ -0,0 +1,66 @@
using MediatR;
using ColaFlow.Modules.ProjectManagement.Application.DTOs;
using ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate;
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
using ColaFlow.Modules.ProjectManagement.Domain.Exceptions;
namespace ColaFlow.Modules.ProjectManagement.Application.Commands.CreateProject;
/// <summary>
/// Handler for CreateProjectCommand
/// </summary>
public sealed class CreateProjectCommandHandler : IRequestHandler<CreateProjectCommand, ProjectDto>
{
private readonly IProjectRepository _projectRepository;
private readonly IUnitOfWork _unitOfWork;
public CreateProjectCommandHandler(
IProjectRepository projectRepository,
IUnitOfWork unitOfWork)
{
_projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
_unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
}
public async Task<ProjectDto> Handle(CreateProjectCommand request, CancellationToken cancellationToken)
{
// Check if project key already exists
var existingProject = await _projectRepository.GetByKeyAsync(request.Key, cancellationToken);
if (existingProject != null)
{
throw new DomainException($"Project with key '{request.Key}' already exists");
}
// Create project aggregate
var project = Project.Create(
request.Name,
request.Description,
request.Key,
UserId.From(request.OwnerId)
);
// Save to repository
await _projectRepository.AddAsync(project, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);
// Return DTO
return MapToDto(project);
}
private static ProjectDto MapToDto(Project project)
{
return new ProjectDto
{
Id = project.Id.Value,
Name = project.Name,
Description = project.Description,
Key = project.Key.Value,
Status = project.Status.Name,
OwnerId = project.OwnerId.Value,
CreatedAt = project.CreatedAt,
UpdatedAt = project.UpdatedAt,
Epics = new List<EpicDto>()
};
}
}

View File

@@ -0,0 +1,24 @@
using FluentValidation;
namespace ColaFlow.Modules.ProjectManagement.Application.Commands.CreateProject;
/// <summary>
/// Validator for CreateProjectCommand
/// </summary>
public sealed class CreateProjectCommandValidator : AbstractValidator<CreateProjectCommand>
{
public CreateProjectCommandValidator()
{
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Project name is required")
.MaximumLength(200).WithMessage("Project name cannot exceed 200 characters");
RuleFor(x => x.Key)
.NotEmpty().WithMessage("Project key is required")
.MaximumLength(20).WithMessage("Project key cannot exceed 20 characters")
.Matches("^[A-Z0-9]+$").WithMessage("Project key must contain only uppercase letters and numbers");
RuleFor(x => x.OwnerId)
.NotEmpty().WithMessage("Owner ID is required");
}
}

View File

@@ -0,0 +1,14 @@
using MediatR;
using ColaFlow.Modules.ProjectManagement.Application.DTOs;
namespace ColaFlow.Modules.ProjectManagement.Application.Commands.UpdateEpic;
/// <summary>
/// Command to update an existing Epic
/// </summary>
public sealed record UpdateEpicCommand : IRequest<EpicDto>
{
public Guid EpicId { get; init; }
public string Name { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,76 @@
using MediatR;
using ColaFlow.Modules.ProjectManagement.Application.DTOs;
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
using ColaFlow.Modules.ProjectManagement.Domain.Exceptions;
namespace ColaFlow.Modules.ProjectManagement.Application.Commands.UpdateEpic;
/// <summary>
/// Handler for UpdateEpicCommand
/// </summary>
public sealed class UpdateEpicCommandHandler : IRequestHandler<UpdateEpicCommand, EpicDto>
{
private readonly IProjectRepository _projectRepository;
private readonly IUnitOfWork _unitOfWork;
public UpdateEpicCommandHandler(
IProjectRepository projectRepository,
IUnitOfWork unitOfWork)
{
_projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
_unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
}
public async Task<EpicDto> Handle(UpdateEpicCommand request, CancellationToken cancellationToken)
{
// Get the project containing the epic
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 == epicId);
if (epic == null)
throw new NotFoundException("Epic", request.EpicId);
// Update epic through domain method
epic.UpdateDetails(request.Name, request.Description);
// Save changes
_projectRepository.Update(project);
await _unitOfWork.SaveChangesAsync(cancellationToken);
// Map to DTO
return new EpicDto
{
Id = epic.Id.Value,
Name = epic.Name,
Description = epic.Description,
ProjectId = epic.ProjectId.Value,
Status = epic.Status.Value,
Priority = epic.Priority.Value,
CreatedBy = epic.CreatedBy.Value,
CreatedAt = epic.CreatedAt,
UpdatedAt = epic.UpdatedAt,
Stories = epic.Stories.Select(s => new StoryDto
{
Id = s.Id.Value,
Title = s.Title,
Description = s.Description,
EpicId = s.EpicId.Value,
Status = s.Status.Value,
Priority = s.Priority.Value,
EstimatedHours = s.EstimatedHours,
ActualHours = s.ActualHours,
AssigneeId = s.AssigneeId?.Value,
CreatedBy = s.CreatedBy.Value,
CreatedAt = s.CreatedAt,
UpdatedAt = s.UpdatedAt,
Tasks = new List<TaskDto>()
}).ToList()
};
}
}

View File

@@ -0,0 +1,19 @@
using FluentValidation;
namespace ColaFlow.Modules.ProjectManagement.Application.Commands.UpdateEpic;
/// <summary>
/// Validator for UpdateEpicCommand
/// </summary>
public sealed class UpdateEpicCommandValidator : AbstractValidator<UpdateEpicCommand>
{
public UpdateEpicCommandValidator()
{
RuleFor(x => x.EpicId)
.NotEmpty().WithMessage("Epic ID is required");
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Epic name is required")
.MaximumLength(200).WithMessage("Epic name cannot exceed 200 characters");
}
}

View File

@@ -0,0 +1,18 @@
namespace ColaFlow.Modules.ProjectManagement.Application.DTOs;
/// <summary>
/// Data Transfer Object for Epic
/// </summary>
public record EpicDto
{
public Guid Id { get; init; }
public string Name { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public Guid ProjectId { get; init; }
public string Status { get; init; } = string.Empty;
public string Priority { get; init; } = string.Empty;
public Guid CreatedBy { get; init; }
public DateTime CreatedAt { get; init; }
public DateTime? UpdatedAt { get; init; }
public List<StoryDto> Stories { get; init; } = new();
}

View File

@@ -0,0 +1,17 @@
namespace ColaFlow.Modules.ProjectManagement.Application.DTOs;
/// <summary>
/// Data Transfer Object for Project
/// </summary>
public record ProjectDto
{
public Guid Id { get; init; }
public string Name { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public string Key { get; init; } = string.Empty;
public string Status { get; init; } = string.Empty;
public Guid OwnerId { get; init; }
public DateTime CreatedAt { get; init; }
public DateTime? UpdatedAt { get; init; }
public List<EpicDto> Epics { get; init; } = new();
}

View File

@@ -0,0 +1,21 @@
namespace ColaFlow.Modules.ProjectManagement.Application.DTOs;
/// <summary>
/// Data Transfer Object for Story
/// </summary>
public record StoryDto
{
public Guid Id { get; init; }
public string Title { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public Guid EpicId { get; init; }
public string Status { get; init; } = string.Empty;
public string Priority { get; init; } = string.Empty;
public Guid? AssigneeId { get; init; }
public decimal? EstimatedHours { get; init; }
public decimal? ActualHours { get; init; }
public Guid CreatedBy { get; init; }
public DateTime CreatedAt { get; init; }
public DateTime? UpdatedAt { get; init; }
public List<TaskDto> Tasks { get; init; } = new();
}

View File

@@ -0,0 +1,20 @@
namespace ColaFlow.Modules.ProjectManagement.Application.DTOs;
/// <summary>
/// Data Transfer Object for Task
/// </summary>
public record TaskDto
{
public Guid Id { get; init; }
public string Title { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public Guid StoryId { get; init; }
public string Status { get; init; } = string.Empty;
public string Priority { get; init; } = string.Empty;
public Guid? AssigneeId { get; init; }
public decimal? EstimatedHours { get; init; }
public decimal? ActualHours { get; init; }
public Guid CreatedBy { get; init; }
public DateTime CreatedAt { get; init; }
public DateTime? UpdatedAt { get; init; }
}

View File

@@ -0,0 +1,9 @@
using MediatR;
using ColaFlow.Modules.ProjectManagement.Application.DTOs;
namespace ColaFlow.Modules.ProjectManagement.Application.Queries.GetEpicById;
/// <summary>
/// Query to get an Epic by its ID
/// </summary>
public sealed record GetEpicByIdQuery(Guid EpicId) : IRequest<EpicDto>;

View File

@@ -0,0 +1,9 @@
using MediatR;
using ColaFlow.Modules.ProjectManagement.Application.DTOs;
namespace ColaFlow.Modules.ProjectManagement.Application.Queries.GetProjectById;
/// <summary>
/// Query to get a project by its ID
/// </summary>
public sealed record GetProjectByIdQuery(Guid ProjectId) : IRequest<ProjectDto>;

View File

@@ -0,0 +1,92 @@
using MediatR;
using ColaFlow.Modules.ProjectManagement.Application.DTOs;
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;
namespace ColaFlow.Modules.ProjectManagement.Application.Queries.GetProjectById;
/// <summary>
/// Handler for GetProjectByIdQuery
/// </summary>
public sealed class GetProjectByIdQueryHandler : IRequestHandler<GetProjectByIdQuery, ProjectDto>
{
private readonly IProjectRepository _projectRepository;
public GetProjectByIdQueryHandler(IProjectRepository projectRepository)
{
_projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
}
public async Task<ProjectDto> Handle(GetProjectByIdQuery request, CancellationToken cancellationToken)
{
var project = await _projectRepository.GetByIdAsync(
ProjectId.From(request.ProjectId),
cancellationToken);
if (project == null)
{
throw new DomainException($"Project with ID '{request.ProjectId}' not found");
}
return MapToDto(project);
}
private static ProjectDto MapToDto(Project project)
{
return new ProjectDto
{
Id = project.Id.Value,
Name = project.Name,
Description = project.Description,
Key = project.Key.Value,
Status = project.Status.Name,
OwnerId = project.OwnerId.Value,
CreatedAt = project.CreatedAt,
UpdatedAt = project.UpdatedAt,
Epics = project.Epics.Select(e => new EpicDto
{
Id = e.Id.Value,
Name = e.Name,
Description = e.Description,
ProjectId = e.ProjectId.Value,
Status = e.Status.Name,
Priority = e.Priority.Name,
CreatedBy = e.CreatedBy.Value,
CreatedAt = e.CreatedAt,
UpdatedAt = e.UpdatedAt,
Stories = e.Stories.Select(s => new StoryDto
{
Id = s.Id.Value,
Title = s.Title,
Description = s.Description,
EpicId = s.EpicId.Value,
Status = s.Status.Name,
Priority = s.Priority.Name,
AssigneeId = s.AssigneeId?.Value,
EstimatedHours = s.EstimatedHours,
ActualHours = s.ActualHours,
CreatedBy = s.CreatedBy.Value,
CreatedAt = s.CreatedAt,
UpdatedAt = s.UpdatedAt,
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()
}).ToList()
};
}
}

View File

@@ -0,0 +1,9 @@
using MediatR;
using ColaFlow.Modules.ProjectManagement.Application.DTOs;
namespace ColaFlow.Modules.ProjectManagement.Application.Queries.GetProjects;
/// <summary>
/// Query to get all projects
/// </summary>
public sealed record GetProjectsQuery : IRequest<List<ProjectDto>>;

View File

@@ -0,0 +1,43 @@
using MediatR;
using ColaFlow.Modules.ProjectManagement.Application.DTOs;
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
using ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate;
namespace ColaFlow.Modules.ProjectManagement.Application.Queries.GetProjects;
/// <summary>
/// Handler for GetProjectsQuery
/// </summary>
public sealed class GetProjectsQueryHandler : IRequestHandler<GetProjectsQuery, List<ProjectDto>>
{
private readonly IProjectRepository _projectRepository;
public GetProjectsQueryHandler(IProjectRepository projectRepository)
{
_projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
}
public async Task<List<ProjectDto>> Handle(GetProjectsQuery request, CancellationToken cancellationToken)
{
var projects = await _projectRepository.GetAllAsync(cancellationToken);
return projects.Select(MapToDto).ToList();
}
private static ProjectDto MapToDto(Project project)
{
return new ProjectDto
{
Id = project.Id.Value,
Name = project.Name,
Description = project.Description,
Key = project.Key.Value,
Status = project.Status.Name,
OwnerId = project.OwnerId.Value,
CreatedAt = project.CreatedAt,
UpdatedAt = project.UpdatedAt,
// Don't load Epics for list view (performance)
Epics = new List<EpicDto>()
};
}
}