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,90 @@
using ColaFlow.Domain.Common;
using ColaFlow.Domain.Exceptions;
using ColaFlow.Domain.ValueObjects;
namespace ColaFlow.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,113 @@
using ColaFlow.Domain.Common;
using ColaFlow.Domain.Events;
using ColaFlow.Domain.Exceptions;
using ColaFlow.Domain.ValueObjects;
namespace ColaFlow.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,111 @@
using ColaFlow.Domain.Common;
using ColaFlow.Domain.Exceptions;
using ColaFlow.Domain.ValueObjects;
namespace ColaFlow.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,108 @@
using ColaFlow.Domain.Common;
using ColaFlow.Domain.Exceptions;
using ColaFlow.Domain.ValueObjects;
namespace ColaFlow.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,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,31 @@
using ColaFlow.Domain.Events;
namespace ColaFlow.Domain.Common;
/// <summary>
/// Base class for all aggregate roots
/// </summary>
public abstract class AggregateRoot : Entity
{
private readonly List<DomainEvent> _domainEvents = new();
public IReadOnlyCollection<DomainEvent> DomainEvents => _domainEvents.AsReadOnly();
protected AggregateRoot() : base()
{
}
protected AggregateRoot(Guid id) : base(id)
{
}
protected void AddDomainEvent(DomainEvent domainEvent)
{
_domainEvents.Add(domainEvent);
}
public void ClearDomainEvents()
{
_domainEvents.Clear();
}
}

View File

@@ -0,0 +1,54 @@
namespace ColaFlow.Domain.Common;
/// <summary>
/// Base class for all entities
/// </summary>
public abstract class Entity
{
public Guid Id { get; protected set; }
protected Entity()
{
Id = Guid.NewGuid();
}
protected Entity(Guid id)
{
Id = id;
}
public override bool Equals(object? obj)
{
if (obj is not Entity other)
return false;
if (ReferenceEquals(this, other))
return true;
if (GetType() != other.GetType())
return false;
return Id == other.Id;
}
public static bool operator ==(Entity? a, Entity? b)
{
if (a is null && b is null)
return true;
if (a is null || b is null)
return false;
return a.Equals(b);
}
public static bool operator !=(Entity? a, Entity? b)
{
return !(a == b);
}
public override int GetHashCode()
{
return Id.GetHashCode();
}
}

View File

@@ -0,0 +1,78 @@
using System.Reflection;
namespace ColaFlow.Domain.Common;
/// <summary>
/// Base class for creating type-safe enumerations
/// </summary>
public abstract class Enumeration : IComparable
{
public int Id { get; private set; }
public string Name { get; private set; }
protected Enumeration(int id, string name)
{
Id = id;
Name = name;
}
public override string ToString() => Name;
public static IEnumerable<T> GetAll<T>() where T : Enumeration
{
var fields = typeof(T).GetFields(BindingFlags.Public |
BindingFlags.Static |
BindingFlags.DeclaredOnly);
return fields.Select(f => f.GetValue(null)).Cast<T>();
}
public override bool Equals(object? obj)
{
if (obj is not Enumeration otherValue)
{
return false;
}
var typeMatches = GetType().Equals(obj.GetType());
var valueMatches = Id.Equals(otherValue.Id);
return typeMatches && valueMatches;
}
public override int GetHashCode() => Id.GetHashCode();
public static int AbsoluteDifference(Enumeration firstValue, Enumeration secondValue)
{
var absoluteDifference = Math.Abs(firstValue.Id - secondValue.Id);
return absoluteDifference;
}
public static T FromValue<T>(int value) where T : Enumeration
{
var matchingItem = Parse<T, int>(value, "value", item => item.Id == value);
return matchingItem;
}
public static T FromDisplayName<T>(string displayName) where T : Enumeration
{
var matchingItem = Parse<T, string>(displayName, "display name", item => item.Name == displayName);
return matchingItem;
}
private static T Parse<T, K>(K value, string description, Func<T, bool> predicate) where T : Enumeration
{
var matchingItem = GetAll<T>().FirstOrDefault(predicate);
if (matchingItem == null)
throw new InvalidOperationException($"'{value}' is not a valid {description} in {typeof(T)}");
return matchingItem;
}
public int CompareTo(object? other)
{
if (other == null) return 1;
return Id.CompareTo(((Enumeration)other).Id);
}
}

View File

@@ -0,0 +1,46 @@
namespace ColaFlow.Domain.Common;
/// <summary>
/// Base class for all value objects
/// </summary>
public abstract class ValueObject
{
protected abstract IEnumerable<object> GetAtomicValues();
public override bool Equals(object? obj)
{
if (obj == null || obj.GetType() != GetType())
return false;
var other = (ValueObject)obj;
return GetAtomicValues().SequenceEqual(other.GetAtomicValues());
}
public override int GetHashCode()
{
return GetAtomicValues()
.Aggregate(1, (current, obj) =>
{
unchecked
{
return (current * 23) + (obj?.GetHashCode() ?? 0);
}
});
}
public static bool operator ==(ValueObject? a, ValueObject? b)
{
if (a is null && b is null)
return true;
if (a is null || b is null)
return false;
return a.Equals(b);
}
public static bool operator !=(ValueObject? a, ValueObject? b)
{
return !(a == b);
}
}

View File

@@ -0,0 +1,10 @@
namespace ColaFlow.Domain.Events;
/// <summary>
/// Base class for all domain events
/// </summary>
public abstract record DomainEvent
{
public Guid EventId { get; init; } = Guid.NewGuid();
public DateTime OccurredOn { get; init; } = DateTime.UtcNow;
}

View File

@@ -0,0 +1,12 @@
using ColaFlow.Domain.ValueObjects;
namespace ColaFlow.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,10 @@
using ColaFlow.Domain.ValueObjects;
namespace ColaFlow.Domain.Events;
/// <summary>
/// Event raised when a project is archived
/// </summary>
public sealed record ProjectArchivedEvent(
ProjectId ProjectId
) : DomainEvent;

View File

@@ -0,0 +1,12 @@
using ColaFlow.Domain.ValueObjects;
namespace ColaFlow.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,12 @@
using ColaFlow.Domain.ValueObjects;
namespace ColaFlow.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.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,26 @@
using ColaFlow.Domain.Common;
namespace ColaFlow.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);
protected override IEnumerable<object> GetAtomicValues()
{
yield return Value;
}
public override string ToString() => Value.ToString();
}

View File

@@ -0,0 +1,26 @@
using ColaFlow.Domain.Common;
namespace ColaFlow.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);
protected override IEnumerable<object> GetAtomicValues()
{
yield return Value;
}
public override string ToString() => Value.ToString();
}

View File

@@ -0,0 +1,38 @@
using ColaFlow.Domain.Common;
using ColaFlow.Domain.Exceptions;
namespace ColaFlow.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.Domain.Common;
namespace ColaFlow.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,26 @@
using ColaFlow.Domain.Common;
namespace ColaFlow.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);
protected override IEnumerable<object> GetAtomicValues()
{
yield return Value;
}
public override string ToString() => Value.ToString();
}

View File

@@ -0,0 +1,26 @@
using ColaFlow.Domain.Common;
namespace ColaFlow.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);
protected override IEnumerable<object> GetAtomicValues()
{
yield return Value;
}
public override string ToString() => Value.ToString();
}

View File

@@ -0,0 +1,18 @@
using ColaFlow.Domain.Common;
namespace ColaFlow.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,26 @@
using ColaFlow.Domain.Common;
namespace ColaFlow.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);
protected override IEnumerable<object> GetAtomicValues()
{
yield return Value;
}
public override string ToString() => Value.ToString();
}

View File

@@ -0,0 +1,19 @@
using ColaFlow.Domain.Common;
namespace ColaFlow.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)
{
}
}