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,91 @@
using ColaFlow.Shared.Kernel.Common;
using ColaFlow.Modules.ProjectManagement.Domain.Exceptions;
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
using ColaFlow.Modules.ProjectManagement.Domain.Events;
namespace ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate;
/// <summary>
/// Epic Entity (part of Project aggregate)
/// </summary>
public class Epic : Entity
{
public new EpicId Id { get; private set; }
public string Name { get; private set; }
public string Description { get; private set; }
public ProjectId ProjectId { get; private set; }
public WorkItemStatus Status { get; private set; }
public TaskPriority Priority { get; private set; }
private readonly List<Story> _stories = new();
public IReadOnlyCollection<Story> Stories => _stories.AsReadOnly();
public DateTime CreatedAt { get; private set; }
public UserId CreatedBy { get; private set; }
public DateTime? UpdatedAt { get; private set; }
// EF Core constructor
private Epic()
{
Id = null!;
Name = null!;
Description = null!;
ProjectId = null!;
Status = null!;
Priority = null!;
CreatedBy = null!;
}
public static Epic Create(string name, string description, ProjectId projectId, UserId createdBy)
{
if (string.IsNullOrWhiteSpace(name))
throw new DomainException("Epic name cannot be empty");
if (name.Length > 200)
throw new DomainException("Epic name cannot exceed 200 characters");
return new Epic
{
Id = EpicId.Create(),
Name = name,
Description = description ?? string.Empty,
ProjectId = projectId,
Status = WorkItemStatus.ToDo,
Priority = TaskPriority.Medium,
CreatedAt = DateTime.UtcNow,
CreatedBy = createdBy
};
}
public Story CreateStory(string title, string description, TaskPriority priority, UserId createdBy)
{
var story = Story.Create(title, description, this.Id, priority, createdBy);
_stories.Add(story);
return story;
}
public void UpdateDetails(string name, string description)
{
if (string.IsNullOrWhiteSpace(name))
throw new DomainException("Epic name cannot be empty");
if (name.Length > 200)
throw new DomainException("Epic name cannot exceed 200 characters");
Name = name;
Description = description ?? string.Empty;
UpdatedAt = DateTime.UtcNow;
}
public void UpdateStatus(WorkItemStatus newStatus)
{
Status = newStatus;
UpdatedAt = DateTime.UtcNow;
}
public void UpdatePriority(TaskPriority newPriority)
{
Priority = newPriority;
UpdatedAt = DateTime.UtcNow;
}
}

View File

@@ -0,0 +1,114 @@
using ColaFlow.Shared.Kernel.Common;
using ColaFlow.Shared.Kernel.Events;
using ColaFlow.Modules.ProjectManagement.Domain.Exceptions;
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
using ColaFlow.Modules.ProjectManagement.Domain.Events;
namespace ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate;
/// <summary>
/// Project Aggregate Root
/// Enforces consistency boundary for Project -> Epic -> Story -> Task hierarchy
/// </summary>
public class Project : AggregateRoot
{
public new ProjectId Id { get; private set; }
public string Name { get; private set; }
public string Description { get; private set; }
public ProjectKey Key { get; private set; }
public ProjectStatus Status { get; private set; }
public UserId OwnerId { get; private set; }
private readonly List<Epic> _epics = new();
public IReadOnlyCollection<Epic> Epics => _epics.AsReadOnly();
public DateTime CreatedAt { get; private set; }
public DateTime? UpdatedAt { get; private set; }
// EF Core constructor
private Project()
{
Id = null!;
Name = null!;
Description = null!;
Key = null!;
Status = null!;
OwnerId = null!;
}
// Factory method
public static Project Create(string name, string description, string key, UserId ownerId)
{
// Validation
if (string.IsNullOrWhiteSpace(name))
throw new DomainException("Project name cannot be empty");
if (name.Length > 200)
throw new DomainException("Project name cannot exceed 200 characters");
var project = new Project
{
Id = ProjectId.Create(),
Name = name,
Description = description ?? string.Empty,
Key = ProjectKey.Create(key),
Status = ProjectStatus.Active,
OwnerId = ownerId,
CreatedAt = DateTime.UtcNow
};
// Raise domain event
project.AddDomainEvent(new ProjectCreatedEvent(project.Id, project.Name, ownerId));
return project;
}
// Business methods
public void UpdateDetails(string name, string description)
{
if (string.IsNullOrWhiteSpace(name))
throw new DomainException("Project name cannot be empty");
if (name.Length > 200)
throw new DomainException("Project name cannot exceed 200 characters");
Name = name;
Description = description ?? string.Empty;
UpdatedAt = DateTime.UtcNow;
AddDomainEvent(new ProjectUpdatedEvent(Id, Name, Description));
}
public Epic CreateEpic(string name, string description, UserId createdBy)
{
if (Status == ProjectStatus.Archived)
throw new DomainException("Cannot create epic in an archived project");
var epic = Epic.Create(name, description, this.Id, createdBy);
_epics.Add(epic);
AddDomainEvent(new EpicCreatedEvent(epic.Id, epic.Name, this.Id));
return epic;
}
public void Archive()
{
if (Status == ProjectStatus.Archived)
throw new DomainException("Project is already archived");
Status = ProjectStatus.Archived;
UpdatedAt = DateTime.UtcNow;
AddDomainEvent(new ProjectArchivedEvent(Id));
}
public void Activate()
{
if (Status == ProjectStatus.Active)
throw new DomainException("Project is already active");
Status = ProjectStatus.Active;
UpdatedAt = DateTime.UtcNow;
}
}

View File

@@ -0,0 +1,112 @@
using ColaFlow.Shared.Kernel.Common;
using ColaFlow.Modules.ProjectManagement.Domain.Exceptions;
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
using ColaFlow.Modules.ProjectManagement.Domain.Events;
namespace ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate;
/// <summary>
/// Story Entity (part of Project aggregate)
/// </summary>
public class Story : Entity
{
public new StoryId Id { get; private set; }
public string Title { get; private set; }
public string Description { get; private set; }
public EpicId EpicId { get; private set; }
public WorkItemStatus Status { get; private set; }
public TaskPriority Priority { get; private set; }
public decimal? EstimatedHours { get; private set; }
public decimal? ActualHours { get; private set; }
public UserId? AssigneeId { get; private set; }
private readonly List<WorkTask> _tasks = new();
public IReadOnlyCollection<WorkTask> Tasks => _tasks.AsReadOnly();
public DateTime CreatedAt { get; private set; }
public UserId CreatedBy { get; private set; }
public DateTime? UpdatedAt { get; private set; }
// EF Core constructor
private Story()
{
Id = null!;
Title = null!;
Description = null!;
EpicId = null!;
Status = null!;
Priority = null!;
CreatedBy = null!;
}
public static Story Create(string title, string description, EpicId epicId, TaskPriority priority, UserId createdBy)
{
if (string.IsNullOrWhiteSpace(title))
throw new DomainException("Story title cannot be empty");
if (title.Length > 200)
throw new DomainException("Story title cannot exceed 200 characters");
return new Story
{
Id = StoryId.Create(),
Title = title,
Description = description ?? string.Empty,
EpicId = epicId,
Status = WorkItemStatus.ToDo,
Priority = priority,
CreatedAt = DateTime.UtcNow,
CreatedBy = createdBy
};
}
public WorkTask CreateTask(string title, string description, TaskPriority priority, UserId createdBy)
{
var task = WorkTask.Create(title, description, this.Id, priority, createdBy);
_tasks.Add(task);
return task;
}
public void UpdateDetails(string title, string description)
{
if (string.IsNullOrWhiteSpace(title))
throw new DomainException("Story title cannot be empty");
if (title.Length > 200)
throw new DomainException("Story title cannot exceed 200 characters");
Title = title;
Description = description ?? string.Empty;
UpdatedAt = DateTime.UtcNow;
}
public void UpdateStatus(WorkItemStatus newStatus)
{
Status = newStatus;
UpdatedAt = DateTime.UtcNow;
}
public void AssignTo(UserId assigneeId)
{
AssigneeId = assigneeId;
UpdatedAt = DateTime.UtcNow;
}
public void UpdateEstimate(decimal hours)
{
if (hours < 0)
throw new DomainException("Estimated hours cannot be negative");
EstimatedHours = hours;
UpdatedAt = DateTime.UtcNow;
}
public void LogActualHours(decimal hours)
{
if (hours < 0)
throw new DomainException("Actual hours cannot be negative");
ActualHours = hours;
UpdatedAt = DateTime.UtcNow;
}
}

View File

@@ -0,0 +1,109 @@
using ColaFlow.Shared.Kernel.Common;
using ColaFlow.Modules.ProjectManagement.Domain.Exceptions;
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
using ColaFlow.Modules.ProjectManagement.Domain.Events;
namespace ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate;
/// <summary>
/// Task Entity (part of Project aggregate)
/// Named "WorkTask" to avoid conflict with System.Threading.Tasks.Task
/// </summary>
public class WorkTask : Entity
{
public new TaskId Id { get; private set; }
public string Title { get; private set; }
public string Description { get; private set; }
public StoryId StoryId { get; private set; }
public WorkItemStatus Status { get; private set; }
public TaskPriority Priority { get; private set; }
public decimal? EstimatedHours { get; private set; }
public decimal? ActualHours { get; private set; }
public UserId? AssigneeId { get; private set; }
public DateTime CreatedAt { get; private set; }
public UserId CreatedBy { get; private set; }
public DateTime? UpdatedAt { get; private set; }
// EF Core constructor
private WorkTask()
{
Id = null!;
Title = null!;
Description = null!;
StoryId = null!;
Status = null!;
Priority = null!;
CreatedBy = null!;
}
public static WorkTask Create(string title, string description, StoryId storyId, TaskPriority priority, UserId createdBy)
{
if (string.IsNullOrWhiteSpace(title))
throw new DomainException("Task title cannot be empty");
if (title.Length > 200)
throw new DomainException("Task title cannot exceed 200 characters");
return new WorkTask
{
Id = TaskId.Create(),
Title = title,
Description = description ?? string.Empty,
StoryId = storyId,
Status = WorkItemStatus.ToDo,
Priority = priority,
CreatedAt = DateTime.UtcNow,
CreatedBy = createdBy
};
}
public void UpdateDetails(string title, string description)
{
if (string.IsNullOrWhiteSpace(title))
throw new DomainException("Task title cannot be empty");
if (title.Length > 200)
throw new DomainException("Task title cannot exceed 200 characters");
Title = title;
Description = description ?? string.Empty;
UpdatedAt = DateTime.UtcNow;
}
public void UpdateStatus(WorkItemStatus newStatus)
{
Status = newStatus;
UpdatedAt = DateTime.UtcNow;
}
public void AssignTo(UserId assigneeId)
{
AssigneeId = assigneeId;
UpdatedAt = DateTime.UtcNow;
}
public void UpdatePriority(TaskPriority newPriority)
{
Priority = newPriority;
UpdatedAt = DateTime.UtcNow;
}
public void UpdateEstimate(decimal hours)
{
if (hours < 0)
throw new DomainException("Estimated hours cannot be negative");
EstimatedHours = hours;
UpdatedAt = DateTime.UtcNow;
}
public void LogActualHours(decimal hours)
{
if (hours < 0)
throw new DomainException("Actual hours cannot be negative");
ActualHours = hours;
UpdatedAt = DateTime.UtcNow;
}
}

View File

@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\..\..\Shared\ColaFlow.Shared.Kernel\ColaFlow.Shared.Kernel.csproj" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>ColaFlow.Modules.ProjectManagement.Domain</AssemblyName>
<RootNamespace>ColaFlow.Modules.ProjectManagement.Domain</RootNamespace>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,13 @@
using ColaFlow.Shared.Kernel.Events;
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
namespace ColaFlow.Modules.ProjectManagement.Domain.Events;
/// <summary>
/// Event raised when an epic is created
/// </summary>
public sealed record EpicCreatedEvent(
EpicId EpicId,
string EpicName,
ProjectId ProjectId
) : DomainEvent;

View File

@@ -0,0 +1,11 @@
using ColaFlow.Shared.Kernel.Events;
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
namespace ColaFlow.Modules.ProjectManagement.Domain.Events;
/// <summary>
/// Event raised when a project is archived
/// </summary>
public sealed record ProjectArchivedEvent(
ProjectId ProjectId
) : DomainEvent;

View File

@@ -0,0 +1,13 @@
using ColaFlow.Shared.Kernel.Events;
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
namespace ColaFlow.Modules.ProjectManagement.Domain.Events;
/// <summary>
/// Event raised when a project is created
/// </summary>
public sealed record ProjectCreatedEvent(
ProjectId ProjectId,
string ProjectName,
UserId CreatedBy
) : DomainEvent;

View File

@@ -0,0 +1,13 @@
using ColaFlow.Shared.Kernel.Events;
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
namespace ColaFlow.Modules.ProjectManagement.Domain.Events;
/// <summary>
/// Event raised when a project is updated
/// </summary>
public sealed record ProjectUpdatedEvent(
ProjectId ProjectId,
string Name,
string Description
) : DomainEvent;

View File

@@ -0,0 +1,18 @@
namespace ColaFlow.Modules.ProjectManagement.Domain.Exceptions;
/// <summary>
/// Exception type for domain layer
/// </summary>
public class DomainException : Exception
{
public DomainException()
{ }
public DomainException(string message)
: base(message)
{ }
public DomainException(string message, Exception innerException)
: base(message, innerException)
{ }
}

View File

@@ -0,0 +1,22 @@
namespace ColaFlow.Modules.ProjectManagement.Domain.Exceptions;
/// <summary>
/// Exception type for not found resources
/// </summary>
public class NotFoundException : Exception
{
public NotFoundException()
{ }
public NotFoundException(string message)
: base(message)
{ }
public NotFoundException(string message, Exception innerException)
: base(message, innerException)
{ }
public NotFoundException(string entityName, object key)
: base($"Entity '{entityName}' with key '{key}' was not found.")
{ }
}

View File

@@ -0,0 +1,55 @@
using ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate;
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
namespace ColaFlow.Modules.ProjectManagement.Domain.Repositories;
/// <summary>
/// Repository interface for Project aggregate
/// </summary>
public interface IProjectRepository
{
/// <summary>
/// Gets a project by its ID
/// </summary>
Task<Project?> GetByIdAsync(ProjectId id, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a project by its unique key
/// </summary>
Task<Project?> GetByKeyAsync(string key, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all projects with pagination
/// </summary>
Task<List<Project>> GetAllAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Gets project containing specific epic
/// </summary>
Task<Project?> GetProjectWithEpicAsync(EpicId epicId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets project containing specific story
/// </summary>
Task<Project?> GetProjectWithStoryAsync(StoryId storyId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets project containing specific task
/// </summary>
Task<Project?> GetProjectWithTaskAsync(TaskId taskId, CancellationToken cancellationToken = default);
/// <summary>
/// Adds a new project
/// </summary>
Task AddAsync(Project project, CancellationToken cancellationToken = default);
/// <summary>
/// Updates an existing project
/// </summary>
void Update(Project project);
/// <summary>
/// Deletes a project
/// </summary>
void Delete(Project project);
}

View File

@@ -0,0 +1,15 @@
namespace ColaFlow.Modules.ProjectManagement.Domain.Repositories;
/// <summary>
/// Unit of Work pattern interface
/// Coordinates the work of multiple repositories and ensures transactional consistency
/// </summary>
public interface IUnitOfWork
{
/// <summary>
/// Saves all changes made in this unit of work to the database
/// </summary>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The number of entities written to the database</returns>
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,27 @@
using ColaFlow.Shared.Kernel.Common;
namespace ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
/// <summary>
/// EpicId Value Object (strongly-typed ID)
/// </summary>
public sealed class EpicId : ValueObject
{
public Guid Value { get; private set; }
private EpicId(Guid value)
{
Value = value;
}
public static EpicId Create() => new EpicId(Guid.NewGuid());
public static EpicId Create(Guid value) => new EpicId(value);
public static EpicId From(Guid value) => new EpicId(value);
protected override IEnumerable<object> GetAtomicValues()
{
yield return Value;
}
public override string ToString() => Value.ToString();
}

View File

@@ -0,0 +1,27 @@
using ColaFlow.Shared.Kernel.Common;
namespace ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
/// <summary>
/// ProjectId Value Object (strongly-typed ID)
/// </summary>
public sealed class ProjectId : ValueObject
{
public Guid Value { get; private set; }
private ProjectId(Guid value)
{
Value = value;
}
public static ProjectId Create() => new ProjectId(Guid.NewGuid());
public static ProjectId Create(Guid value) => new ProjectId(value);
public static ProjectId From(Guid value) => new ProjectId(value);
protected override IEnumerable<object> GetAtomicValues()
{
yield return Value;
}
public override string ToString() => Value.ToString();
}

View File

@@ -0,0 +1,38 @@
using ColaFlow.Shared.Kernel.Common;
using ColaFlow.Modules.ProjectManagement.Domain.Exceptions;
namespace ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
/// <summary>
/// ProjectKey Value Object (e.g., "COLA", "FLOW")
/// </summary>
public sealed class ProjectKey : ValueObject
{
public string Value { get; private set; }
private ProjectKey(string value)
{
Value = value;
}
public static ProjectKey Create(string value)
{
if (string.IsNullOrWhiteSpace(value))
throw new DomainException("Project key cannot be empty");
if (value.Length > 10)
throw new DomainException("Project key cannot exceed 10 characters");
if (!System.Text.RegularExpressions.Regex.IsMatch(value, "^[A-Z0-9]+$"))
throw new DomainException("Project key must contain only uppercase letters and numbers");
return new ProjectKey(value);
}
protected override IEnumerable<object> GetAtomicValues()
{
yield return Value;
}
public override string ToString() => Value;
}

View File

@@ -0,0 +1,17 @@
using ColaFlow.Shared.Kernel.Common;
namespace ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
/// <summary>
/// ProjectStatus Enumeration
/// </summary>
public sealed class ProjectStatus : Enumeration
{
public static readonly ProjectStatus Active = new(1, "Active");
public static readonly ProjectStatus Archived = new(2, "Archived");
public static readonly ProjectStatus OnHold = new(3, "On Hold");
private ProjectStatus(int id, string name) : base(id, name)
{
}
}

View File

@@ -0,0 +1,27 @@
using ColaFlow.Shared.Kernel.Common;
namespace ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
/// <summary>
/// StoryId Value Object (strongly-typed ID)
/// </summary>
public sealed class StoryId : ValueObject
{
public Guid Value { get; private set; }
private StoryId(Guid value)
{
Value = value;
}
public static StoryId Create() => new StoryId(Guid.NewGuid());
public static StoryId Create(Guid value) => new StoryId(value);
public static StoryId From(Guid value) => new StoryId(value);
protected override IEnumerable<object> GetAtomicValues()
{
yield return Value;
}
public override string ToString() => Value.ToString();
}

View File

@@ -0,0 +1,27 @@
using ColaFlow.Shared.Kernel.Common;
namespace ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
/// <summary>
/// TaskId Value Object (strongly-typed ID)
/// </summary>
public sealed class TaskId : ValueObject
{
public Guid Value { get; private set; }
private TaskId(Guid value)
{
Value = value;
}
public static TaskId Create() => new TaskId(Guid.NewGuid());
public static TaskId Create(Guid value) => new TaskId(value);
public static TaskId From(Guid value) => new TaskId(value);
protected override IEnumerable<object> GetAtomicValues()
{
yield return Value;
}
public override string ToString() => Value.ToString();
}

View File

@@ -0,0 +1,18 @@
using ColaFlow.Shared.Kernel.Common;
namespace ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
/// <summary>
/// TaskPriority Enumeration
/// </summary>
public sealed class TaskPriority : Enumeration
{
public static readonly TaskPriority Low = new(1, "Low");
public static readonly TaskPriority Medium = new(2, "Medium");
public static readonly TaskPriority High = new(3, "High");
public static readonly TaskPriority Urgent = new(4, "Urgent");
private TaskPriority(int id, string name) : base(id, name)
{
}
}

View File

@@ -0,0 +1,27 @@
using ColaFlow.Shared.Kernel.Common;
namespace ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
/// <summary>
/// UserId Value Object (strongly-typed ID)
/// </summary>
public sealed class UserId : ValueObject
{
public Guid Value { get; private set; }
private UserId(Guid value)
{
Value = value;
}
public static UserId Create() => new UserId(Guid.NewGuid());
public static UserId Create(Guid value) => new UserId(value);
public static UserId From(Guid value) => new UserId(value);
protected override IEnumerable<object> GetAtomicValues()
{
yield return Value;
}
public override string ToString() => Value.ToString();
}

View File

@@ -0,0 +1,19 @@
using ColaFlow.Shared.Kernel.Common;
namespace ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
/// <summary>
/// WorkItemStatus Enumeration (renamed from TaskStatus to avoid conflict with System.Threading.Tasks.TaskStatus)
/// </summary>
public sealed class WorkItemStatus : Enumeration
{
public static readonly WorkItemStatus ToDo = new(1, "To Do");
public static readonly WorkItemStatus InProgress = new(2, "In Progress");
public static readonly WorkItemStatus InReview = new(3, "In Review");
public static readonly WorkItemStatus Done = new(4, "Done");
public static readonly WorkItemStatus Blocked = new(5, "Blocked");
private WorkItemStatus(int id, string name) : base(id, name)
{
}
}