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,30 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Scalar.AspNetCore" Version="2.9.0" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Modules\ProjectManagement\ColaFlow.Modules.ProjectManagement.Application\ColaFlow.Modules.ProjectManagement.Application.csproj" />
<ProjectReference Include="..\Modules\ProjectManagement\ColaFlow.Modules.ProjectManagement.Infrastructure\ColaFlow.Modules.ProjectManagement.Infrastructure.csproj" />
<ProjectReference Include="..\Shared\ColaFlow.Shared.Kernel\ColaFlow.Shared.Kernel.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MediatR" Version="11.1.0" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.10.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,6 @@
@ColaFlow.API_HostAddress = http://localhost:5167
GET {{ColaFlow.API_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@@ -0,0 +1,62 @@
using MediatR;
using Microsoft.AspNetCore.Mvc;
using ColaFlow.Modules.ProjectManagement.Application.DTOs;
using ColaFlow.Modules.ProjectManagement.Application.Commands.CreateProject;
using ColaFlow.Modules.ProjectManagement.Application.Queries.GetProjectById;
using ColaFlow.Modules.ProjectManagement.Application.Queries.GetProjects;
namespace ColaFlow.API.Controllers;
/// <summary>
/// Projects API Controller
/// </summary>
[ApiController]
[Route("api/v1/[controller]")]
public class ProjectsController : ControllerBase
{
private readonly IMediator _mediator;
public ProjectsController(IMediator mediator)
{
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
}
/// <summary>
/// Get all projects
/// </summary>
[HttpGet]
[ProducesResponseType(typeof(List<ProjectDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetProjects(CancellationToken cancellationToken = default)
{
var query = new GetProjectsQuery();
var result = await _mediator.Send(query, cancellationToken);
return Ok(result);
}
/// <summary>
/// Get project by ID
/// </summary>
[HttpGet("{id:guid}")]
[ProducesResponseType(typeof(ProjectDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetProject(Guid id, CancellationToken cancellationToken = default)
{
var query = new GetProjectByIdQuery(id);
var result = await _mediator.Send(query, cancellationToken);
return Ok(result);
}
/// <summary>
/// Create a new project
/// </summary>
[HttpPost]
[ProducesResponseType(typeof(ProjectDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> CreateProject(
[FromBody] CreateProjectCommand command,
CancellationToken cancellationToken = default)
{
var result = await _mediator.Send(command, cancellationToken);
return CreatedAtAction(nameof(GetProject), new { id = result.Id }, result);
}
}

View File

@@ -0,0 +1,46 @@
using Microsoft.EntityFrameworkCore;
using FluentValidation;
using MediatR;
using ColaFlow.Modules.ProjectManagement.Application.Behaviors;
using ColaFlow.Modules.ProjectManagement.Application.Commands.CreateProject;
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
using ColaFlow.Modules.ProjectManagement.Infrastructure.Persistence;
using ColaFlow.Modules.ProjectManagement.Infrastructure.Repositories;
namespace ColaFlow.API.Extensions;
/// <summary>
/// Extension methods for registering modules
/// </summary>
public static class ModuleExtensions
{
/// <summary>
/// Register ProjectManagement Module
/// </summary>
public static IServiceCollection AddProjectManagementModule(
this IServiceCollection services,
IConfiguration configuration)
{
// Register DbContext
var connectionString = configuration.GetConnectionString("PMDatabase");
services.AddDbContext<PMDbContext>(options =>
options.UseNpgsql(connectionString));
// Register repositories
services.AddScoped<IProjectRepository, ProjectRepository>();
services.AddScoped<IUnitOfWork, UnitOfWork>();
// Register MediatR handlers from Application assembly
services.AddMediatR(typeof(CreateProjectCommand).Assembly);
// Register FluentValidation validators
services.AddValidatorsFromAssembly(typeof(CreateProjectCommand).Assembly);
// Register pipeline behaviors
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
Console.WriteLine("[ProjectManagement] Module registered");
return services;
}
}

View File

@@ -0,0 +1,96 @@
using System.Net;
using System.Text.Json;
using FluentValidation;
using ColaFlow.Modules.ProjectManagement.Domain.Exceptions;
namespace ColaFlow.API.Middleware;
/// <summary>
/// Global exception handler middleware
/// </summary>
public class GlobalExceptionHandlerMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<GlobalExceptionHandlerMiddleware> _logger;
public GlobalExceptionHandlerMiddleware(
RequestDelegate next,
ILogger<GlobalExceptionHandlerMiddleware> logger)
{
_next = next ?? throw new ArgumentNullException(nameof(next));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
await HandleExceptionAsync(context, ex);
}
}
private async Task HandleExceptionAsync(HttpContext context, Exception exception)
{
context.Response.ContentType = "application/json";
var (statusCode, response) = exception switch
{
ValidationException validationEx => (
StatusCodes.Status400BadRequest,
new
{
StatusCode = StatusCodes.Status400BadRequest,
Message = "Validation failed",
Errors = validationEx.Errors.Select(e => new
{
Property = e.PropertyName,
Message = e.ErrorMessage
})
}),
DomainException domainEx => (
StatusCodes.Status400BadRequest,
new
{
StatusCode = StatusCodes.Status400BadRequest,
Message = domainEx.Message
}),
NotFoundException notFoundEx => (
StatusCodes.Status404NotFound,
new
{
StatusCode = StatusCodes.Status404NotFound,
Message = notFoundEx.Message
}),
_ => (
StatusCodes.Status500InternalServerError,
new
{
StatusCode = StatusCodes.Status500InternalServerError,
Message = "An internal server error occurred"
})
};
context.Response.StatusCode = statusCode;
// Log with appropriate level
if (statusCode >= 500)
{
_logger.LogError(exception, "Internal server error occurred: {Message}", exception.Message);
}
else if (statusCode >= 400)
{
_logger.LogWarning(exception, "Client error occurred: {Message}", exception.Message);
}
var jsonResponse = JsonSerializer.Serialize(response, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
await context.Response.WriteAsync(jsonResponse);
}
}

View File

@@ -0,0 +1,31 @@
using ColaFlow.API.Extensions;
using ColaFlow.API.Middleware;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
// Register ProjectManagement Module
builder.Services.AddProjectManagementModule(builder.Configuration);
// Add controllers
builder.Services.AddControllers();
// Configure OpenAPI/Scalar
builder.Services.AddOpenApi();
var app = builder.Build();
// Configure the HTTP request pipeline
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
// Global exception handler (should be first in pipeline)
app.UseMiddleware<GlobalExceptionHandlerMiddleware>();
app.UseHttpsRedirection();
app.MapControllers();
app.Run();

View File

@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5167",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7295;http://localhost:5167",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,12 @@
{
"ConnectionStrings": {
"PMDatabase": "Host=localhost;Port=5432;Database=colaflow_pm;Username=colaflow;Password=colaflow_dev_password"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Information"
}
}
}

View File

@@ -0,0 +1,12 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"PMDatabase": "Host=localhost;Port=5432;Database=colaflow;Username=colaflow;Password=colaflow_dev_password"
}
}

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\ColaFlow.Domain\ColaFlow.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="AutoMapper" Version="12.0.1" />
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />
<PackageReference Include="FluentValidation" Version="12.0.0" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.0.0" />
<PackageReference Include="MediatR" Version="11.1.0" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

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)
{
}
}

View File

@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\ColaFlow.Domain\ColaFlow.Domain.csproj" />
<ProjectReference Include="..\ColaFlow.Application\ColaFlow.Application.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.10">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="StackExchange.Redis" Version="2.9.32" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,46 @@
using FluentValidation;
using MediatR;
namespace ColaFlow.Modules.ProjectManagement.Application.Behaviors;
/// <summary>
/// Pipeline behavior for request validation using FluentValidation
/// </summary>
public sealed class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
{
_validators = validators;
}
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
if (!_validators.Any())
{
return await next();
}
var context = new ValidationContext<TRequest>(request);
var validationResults = await Task.WhenAll(
_validators.Select(v => v.ValidateAsync(context, cancellationToken)));
var failures = validationResults
.SelectMany(r => r.Errors)
.Where(f => f != null)
.ToList();
if (failures.Any())
{
throw new ValidationException(failures);
}
return await next();
}
}

View File

@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\ColaFlow.Modules.ProjectManagement.Domain\ColaFlow.Modules.ProjectManagement.Domain.csproj" />
<ProjectReference Include="..\ColaFlow.Modules.ProjectManagement.Contracts\ColaFlow.Modules.ProjectManagement.Contracts.csproj" />
<ProjectReference Include="..\..\..\Shared\ColaFlow.Shared.Kernel\ColaFlow.Shared.Kernel.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MediatR" Version="11.1.0" />
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="11.1.0" />
<PackageReference Include="FluentValidation" Version="11.10.0" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.10.0" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>ColaFlow.Modules.ProjectManagement.Application</AssemblyName>
<RootNamespace>ColaFlow.Modules.ProjectManagement.Application</RootNamespace>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,15 @@
using MediatR;
using ColaFlow.Modules.ProjectManagement.Application.DTOs;
namespace ColaFlow.Modules.ProjectManagement.Application.Commands.CreateEpic;
/// <summary>
/// Command to create a new Epic
/// </summary>
public sealed record CreateEpicCommand : IRequest<EpicDto>
{
public Guid ProjectId { get; init; }
public string Name { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public Guid CreatedBy { get; init; }
}

View File

@@ -0,0 +1,57 @@
using MediatR;
using ColaFlow.Modules.ProjectManagement.Application.DTOs;
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
using ColaFlow.Modules.ProjectManagement.Domain.Exceptions;
namespace ColaFlow.Modules.ProjectManagement.Application.Commands.CreateEpic;
/// <summary>
/// Handler for CreateEpicCommand
/// </summary>
public sealed class CreateEpicCommandHandler : IRequestHandler<CreateEpicCommand, EpicDto>
{
private readonly IProjectRepository _projectRepository;
private readonly IUnitOfWork _unitOfWork;
public CreateEpicCommandHandler(
IProjectRepository projectRepository,
IUnitOfWork unitOfWork)
{
_projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
_unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
}
public async Task<EpicDto> Handle(CreateEpicCommand request, CancellationToken cancellationToken)
{
// Get the project
var projectId = ProjectId.From(request.ProjectId);
var project = await _projectRepository.GetByIdAsync(projectId, cancellationToken);
if (project == null)
throw new NotFoundException("Project", request.ProjectId);
// Create epic through aggregate root
var createdById = UserId.From(request.CreatedBy);
var epic = project.CreateEpic(request.Name, request.Description, createdById);
// Update project (epic is part of aggregate)
_projectRepository.Update(project);
await _unitOfWork.SaveChangesAsync(cancellationToken);
// Map to DTO
return new EpicDto
{
Id = epic.Id.Value,
Name = epic.Name,
Description = epic.Description,
ProjectId = epic.ProjectId.Value,
Status = epic.Status.Value,
Priority = epic.Priority.Value,
CreatedBy = epic.CreatedBy.Value,
CreatedAt = epic.CreatedAt,
UpdatedAt = epic.UpdatedAt,
Stories = new List<StoryDto>()
};
}
}

View File

@@ -0,0 +1,22 @@
using FluentValidation;
namespace ColaFlow.Modules.ProjectManagement.Application.Commands.CreateEpic;
/// <summary>
/// Validator for CreateEpicCommand
/// </summary>
public sealed class CreateEpicCommandValidator : AbstractValidator<CreateEpicCommand>
{
public CreateEpicCommandValidator()
{
RuleFor(x => x.ProjectId)
.NotEmpty().WithMessage("Project ID is required");
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Epic name is required")
.MaximumLength(200).WithMessage("Epic name cannot exceed 200 characters");
RuleFor(x => x.CreatedBy)
.NotEmpty().WithMessage("Created by user ID is required");
}
}

View File

@@ -0,0 +1,15 @@
using MediatR;
using ColaFlow.Modules.ProjectManagement.Application.DTOs;
namespace ColaFlow.Modules.ProjectManagement.Application.Commands.CreateProject;
/// <summary>
/// Command to create a new project
/// </summary>
public sealed record CreateProjectCommand : IRequest<ProjectDto>
{
public string Name { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public string Key { get; init; } = string.Empty;
public Guid OwnerId { get; init; }
}

View File

@@ -0,0 +1,66 @@
using MediatR;
using ColaFlow.Modules.ProjectManagement.Application.DTOs;
using ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate;
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
using ColaFlow.Modules.ProjectManagement.Domain.Exceptions;
namespace ColaFlow.Modules.ProjectManagement.Application.Commands.CreateProject;
/// <summary>
/// Handler for CreateProjectCommand
/// </summary>
public sealed class CreateProjectCommandHandler : IRequestHandler<CreateProjectCommand, ProjectDto>
{
private readonly IProjectRepository _projectRepository;
private readonly IUnitOfWork _unitOfWork;
public CreateProjectCommandHandler(
IProjectRepository projectRepository,
IUnitOfWork unitOfWork)
{
_projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
_unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
}
public async Task<ProjectDto> Handle(CreateProjectCommand request, CancellationToken cancellationToken)
{
// Check if project key already exists
var existingProject = await _projectRepository.GetByKeyAsync(request.Key, cancellationToken);
if (existingProject != null)
{
throw new DomainException($"Project with key '{request.Key}' already exists");
}
// Create project aggregate
var project = Project.Create(
request.Name,
request.Description,
request.Key,
UserId.From(request.OwnerId)
);
// Save to repository
await _projectRepository.AddAsync(project, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);
// Return DTO
return MapToDto(project);
}
private static ProjectDto MapToDto(Project project)
{
return new ProjectDto
{
Id = project.Id.Value,
Name = project.Name,
Description = project.Description,
Key = project.Key.Value,
Status = project.Status.Name,
OwnerId = project.OwnerId.Value,
CreatedAt = project.CreatedAt,
UpdatedAt = project.UpdatedAt,
Epics = new List<EpicDto>()
};
}
}

View File

@@ -0,0 +1,24 @@
using FluentValidation;
namespace ColaFlow.Modules.ProjectManagement.Application.Commands.CreateProject;
/// <summary>
/// Validator for CreateProjectCommand
/// </summary>
public sealed class CreateProjectCommandValidator : AbstractValidator<CreateProjectCommand>
{
public CreateProjectCommandValidator()
{
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Project name is required")
.MaximumLength(200).WithMessage("Project name cannot exceed 200 characters");
RuleFor(x => x.Key)
.NotEmpty().WithMessage("Project key is required")
.MaximumLength(20).WithMessage("Project key cannot exceed 20 characters")
.Matches("^[A-Z0-9]+$").WithMessage("Project key must contain only uppercase letters and numbers");
RuleFor(x => x.OwnerId)
.NotEmpty().WithMessage("Owner ID is required");
}
}

View File

@@ -0,0 +1,14 @@
using MediatR;
using ColaFlow.Modules.ProjectManagement.Application.DTOs;
namespace ColaFlow.Modules.ProjectManagement.Application.Commands.UpdateEpic;
/// <summary>
/// Command to update an existing Epic
/// </summary>
public sealed record UpdateEpicCommand : IRequest<EpicDto>
{
public Guid EpicId { get; init; }
public string Name { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,76 @@
using MediatR;
using ColaFlow.Modules.ProjectManagement.Application.DTOs;
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
using ColaFlow.Modules.ProjectManagement.Domain.Exceptions;
namespace ColaFlow.Modules.ProjectManagement.Application.Commands.UpdateEpic;
/// <summary>
/// Handler for UpdateEpicCommand
/// </summary>
public sealed class UpdateEpicCommandHandler : IRequestHandler<UpdateEpicCommand, EpicDto>
{
private readonly IProjectRepository _projectRepository;
private readonly IUnitOfWork _unitOfWork;
public UpdateEpicCommandHandler(
IProjectRepository projectRepository,
IUnitOfWork unitOfWork)
{
_projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
_unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
}
public async Task<EpicDto> Handle(UpdateEpicCommand request, CancellationToken cancellationToken)
{
// Get the project containing the epic
var epicId = EpicId.From(request.EpicId);
var project = await _projectRepository.GetProjectWithEpicAsync(epicId, cancellationToken);
if (project == null)
throw new NotFoundException("Epic", request.EpicId);
// Find the epic
var epic = project.Epics.FirstOrDefault(e => e.Id == epicId);
if (epic == null)
throw new NotFoundException("Epic", request.EpicId);
// Update epic through domain method
epic.UpdateDetails(request.Name, request.Description);
// Save changes
_projectRepository.Update(project);
await _unitOfWork.SaveChangesAsync(cancellationToken);
// Map to DTO
return new EpicDto
{
Id = epic.Id.Value,
Name = epic.Name,
Description = epic.Description,
ProjectId = epic.ProjectId.Value,
Status = epic.Status.Value,
Priority = epic.Priority.Value,
CreatedBy = epic.CreatedBy.Value,
CreatedAt = epic.CreatedAt,
UpdatedAt = epic.UpdatedAt,
Stories = epic.Stories.Select(s => new StoryDto
{
Id = s.Id.Value,
Title = s.Title,
Description = s.Description,
EpicId = s.EpicId.Value,
Status = s.Status.Value,
Priority = s.Priority.Value,
EstimatedHours = s.EstimatedHours,
ActualHours = s.ActualHours,
AssigneeId = s.AssigneeId?.Value,
CreatedBy = s.CreatedBy.Value,
CreatedAt = s.CreatedAt,
UpdatedAt = s.UpdatedAt,
Tasks = new List<TaskDto>()
}).ToList()
};
}
}

View File

@@ -0,0 +1,19 @@
using FluentValidation;
namespace ColaFlow.Modules.ProjectManagement.Application.Commands.UpdateEpic;
/// <summary>
/// Validator for UpdateEpicCommand
/// </summary>
public sealed class UpdateEpicCommandValidator : AbstractValidator<UpdateEpicCommand>
{
public UpdateEpicCommandValidator()
{
RuleFor(x => x.EpicId)
.NotEmpty().WithMessage("Epic ID is required");
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Epic name is required")
.MaximumLength(200).WithMessage("Epic name cannot exceed 200 characters");
}
}

View File

@@ -0,0 +1,18 @@
namespace ColaFlow.Modules.ProjectManagement.Application.DTOs;
/// <summary>
/// Data Transfer Object for Epic
/// </summary>
public record EpicDto
{
public Guid Id { get; init; }
public string Name { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public Guid ProjectId { get; init; }
public string Status { get; init; } = string.Empty;
public string Priority { get; init; } = string.Empty;
public Guid CreatedBy { get; init; }
public DateTime CreatedAt { get; init; }
public DateTime? UpdatedAt { get; init; }
public List<StoryDto> Stories { get; init; } = new();
}

View File

@@ -0,0 +1,17 @@
namespace ColaFlow.Modules.ProjectManagement.Application.DTOs;
/// <summary>
/// Data Transfer Object for Project
/// </summary>
public record ProjectDto
{
public Guid Id { get; init; }
public string Name { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public string Key { get; init; } = string.Empty;
public string Status { get; init; } = string.Empty;
public Guid OwnerId { get; init; }
public DateTime CreatedAt { get; init; }
public DateTime? UpdatedAt { get; init; }
public List<EpicDto> Epics { get; init; } = new();
}

View File

@@ -0,0 +1,21 @@
namespace ColaFlow.Modules.ProjectManagement.Application.DTOs;
/// <summary>
/// Data Transfer Object for Story
/// </summary>
public record StoryDto
{
public Guid Id { get; init; }
public string Title { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public Guid EpicId { get; init; }
public string Status { get; init; } = string.Empty;
public string Priority { get; init; } = string.Empty;
public Guid? AssigneeId { get; init; }
public decimal? EstimatedHours { get; init; }
public decimal? ActualHours { get; init; }
public Guid CreatedBy { get; init; }
public DateTime CreatedAt { get; init; }
public DateTime? UpdatedAt { get; init; }
public List<TaskDto> Tasks { get; init; } = new();
}

View File

@@ -0,0 +1,20 @@
namespace ColaFlow.Modules.ProjectManagement.Application.DTOs;
/// <summary>
/// Data Transfer Object for Task
/// </summary>
public record TaskDto
{
public Guid Id { get; init; }
public string Title { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public Guid StoryId { get; init; }
public string Status { get; init; } = string.Empty;
public string Priority { get; init; } = string.Empty;
public Guid? AssigneeId { get; init; }
public decimal? EstimatedHours { get; init; }
public decimal? ActualHours { get; init; }
public Guid CreatedBy { get; init; }
public DateTime CreatedAt { get; init; }
public DateTime? UpdatedAt { get; init; }
}

View File

@@ -0,0 +1,9 @@
using MediatR;
using ColaFlow.Modules.ProjectManagement.Application.DTOs;
namespace ColaFlow.Modules.ProjectManagement.Application.Queries.GetEpicById;
/// <summary>
/// Query to get an Epic by its ID
/// </summary>
public sealed record GetEpicByIdQuery(Guid EpicId) : IRequest<EpicDto>;

View File

@@ -0,0 +1,9 @@
using MediatR;
using ColaFlow.Modules.ProjectManagement.Application.DTOs;
namespace ColaFlow.Modules.ProjectManagement.Application.Queries.GetProjectById;
/// <summary>
/// Query to get a project by its ID
/// </summary>
public sealed record GetProjectByIdQuery(Guid ProjectId) : IRequest<ProjectDto>;

View File

@@ -0,0 +1,92 @@
using MediatR;
using ColaFlow.Modules.ProjectManagement.Application.DTOs;
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
using ColaFlow.Modules.ProjectManagement.Domain.Exceptions;
using ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate;
namespace ColaFlow.Modules.ProjectManagement.Application.Queries.GetProjectById;
/// <summary>
/// Handler for GetProjectByIdQuery
/// </summary>
public sealed class GetProjectByIdQueryHandler : IRequestHandler<GetProjectByIdQuery, ProjectDto>
{
private readonly IProjectRepository _projectRepository;
public GetProjectByIdQueryHandler(IProjectRepository projectRepository)
{
_projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
}
public async Task<ProjectDto> Handle(GetProjectByIdQuery request, CancellationToken cancellationToken)
{
var project = await _projectRepository.GetByIdAsync(
ProjectId.From(request.ProjectId),
cancellationToken);
if (project == null)
{
throw new DomainException($"Project with ID '{request.ProjectId}' not found");
}
return MapToDto(project);
}
private static ProjectDto MapToDto(Project project)
{
return new ProjectDto
{
Id = project.Id.Value,
Name = project.Name,
Description = project.Description,
Key = project.Key.Value,
Status = project.Status.Name,
OwnerId = project.OwnerId.Value,
CreatedAt = project.CreatedAt,
UpdatedAt = project.UpdatedAt,
Epics = project.Epics.Select(e => new EpicDto
{
Id = e.Id.Value,
Name = e.Name,
Description = e.Description,
ProjectId = e.ProjectId.Value,
Status = e.Status.Name,
Priority = e.Priority.Name,
CreatedBy = e.CreatedBy.Value,
CreatedAt = e.CreatedAt,
UpdatedAt = e.UpdatedAt,
Stories = e.Stories.Select(s => new StoryDto
{
Id = s.Id.Value,
Title = s.Title,
Description = s.Description,
EpicId = s.EpicId.Value,
Status = s.Status.Name,
Priority = s.Priority.Name,
AssigneeId = s.AssigneeId?.Value,
EstimatedHours = s.EstimatedHours,
ActualHours = s.ActualHours,
CreatedBy = s.CreatedBy.Value,
CreatedAt = s.CreatedAt,
UpdatedAt = s.UpdatedAt,
Tasks = s.Tasks.Select(t => new TaskDto
{
Id = t.Id.Value,
Title = t.Title,
Description = t.Description,
StoryId = t.StoryId.Value,
Status = t.Status.Name,
Priority = t.Priority.Name,
AssigneeId = t.AssigneeId?.Value,
EstimatedHours = t.EstimatedHours,
ActualHours = t.ActualHours,
CreatedBy = t.CreatedBy.Value,
CreatedAt = t.CreatedAt,
UpdatedAt = t.UpdatedAt
}).ToList()
}).ToList()
}).ToList()
};
}
}

View File

@@ -0,0 +1,9 @@
using MediatR;
using ColaFlow.Modules.ProjectManagement.Application.DTOs;
namespace ColaFlow.Modules.ProjectManagement.Application.Queries.GetProjects;
/// <summary>
/// Query to get all projects
/// </summary>
public sealed record GetProjectsQuery : IRequest<List<ProjectDto>>;

View File

@@ -0,0 +1,43 @@
using MediatR;
using ColaFlow.Modules.ProjectManagement.Application.DTOs;
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
using ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate;
namespace ColaFlow.Modules.ProjectManagement.Application.Queries.GetProjects;
/// <summary>
/// Handler for GetProjectsQuery
/// </summary>
public sealed class GetProjectsQueryHandler : IRequestHandler<GetProjectsQuery, List<ProjectDto>>
{
private readonly IProjectRepository _projectRepository;
public GetProjectsQueryHandler(IProjectRepository projectRepository)
{
_projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
}
public async Task<List<ProjectDto>> Handle(GetProjectsQuery request, CancellationToken cancellationToken)
{
var projects = await _projectRepository.GetAllAsync(cancellationToken);
return projects.Select(MapToDto).ToList();
}
private static ProjectDto MapToDto(Project project)
{
return new ProjectDto
{
Id = project.Id.Value,
Name = project.Name,
Description = project.Description,
Key = project.Key.Value,
Status = project.Status.Name,
OwnerId = project.OwnerId.Value,
CreatedAt = project.CreatedAt,
UpdatedAt = project.UpdatedAt,
// Don't load Epics for list view (performance)
Epics = new List<EpicDto>()
};
}
}

View File

@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>ColaFlow.Modules.ProjectManagement.Contracts</AssemblyName>
<RootNamespace>ColaFlow.Modules.ProjectManagement.Contracts</RootNamespace>
</PropertyGroup>
</Project>

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)
{
}
}

View File

@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>ColaFlow.Modules.ProjectManagement.Infrastructure</AssemblyName>
<RootNamespace>ColaFlow.Modules.ProjectManagement.Infrastructure</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ColaFlow.Modules.ProjectManagement.Domain\ColaFlow.Modules.ProjectManagement.Domain.csproj" />
<ProjectReference Include="..\ColaFlow.Modules.ProjectManagement.Application\ColaFlow.Modules.ProjectManagement.Application.csproj" />
<ProjectReference Include="..\..\..\Shared\ColaFlow.Shared.Kernel\ColaFlow.Shared.Kernel.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,298 @@
// <auto-generated />
using System;
using ColaFlow.Modules.ProjectManagement.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ColaFlow.Modules.ProjectManagement.Infrastructure.Migrations
{
[DbContext(typeof(PMDbContext))]
[Migration("20251102220422_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("project_management")
.HasAnnotation("ProductVersion", "9.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate.Epic", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Priority")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<Guid>("ProjectId")
.HasColumnType("uuid");
b.Property<Guid?>("ProjectId1")
.HasColumnType("uuid");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("ProjectId");
b.HasIndex("ProjectId1");
b.ToTable("Epics", "project_management");
});
modelBuilder.Entity("ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate.Project", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid>("OwnerId")
.HasColumnType("uuid");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("OwnerId");
b.ToTable("Projects", "project_management");
});
modelBuilder.Entity("ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate.Story", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<decimal?>("ActualHours")
.HasColumnType("numeric");
b.Property<Guid?>("AssigneeId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<Guid>("EpicId")
.HasColumnType("uuid");
b.Property<decimal?>("EstimatedHours")
.HasColumnType("numeric");
b.Property<string>("Priority")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("AssigneeId");
b.HasIndex("CreatedAt");
b.HasIndex("EpicId");
b.ToTable("Stories", "project_management");
});
modelBuilder.Entity("ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate.WorkTask", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<decimal?>("ActualHours")
.HasColumnType("numeric");
b.Property<Guid?>("AssigneeId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<decimal?>("EstimatedHours")
.HasColumnType("numeric");
b.Property<string>("Priority")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<Guid>("StoryId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("AssigneeId");
b.HasIndex("CreatedAt");
b.HasIndex("StoryId");
b.ToTable("Tasks", "project_management");
});
modelBuilder.Entity("ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate.Epic", b =>
{
b.HasOne("ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate.Project", null)
.WithMany()
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate.Project", null)
.WithMany("Epics")
.HasForeignKey("ProjectId1");
});
modelBuilder.Entity("ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate.Project", b =>
{
b.OwnsOne("ColaFlow.Modules.ProjectManagement.Domain.ValueObjects.ProjectKey", "Key", b1 =>
{
b1.Property<Guid>("ProjectId")
.HasColumnType("uuid");
b1.Property<string>("Value")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("Key");
b1.HasKey("ProjectId");
b1.HasIndex("Value")
.IsUnique();
b1.ToTable("Projects", "project_management");
b1.WithOwner()
.HasForeignKey("ProjectId");
});
b.Navigation("Key")
.IsRequired();
});
modelBuilder.Entity("ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate.Story", b =>
{
b.HasOne("ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate.Epic", null)
.WithMany()
.HasForeignKey("EpicId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate.WorkTask", b =>
{
b.HasOne("ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate.Story", null)
.WithMany()
.HasForeignKey("StoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate.Project", b =>
{
b.Navigation("Epics");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,224 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ColaFlow.Modules.ProjectManagement.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "project_management");
migrationBuilder.CreateTable(
name: "Projects",
schema: "project_management",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Description = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: false),
Key = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
Status = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
OwnerId = table.Column<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Projects", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Epics",
schema: "project_management",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Description = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: false),
ProjectId = table.Column<Guid>(type: "uuid", nullable: false),
Status = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
Priority = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
CreatedBy = table.Column<Guid>(type: "uuid", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
ProjectId1 = table.Column<Guid>(type: "uuid", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Epics", x => x.Id);
table.ForeignKey(
name: "FK_Epics_Projects_ProjectId",
column: x => x.ProjectId,
principalSchema: "project_management",
principalTable: "Projects",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Epics_Projects_ProjectId1",
column: x => x.ProjectId1,
principalSchema: "project_management",
principalTable: "Projects",
principalColumn: "Id");
});
migrationBuilder.CreateTable(
name: "Stories",
schema: "project_management",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Title = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Description = table.Column<string>(type: "character varying(4000)", maxLength: 4000, nullable: false),
EpicId = table.Column<Guid>(type: "uuid", nullable: false),
Status = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
Priority = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
EstimatedHours = table.Column<decimal>(type: "numeric", nullable: true),
ActualHours = table.Column<decimal>(type: "numeric", nullable: true),
AssigneeId = table.Column<Guid>(type: "uuid", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
CreatedBy = table.Column<Guid>(type: "uuid", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Stories", x => x.Id);
table.ForeignKey(
name: "FK_Stories_Epics_EpicId",
column: x => x.EpicId,
principalSchema: "project_management",
principalTable: "Epics",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Tasks",
schema: "project_management",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Title = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Description = table.Column<string>(type: "character varying(4000)", maxLength: 4000, nullable: false),
StoryId = table.Column<Guid>(type: "uuid", nullable: false),
Status = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
Priority = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
EstimatedHours = table.Column<decimal>(type: "numeric", nullable: true),
ActualHours = table.Column<decimal>(type: "numeric", nullable: true),
AssigneeId = table.Column<Guid>(type: "uuid", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
CreatedBy = table.Column<Guid>(type: "uuid", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Tasks", x => x.Id);
table.ForeignKey(
name: "FK_Tasks_Stories_StoryId",
column: x => x.StoryId,
principalSchema: "project_management",
principalTable: "Stories",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Epics_CreatedAt",
schema: "project_management",
table: "Epics",
column: "CreatedAt");
migrationBuilder.CreateIndex(
name: "IX_Epics_ProjectId",
schema: "project_management",
table: "Epics",
column: "ProjectId");
migrationBuilder.CreateIndex(
name: "IX_Epics_ProjectId1",
schema: "project_management",
table: "Epics",
column: "ProjectId1");
migrationBuilder.CreateIndex(
name: "IX_Projects_CreatedAt",
schema: "project_management",
table: "Projects",
column: "CreatedAt");
migrationBuilder.CreateIndex(
name: "IX_Projects_Key",
schema: "project_management",
table: "Projects",
column: "Key",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Projects_OwnerId",
schema: "project_management",
table: "Projects",
column: "OwnerId");
migrationBuilder.CreateIndex(
name: "IX_Stories_AssigneeId",
schema: "project_management",
table: "Stories",
column: "AssigneeId");
migrationBuilder.CreateIndex(
name: "IX_Stories_CreatedAt",
schema: "project_management",
table: "Stories",
column: "CreatedAt");
migrationBuilder.CreateIndex(
name: "IX_Stories_EpicId",
schema: "project_management",
table: "Stories",
column: "EpicId");
migrationBuilder.CreateIndex(
name: "IX_Tasks_AssigneeId",
schema: "project_management",
table: "Tasks",
column: "AssigneeId");
migrationBuilder.CreateIndex(
name: "IX_Tasks_CreatedAt",
schema: "project_management",
table: "Tasks",
column: "CreatedAt");
migrationBuilder.CreateIndex(
name: "IX_Tasks_StoryId",
schema: "project_management",
table: "Tasks",
column: "StoryId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Tasks",
schema: "project_management");
migrationBuilder.DropTable(
name: "Stories",
schema: "project_management");
migrationBuilder.DropTable(
name: "Epics",
schema: "project_management");
migrationBuilder.DropTable(
name: "Projects",
schema: "project_management");
}
}
}

View File

@@ -0,0 +1,295 @@
// <auto-generated />
using System;
using ColaFlow.Modules.ProjectManagement.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ColaFlow.Modules.ProjectManagement.Infrastructure.Migrations
{
[DbContext(typeof(PMDbContext))]
partial class PMDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("project_management")
.HasAnnotation("ProductVersion", "9.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate.Epic", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Priority")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<Guid>("ProjectId")
.HasColumnType("uuid");
b.Property<Guid?>("ProjectId1")
.HasColumnType("uuid");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("ProjectId");
b.HasIndex("ProjectId1");
b.ToTable("Epics", "project_management");
});
modelBuilder.Entity("ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate.Project", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid>("OwnerId")
.HasColumnType("uuid");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("OwnerId");
b.ToTable("Projects", "project_management");
});
modelBuilder.Entity("ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate.Story", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<decimal?>("ActualHours")
.HasColumnType("numeric");
b.Property<Guid?>("AssigneeId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<Guid>("EpicId")
.HasColumnType("uuid");
b.Property<decimal?>("EstimatedHours")
.HasColumnType("numeric");
b.Property<string>("Priority")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("AssigneeId");
b.HasIndex("CreatedAt");
b.HasIndex("EpicId");
b.ToTable("Stories", "project_management");
});
modelBuilder.Entity("ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate.WorkTask", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<decimal?>("ActualHours")
.HasColumnType("numeric");
b.Property<Guid?>("AssigneeId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<decimal?>("EstimatedHours")
.HasColumnType("numeric");
b.Property<string>("Priority")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<Guid>("StoryId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("AssigneeId");
b.HasIndex("CreatedAt");
b.HasIndex("StoryId");
b.ToTable("Tasks", "project_management");
});
modelBuilder.Entity("ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate.Epic", b =>
{
b.HasOne("ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate.Project", null)
.WithMany()
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate.Project", null)
.WithMany("Epics")
.HasForeignKey("ProjectId1");
});
modelBuilder.Entity("ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate.Project", b =>
{
b.OwnsOne("ColaFlow.Modules.ProjectManagement.Domain.ValueObjects.ProjectKey", "Key", b1 =>
{
b1.Property<Guid>("ProjectId")
.HasColumnType("uuid");
b1.Property<string>("Value")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("Key");
b1.HasKey("ProjectId");
b1.HasIndex("Value")
.IsUnique();
b1.ToTable("Projects", "project_management");
b1.WithOwner()
.HasForeignKey("ProjectId");
});
b.Navigation("Key")
.IsRequired();
});
modelBuilder.Entity("ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate.Story", b =>
{
b.HasOne("ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate.Epic", null)
.WithMany()
.HasForeignKey("EpicId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate.WorkTask", b =>
{
b.HasOne("ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate.Story", null)
.WithMany()
.HasForeignKey("StoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate.Project", b =>
{
b.Navigation("Epics");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,86 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate;
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
using ColaFlow.Shared.Kernel.Common;
namespace ColaFlow.Modules.ProjectManagement.Infrastructure.Persistence.Configurations;
/// <summary>
/// Entity configuration for Epic entity
/// </summary>
public class EpicConfiguration : IEntityTypeConfiguration<Epic>
{
public void Configure(EntityTypeBuilder<Epic> builder)
{
builder.ToTable("Epics");
// Primary key
builder.HasKey("Id");
// Id conversion
builder.Property(e => e.Id)
.HasConversion(
id => id.Value,
value => EpicId.From(value))
.IsRequired()
.ValueGeneratedNever();
// ProjectId (foreign key)
builder.Property(e => e.ProjectId)
.HasConversion(
id => id.Value,
value => ProjectId.From(value))
.IsRequired();
// Basic properties
builder.Property(e => e.Name)
.HasMaxLength(200)
.IsRequired();
builder.Property(e => e.Description)
.HasMaxLength(2000);
// Status enumeration
builder.Property(e => e.Status)
.HasConversion(
s => s.Name,
name => Enumeration.FromDisplayName<WorkItemStatus>(name))
.HasMaxLength(50)
.IsRequired();
// Priority enumeration
builder.Property(e => e.Priority)
.HasConversion(
p => p.Name,
name => Enumeration.FromDisplayName<TaskPriority>(name))
.HasMaxLength(50)
.IsRequired();
// CreatedBy conversion
builder.Property(e => e.CreatedBy)
.HasConversion(
id => id.Value,
value => UserId.From(value))
.IsRequired();
// Timestamps
builder.Property(e => e.CreatedAt)
.IsRequired();
builder.Property(e => e.UpdatedAt);
// Ignore navigation properties (DDD pattern - access through aggregate)
builder.Ignore(e => e.Stories);
// Foreign key relationship to Project
builder.HasOne<Project>()
.WithMany()
.HasForeignKey(e => e.ProjectId)
.OnDelete(DeleteBehavior.Cascade);
// Indexes
builder.HasIndex(e => e.ProjectId);
builder.HasIndex(e => e.CreatedAt);
}
}

View File

@@ -0,0 +1,79 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate;
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
using ColaFlow.Shared.Kernel.Common;
namespace ColaFlow.Modules.ProjectManagement.Infrastructure.Persistence.Configurations;
/// <summary>
/// Entity configuration for Project aggregate root
/// </summary>
public class ProjectConfiguration : IEntityTypeConfiguration<Project>
{
public void Configure(EntityTypeBuilder<Project> builder)
{
builder.ToTable("Projects");
// Primary key
builder.HasKey(p => p.Id);
// Id conversion (StronglyTypedId to Guid)
builder.Property(p => p.Id)
.HasConversion(
id => id.Value,
value => ProjectId.From(value))
.IsRequired()
.ValueGeneratedNever();
// Basic properties
builder.Property(p => p.Name)
.HasMaxLength(200)
.IsRequired();
builder.Property(p => p.Description)
.HasMaxLength(2000);
// ProjectKey as owned value object
builder.OwnsOne(p => p.Key, kb =>
{
kb.Property(k => k.Value)
.HasColumnName("Key")
.HasMaxLength(20)
.IsRequired();
kb.HasIndex(k => k.Value).IsUnique();
});
// Status enumeration (stored as string)
builder.Property(p => p.Status)
.HasConversion(
s => s.Name,
name => Enumeration.FromDisplayName<ProjectStatus>(name))
.HasMaxLength(50)
.IsRequired();
// OwnerId conversion
builder.Property(p => p.OwnerId)
.HasConversion(
id => id.Value,
value => UserId.From(value))
.IsRequired();
// Timestamps
builder.Property(p => p.CreatedAt)
.IsRequired();
builder.Property(p => p.UpdatedAt);
// Relationships - Epics collection (owned by aggregate)
// Note: We don't expose this as navigation property in DDD, epics are accessed through repository
// Indexes for performance
builder.HasIndex(p => p.CreatedAt);
builder.HasIndex(p => p.OwnerId);
// Ignore DomainEvents (handled separately)
builder.Ignore(p => p.DomainEvents);
}
}

View File

@@ -0,0 +1,97 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate;
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
using ColaFlow.Shared.Kernel.Common;
namespace ColaFlow.Modules.ProjectManagement.Infrastructure.Persistence.Configurations;
/// <summary>
/// Entity configuration for Story entity
/// </summary>
public class StoryConfiguration : IEntityTypeConfiguration<Story>
{
public void Configure(EntityTypeBuilder<Story> builder)
{
builder.ToTable("Stories");
// Primary key
builder.HasKey("Id");
// Id conversion
builder.Property(s => s.Id)
.HasConversion(
id => id.Value,
value => StoryId.From(value))
.IsRequired()
.ValueGeneratedNever();
// EpicId (foreign key)
builder.Property(s => s.EpicId)
.HasConversion(
id => id.Value,
value => EpicId.From(value))
.IsRequired();
// Basic properties
builder.Property(s => s.Title)
.HasMaxLength(200)
.IsRequired();
builder.Property(s => s.Description)
.HasMaxLength(4000);
// Status enumeration
builder.Property(s => s.Status)
.HasConversion(
st => st.Name,
name => Enumeration.FromDisplayName<WorkItemStatus>(name))
.HasMaxLength(50)
.IsRequired();
// Priority enumeration
builder.Property(s => s.Priority)
.HasConversion(
p => p.Name,
name => Enumeration.FromDisplayName<TaskPriority>(name))
.HasMaxLength(50)
.IsRequired();
// CreatedBy conversion
builder.Property(s => s.CreatedBy)
.HasConversion(
id => id.Value,
value => UserId.From(value))
.IsRequired();
// AssigneeId (optional)
builder.Property(s => s.AssigneeId)
.HasConversion(
id => id != null ? id.Value : (Guid?)null,
value => value.HasValue ? UserId.From(value.Value) : null);
// Effort tracking
builder.Property(s => s.EstimatedHours);
builder.Property(s => s.ActualHours);
// Timestamps
builder.Property(s => s.CreatedAt)
.IsRequired();
builder.Property(s => s.UpdatedAt);
// Ignore navigation properties (DDD pattern - access through aggregate)
builder.Ignore(s => s.Tasks);
// Foreign key relationship to Epic
builder.HasOne<Epic>()
.WithMany()
.HasForeignKey(s => s.EpicId)
.OnDelete(DeleteBehavior.Cascade);
// Indexes
builder.HasIndex(s => s.EpicId);
builder.HasIndex(s => s.AssigneeId);
builder.HasIndex(s => s.CreatedAt);
}
}

View File

@@ -0,0 +1,94 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate;
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
using ColaFlow.Shared.Kernel.Common;
namespace ColaFlow.Modules.ProjectManagement.Infrastructure.Persistence.Configurations;
/// <summary>
/// Entity configuration for WorkTask entity
/// </summary>
public class WorkTaskConfiguration : IEntityTypeConfiguration<WorkTask>
{
public void Configure(EntityTypeBuilder<WorkTask> builder)
{
builder.ToTable("Tasks");
// Primary key
builder.HasKey("Id");
// Id conversion
builder.Property(t => t.Id)
.HasConversion(
id => id.Value,
value => TaskId.From(value))
.IsRequired()
.ValueGeneratedNever();
// StoryId (foreign key)
builder.Property(t => t.StoryId)
.HasConversion(
id => id.Value,
value => StoryId.From(value))
.IsRequired();
// Basic properties
builder.Property(t => t.Title)
.HasMaxLength(200)
.IsRequired();
builder.Property(t => t.Description)
.HasMaxLength(4000);
// Status enumeration
builder.Property(t => t.Status)
.HasConversion(
s => s.Name,
name => Enumeration.FromDisplayName<WorkItemStatus>(name))
.HasMaxLength(50)
.IsRequired();
// Priority enumeration
builder.Property(t => t.Priority)
.HasConversion(
p => p.Name,
name => Enumeration.FromDisplayName<TaskPriority>(name))
.HasMaxLength(50)
.IsRequired();
// CreatedBy conversion
builder.Property(t => t.CreatedBy)
.HasConversion(
id => id.Value,
value => UserId.From(value))
.IsRequired();
// AssigneeId (optional)
builder.Property(t => t.AssigneeId)
.HasConversion(
id => id != null ? id.Value : (Guid?)null,
value => value.HasValue ? UserId.From(value.Value) : null);
// Effort tracking
builder.Property(t => t.EstimatedHours);
builder.Property(t => t.ActualHours);
// Timestamps
builder.Property(t => t.CreatedAt)
.IsRequired();
builder.Property(t => t.UpdatedAt);
// Foreign key relationship to Story
builder.HasOne<Story>()
.WithMany()
.HasForeignKey(t => t.StoryId)
.OnDelete(DeleteBehavior.Cascade);
// Indexes
builder.HasIndex(t => t.StoryId);
builder.HasIndex(t => t.AssigneeId);
builder.HasIndex(t => t.CreatedAt);
}
}

View File

@@ -0,0 +1,31 @@
using System.Reflection;
using Microsoft.EntityFrameworkCore;
using ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate;
namespace ColaFlow.Modules.ProjectManagement.Infrastructure.Persistence;
/// <summary>
/// Project Management Module DbContext
/// </summary>
public class PMDbContext : DbContext
{
public PMDbContext(DbContextOptions<PMDbContext> options) : base(options)
{
}
public DbSet<Project> Projects => Set<Project>();
public DbSet<Epic> Epics => Set<Epic>();
public DbSet<Story> Stories => Set<Story>();
public DbSet<WorkTask> Tasks => Set<WorkTask>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Set default schema for this module (must be before configurations)
modelBuilder.HasDefaultSchema("project_management");
// Apply all entity configurations from this assembly
modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());
}
}

View File

@@ -0,0 +1,49 @@
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
using ColaFlow.Shared.Kernel.Common;
namespace ColaFlow.Modules.ProjectManagement.Infrastructure.Persistence;
/// <summary>
/// Unit of Work implementation for ProjectManagement module
/// </summary>
public class UnitOfWork : IUnitOfWork
{
private readonly PMDbContext _context;
public UnitOfWork(PMDbContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
// Dispatch domain events before saving
await DispatchDomainEventsAsync(cancellationToken);
// Save changes to database
return await _context.SaveChangesAsync(cancellationToken);
}
private async Task DispatchDomainEventsAsync(CancellationToken cancellationToken)
{
// Get all entities with domain events
var domainEntities = _context.ChangeTracker
.Entries<AggregateRoot>()
.Where(x => x.Entity.DomainEvents.Any())
.Select(x => x.Entity)
.ToList();
// Get all domain events
var domainEvents = domainEntities
.SelectMany(x => x.DomainEvents)
.ToList();
// Clear domain events from entities
domainEntities.ForEach(entity => entity.ClearDomainEvents());
// TODO: Dispatch domain events to handlers
// This will be implemented when we add MediatR
// For now, we just clear the events
await Task.CompletedTask;
}
}

View File

@@ -0,0 +1,55 @@
using Microsoft.EntityFrameworkCore;
using ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate;
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
using ColaFlow.Modules.ProjectManagement.Infrastructure.Persistence;
namespace ColaFlow.Modules.ProjectManagement.Infrastructure.Repositories;
/// <summary>
/// Project repository implementation using EF Core
/// </summary>
public class ProjectRepository : IProjectRepository
{
private readonly PMDbContext _context;
public ProjectRepository(PMDbContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
public async Task<Project?> GetByIdAsync(ProjectId id, CancellationToken cancellationToken = default)
{
return await _context.Projects
.Include(p => p.Epics)
.FirstOrDefaultAsync(p => p.Id == id, cancellationToken);
}
public async Task<Project?> GetByKeyAsync(string key, CancellationToken cancellationToken = default)
{
return await _context.Projects
.FirstOrDefaultAsync(p => p.Key.Value == key, cancellationToken);
}
public async Task<List<Project>> GetAllAsync(CancellationToken cancellationToken = default)
{
return await _context.Projects
.OrderByDescending(p => p.CreatedAt)
.ToListAsync(cancellationToken);
}
public async Task AddAsync(Project project, CancellationToken cancellationToken = default)
{
await _context.Projects.AddAsync(project, cancellationToken);
}
public void Update(Project project)
{
_context.Projects.Update(project);
}
public void Delete(Project project)
{
_context.Projects.Remove(project);
}
}

View File

@@ -0,0 +1,55 @@
using ColaFlow.Shared.Kernel.Modules;
using Microsoft.AspNetCore.Builder;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using FluentValidation;
using MediatR;
using ColaFlow.Modules.ProjectManagement.Application.Behaviors;
using ColaFlow.Modules.ProjectManagement.Application.Commands.CreateProject;
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
using ColaFlow.Modules.ProjectManagement.Infrastructure.Persistence;
using ColaFlow.Modules.ProjectManagement.Infrastructure.Repositories;
namespace ColaFlow.Modules.ProjectManagement;
/// <summary>
/// Project Management Module
/// Responsible for managing projects, epics, stories, and tasks.
/// </summary>
public class ProjectManagementModule : IModule
{
public string Name => "ProjectManagement";
public void RegisterServices(IServiceCollection services, IConfiguration configuration)
{
// Register DbContext
var connectionString = configuration.GetConnectionString("PMDatabase");
services.AddDbContext<PMDbContext>(options =>
options.UseNpgsql(connectionString));
// Register repositories
services.AddScoped<IProjectRepository, ProjectRepository>();
services.AddScoped<IUnitOfWork, UnitOfWork>();
// Register MediatR handlers from Application assembly
services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssembly(typeof(CreateProjectCommand).Assembly);
});
// Register FluentValidation validators
services.AddValidatorsFromAssembly(typeof(CreateProjectCommand).Assembly);
// Register pipeline behaviors
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
Console.WriteLine($"[{Name}] Module services registered");
}
public void ConfigureApplication(IApplicationBuilder app)
{
// Configure module-specific middleware if needed
Console.WriteLine($"[{Name}] Module application configured");
}
}

View File

@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.2.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,31 @@
using ColaFlow.Shared.Kernel.Events;
namespace ColaFlow.Shared.Kernel.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.Shared.Kernel.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.Shared.Kernel.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.Shared.Kernel.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.Shared.Kernel.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,30 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace ColaFlow.Shared.Kernel.Modules;
/// <summary>
/// Defines the contract for a modular component in ColaFlow.
/// Each module is responsible for registering its own services and configuring its middleware.
/// </summary>
public interface IModule
{
/// <summary>
/// Gets the unique name of the module.
/// </summary>
string Name { get; }
/// <summary>
/// Registers module-specific services with the dependency injection container.
/// </summary>
/// <param name="services">The service collection to register services with.</param>
/// <param name="configuration">The application configuration.</param>
void RegisterServices(IServiceCollection services, IConfiguration configuration);
/// <summary>
/// Configures module-specific middleware and application behaviors.
/// </summary>
/// <param name="app">The application builder.</param>
void ConfigureApplication(IApplicationBuilder app);
}