From 8c6b611b17045a1f519df73ed84ae369f91b791f Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Wed, 5 Nov 2025 00:08:48 +0100 Subject: [PATCH] feat(backend): Implement Sprint Aggregate Root and Domain Events (Task 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Aggregates/ProjectAggregate/Sprint.cs | 165 ++++++++++++++++++ .../Enums/SprintStatus.cs | 35 ++++ .../Events/SprintCompletedEvent.cs | 8 + .../Events/SprintCreatedEvent.cs | 8 + .../Events/SprintDeletedEvent.cs | 8 + .../Events/SprintStartedEvent.cs | 8 + .../Events/SprintUpdatedEvent.cs | 8 + .../Events/TaskAddedToSprintEvent.cs | 8 + .../Events/TaskRemovedFromSprintEvent.cs | 8 + .../ValueObjects/SprintId.cs | 25 +++ 10 files changed, 281 insertions(+) create mode 100644 colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Aggregates/ProjectAggregate/Sprint.cs create mode 100644 colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Enums/SprintStatus.cs create mode 100644 colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Events/SprintCompletedEvent.cs create mode 100644 colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Events/SprintCreatedEvent.cs create mode 100644 colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Events/SprintDeletedEvent.cs create mode 100644 colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Events/SprintStartedEvent.cs create mode 100644 colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Events/SprintUpdatedEvent.cs create mode 100644 colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Events/TaskAddedToSprintEvent.cs create mode 100644 colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Events/TaskRemovedFromSprintEvent.cs create mode 100644 colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/ValueObjects/SprintId.cs diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Aggregates/ProjectAggregate/Sprint.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Aggregates/ProjectAggregate/Sprint.cs new file mode 100644 index 0000000..e4b5705 --- /dev/null +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Aggregates/ProjectAggregate/Sprint.cs @@ -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; + +/// +/// Sprint Entity (part of Project aggregate) +/// +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 _taskIds = new(); + public IReadOnlyCollection 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!; + } + + /// + /// Create a new Sprint + /// + 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 + }; + } + + /// + /// Update sprint details + /// + 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; + } + + /// + /// Start the sprint (Planned → Active) + /// + 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; + } + + /// + /// Complete the sprint (Active → Completed) + /// + 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; + } + + /// + /// Add a task to the sprint + /// + 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; + } + + /// + /// Remove a task from the sprint + /// + 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"); + } +} diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Enums/SprintStatus.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Enums/SprintStatus.cs new file mode 100644 index 0000000..ca201a2 --- /dev/null +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Enums/SprintStatus.cs @@ -0,0 +1,35 @@ +namespace ColaFlow.Modules.ProjectManagement.Domain.Enums; + +/// +/// Sprint Status +/// +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 GetAll() + { + yield return Planned; + yield return Active; + yield return Completed; + } + + public override string ToString() => Name; +} diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Events/SprintCompletedEvent.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Events/SprintCompletedEvent.cs new file mode 100644 index 0000000..017f6c1 --- /dev/null +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Events/SprintCompletedEvent.cs @@ -0,0 +1,8 @@ +using MediatR; + +namespace ColaFlow.Modules.ProjectManagement.Domain.Events; + +/// +/// Event raised when a Sprint is completed +/// +public sealed record SprintCompletedEvent(Guid SprintId, string SprintName, Guid ProjectId, int TaskCount) : INotification; diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Events/SprintCreatedEvent.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Events/SprintCreatedEvent.cs new file mode 100644 index 0000000..f2ba0e6 --- /dev/null +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Events/SprintCreatedEvent.cs @@ -0,0 +1,8 @@ +using MediatR; + +namespace ColaFlow.Modules.ProjectManagement.Domain.Events; + +/// +/// Event raised when a Sprint is created +/// +public sealed record SprintCreatedEvent(Guid SprintId, string SprintName, Guid ProjectId) : INotification; diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Events/SprintDeletedEvent.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Events/SprintDeletedEvent.cs new file mode 100644 index 0000000..7ecb885 --- /dev/null +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Events/SprintDeletedEvent.cs @@ -0,0 +1,8 @@ +using MediatR; + +namespace ColaFlow.Modules.ProjectManagement.Domain.Events; + +/// +/// Event raised when a Sprint is deleted +/// +public sealed record SprintDeletedEvent(Guid SprintId, string SprintName, Guid ProjectId) : INotification; diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Events/SprintStartedEvent.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Events/SprintStartedEvent.cs new file mode 100644 index 0000000..2839d82 --- /dev/null +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Events/SprintStartedEvent.cs @@ -0,0 +1,8 @@ +using MediatR; + +namespace ColaFlow.Modules.ProjectManagement.Domain.Events; + +/// +/// Event raised when a Sprint is started +/// +public sealed record SprintStartedEvent(Guid SprintId, string SprintName, Guid ProjectId) : INotification; diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Events/SprintUpdatedEvent.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Events/SprintUpdatedEvent.cs new file mode 100644 index 0000000..36dcc4c --- /dev/null +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Events/SprintUpdatedEvent.cs @@ -0,0 +1,8 @@ +using MediatR; + +namespace ColaFlow.Modules.ProjectManagement.Domain.Events; + +/// +/// Event raised when a Sprint is updated +/// +public sealed record SprintUpdatedEvent(Guid SprintId, string SprintName, Guid ProjectId) : INotification; diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Events/TaskAddedToSprintEvent.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Events/TaskAddedToSprintEvent.cs new file mode 100644 index 0000000..11aff2b --- /dev/null +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Events/TaskAddedToSprintEvent.cs @@ -0,0 +1,8 @@ +using MediatR; + +namespace ColaFlow.Modules.ProjectManagement.Domain.Events; + +/// +/// Event raised when a Task is added to a Sprint +/// +public sealed record TaskAddedToSprintEvent(Guid SprintId, Guid TaskId, Guid ProjectId) : INotification; diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Events/TaskRemovedFromSprintEvent.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Events/TaskRemovedFromSprintEvent.cs new file mode 100644 index 0000000..eb9981f --- /dev/null +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Events/TaskRemovedFromSprintEvent.cs @@ -0,0 +1,8 @@ +using MediatR; + +namespace ColaFlow.Modules.ProjectManagement.Domain.Events; + +/// +/// Event raised when a Task is removed from a Sprint +/// +public sealed record TaskRemovedFromSprintEvent(Guid SprintId, Guid TaskId, Guid ProjectId) : INotification; diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/ValueObjects/SprintId.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/ValueObjects/SprintId.cs new file mode 100644 index 0000000..8eb3884 --- /dev/null +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/ValueObjects/SprintId.cs @@ -0,0 +1,25 @@ +namespace ColaFlow.Modules.ProjectManagement.Domain.ValueObjects; + +/// +/// Sprint ID Value Object +/// +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; +}