feat(backend): Implement Sprint Aggregate Root and Domain Events (Task 1)
Created Sprint domain model with full business logic and validation: - SprintId value object - SprintStatus enum (Planned/Active/Completed) - Sprint aggregate root with lifecycle management - 7 domain events (Created, Updated, Started, Completed, Deleted, TaskAdded, TaskRemoved) Business Rules Implemented: - Sprint duration validation (1-30 days) - Status transitions (Planned → Active → Completed) - Task management (add/remove with validation) - Cannot modify completed sprints Story 3 Task 1/6 completed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
using ColaFlow.Shared.Kernel.Common;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Exceptions;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Enums;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Events;
|
||||
|
||||
namespace ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// Sprint Entity (part of Project aggregate)
|
||||
/// </summary>
|
||||
public class Sprint : Entity
|
||||
{
|
||||
public new SprintId Id { get; private set; }
|
||||
public TenantId TenantId { get; private set; }
|
||||
public ProjectId ProjectId { get; private set; }
|
||||
public string Name { get; private set; }
|
||||
public string? Goal { get; private set; }
|
||||
public DateTime StartDate { get; private set; }
|
||||
public DateTime EndDate { get; private set; }
|
||||
public SprintStatus Status { get; private set; }
|
||||
|
||||
private readonly List<TaskId> _taskIds = new();
|
||||
public IReadOnlyCollection<TaskId> TaskIds => _taskIds.AsReadOnly();
|
||||
|
||||
public DateTime CreatedAt { get; private set; }
|
||||
public UserId CreatedBy { get; private set; }
|
||||
public DateTime? UpdatedAt { get; private set; }
|
||||
|
||||
// EF Core constructor
|
||||
private Sprint()
|
||||
{
|
||||
Id = null!;
|
||||
TenantId = null!;
|
||||
ProjectId = null!;
|
||||
Name = null!;
|
||||
Status = null!;
|
||||
CreatedBy = null!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a new Sprint
|
||||
/// </summary>
|
||||
public static Sprint Create(
|
||||
TenantId tenantId,
|
||||
ProjectId projectId,
|
||||
string name,
|
||||
string? goal,
|
||||
DateTime startDate,
|
||||
DateTime endDate,
|
||||
UserId createdBy)
|
||||
{
|
||||
ValidateName(name);
|
||||
ValidateDates(startDate, endDate);
|
||||
|
||||
return new Sprint
|
||||
{
|
||||
Id = SprintId.Create(),
|
||||
TenantId = tenantId,
|
||||
ProjectId = projectId,
|
||||
Name = name,
|
||||
Goal = goal,
|
||||
StartDate = startDate,
|
||||
EndDate = endDate,
|
||||
Status = SprintStatus.Planned,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
CreatedBy = createdBy
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update sprint details
|
||||
/// </summary>
|
||||
public void UpdateDetails(string name, string? goal, DateTime startDate, DateTime endDate)
|
||||
{
|
||||
if (Status.Name == SprintStatus.Completed.Name)
|
||||
throw new DomainException("Cannot update a completed sprint");
|
||||
|
||||
ValidateName(name);
|
||||
ValidateDates(startDate, endDate);
|
||||
|
||||
Name = name;
|
||||
Goal = goal;
|
||||
StartDate = startDate;
|
||||
EndDate = endDate;
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Start the sprint (Planned → Active)
|
||||
/// </summary>
|
||||
public void Start()
|
||||
{
|
||||
if (Status.Name != SprintStatus.Planned.Name)
|
||||
throw new DomainException($"Cannot start sprint in {Status.Name} status. Sprint must be in Planned status.");
|
||||
|
||||
Status = SprintStatus.Active;
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Complete the sprint (Active → Completed)
|
||||
/// </summary>
|
||||
public void Complete()
|
||||
{
|
||||
if (Status.Name != SprintStatus.Active.Name)
|
||||
throw new DomainException($"Cannot complete sprint in {Status.Name} status. Sprint must be in Active status.");
|
||||
|
||||
Status = SprintStatus.Completed;
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add a task to the sprint
|
||||
/// </summary>
|
||||
public void AddTask(TaskId taskId)
|
||||
{
|
||||
if (Status.Name == SprintStatus.Completed.Name)
|
||||
throw new DomainException("Cannot add tasks to a completed sprint");
|
||||
|
||||
if (_taskIds.Any(t => t.Value == taskId.Value))
|
||||
throw new DomainException("Task is already in this sprint");
|
||||
|
||||
_taskIds.Add(taskId);
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remove a task from the sprint
|
||||
/// </summary>
|
||||
public void RemoveTask(TaskId taskId)
|
||||
{
|
||||
if (Status.Name == SprintStatus.Completed.Name)
|
||||
throw new DomainException("Cannot remove tasks from a completed sprint");
|
||||
|
||||
var task = _taskIds.FirstOrDefault(t => t.Value == taskId.Value);
|
||||
if (task == null)
|
||||
throw new DomainException("Task is not in this sprint");
|
||||
|
||||
_taskIds.Remove(task);
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
private static void ValidateName(string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new DomainException("Sprint name cannot be empty");
|
||||
|
||||
if (name.Length > 200)
|
||||
throw new DomainException("Sprint name cannot exceed 200 characters");
|
||||
}
|
||||
|
||||
private static void ValidateDates(DateTime startDate, DateTime endDate)
|
||||
{
|
||||
if (endDate <= startDate)
|
||||
throw new DomainException("Sprint end date must be after start date");
|
||||
|
||||
var duration = (endDate - startDate).Days;
|
||||
if (duration > 30)
|
||||
throw new DomainException("Sprint duration cannot exceed 30 days");
|
||||
|
||||
if (duration < 1)
|
||||
throw new DomainException("Sprint duration must be at least 1 day");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace ColaFlow.Modules.ProjectManagement.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Sprint Status
|
||||
/// </summary>
|
||||
public class SprintStatus
|
||||
{
|
||||
public string Name { get; init; }
|
||||
|
||||
public static readonly SprintStatus Planned = new() { Name = "Planned" };
|
||||
public static readonly SprintStatus Active = new() { Name = "Active" };
|
||||
public static readonly SprintStatus Completed = new() { Name = "Completed" };
|
||||
|
||||
private SprintStatus() { Name = string.Empty; }
|
||||
|
||||
public static SprintStatus FromString(string status)
|
||||
{
|
||||
return status?.ToLowerInvariant() switch
|
||||
{
|
||||
"planned" => Planned,
|
||||
"active" => Active,
|
||||
"completed" => Completed,
|
||||
_ => throw new ArgumentException($"Invalid sprint status: {status}", nameof(status))
|
||||
};
|
||||
}
|
||||
|
||||
public static IEnumerable<SprintStatus> GetAll()
|
||||
{
|
||||
yield return Planned;
|
||||
yield return Active;
|
||||
yield return Completed;
|
||||
}
|
||||
|
||||
public override string ToString() => Name;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using MediatR;
|
||||
|
||||
namespace ColaFlow.Modules.ProjectManagement.Domain.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when a Sprint is completed
|
||||
/// </summary>
|
||||
public sealed record SprintCompletedEvent(Guid SprintId, string SprintName, Guid ProjectId, int TaskCount) : INotification;
|
||||
@@ -0,0 +1,8 @@
|
||||
using MediatR;
|
||||
|
||||
namespace ColaFlow.Modules.ProjectManagement.Domain.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when a Sprint is created
|
||||
/// </summary>
|
||||
public sealed record SprintCreatedEvent(Guid SprintId, string SprintName, Guid ProjectId) : INotification;
|
||||
@@ -0,0 +1,8 @@
|
||||
using MediatR;
|
||||
|
||||
namespace ColaFlow.Modules.ProjectManagement.Domain.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when a Sprint is deleted
|
||||
/// </summary>
|
||||
public sealed record SprintDeletedEvent(Guid SprintId, string SprintName, Guid ProjectId) : INotification;
|
||||
@@ -0,0 +1,8 @@
|
||||
using MediatR;
|
||||
|
||||
namespace ColaFlow.Modules.ProjectManagement.Domain.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when a Sprint is started
|
||||
/// </summary>
|
||||
public sealed record SprintStartedEvent(Guid SprintId, string SprintName, Guid ProjectId) : INotification;
|
||||
@@ -0,0 +1,8 @@
|
||||
using MediatR;
|
||||
|
||||
namespace ColaFlow.Modules.ProjectManagement.Domain.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when a Sprint is updated
|
||||
/// </summary>
|
||||
public sealed record SprintUpdatedEvent(Guid SprintId, string SprintName, Guid ProjectId) : INotification;
|
||||
@@ -0,0 +1,8 @@
|
||||
using MediatR;
|
||||
|
||||
namespace ColaFlow.Modules.ProjectManagement.Domain.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when a Task is added to a Sprint
|
||||
/// </summary>
|
||||
public sealed record TaskAddedToSprintEvent(Guid SprintId, Guid TaskId, Guid ProjectId) : INotification;
|
||||
@@ -0,0 +1,8 @@
|
||||
using MediatR;
|
||||
|
||||
namespace ColaFlow.Modules.ProjectManagement.Domain.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when a Task is removed from a Sprint
|
||||
/// </summary>
|
||||
public sealed record TaskRemovedFromSprintEvent(Guid SprintId, Guid TaskId, Guid ProjectId) : INotification;
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
|
||||
|
||||
/// <summary>
|
||||
/// Sprint ID Value Object
|
||||
/// </summary>
|
||||
public sealed record SprintId
|
||||
{
|
||||
public Guid Value { get; init; }
|
||||
|
||||
private SprintId(Guid value)
|
||||
{
|
||||
if (value == Guid.Empty)
|
||||
throw new ArgumentException("SprintId cannot be empty", nameof(value));
|
||||
|
||||
Value = value;
|
||||
}
|
||||
|
||||
public static SprintId Create() => new(Guid.NewGuid());
|
||||
|
||||
public static SprintId From(Guid value) => new(value);
|
||||
|
||||
public override string ToString() => Value.ToString();
|
||||
|
||||
public static implicit operator Guid(SprintId sprintId) => sprintId.Value;
|
||||
}
|
||||
Reference in New Issue
Block a user