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:
Yaojia Wang
2025-11-05 00:08:48 +01:00
parent 7680441092
commit 8c6b611b17
10 changed files with 281 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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