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,43 @@
# .dockerignore for ColaFlow API
# Binaries
**/bin/
**/obj/
# Visual Studio / Rider
.vs/
.idea/
*.user
*.suo
*.userosscache
*.sln.docstates
# Build results
[Dd]ebug/
[Rr]elease/
x64/
x86/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
# Test results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
*.trx
*.coverage
# NuGet
*.nupkg
packages/
.nuget/
# Others
*.log
*.bak
*.tmp
.DS_Store
Thumbs.db

65
colaflow-api/.gitignore vendored Normal file
View File

@@ -0,0 +1,65 @@
# .NET Core
bin/
obj/
*.user
*.suo
*.cache
.vs/
.idea/
# Build results
[Dd]ebug/
[Rr]elease/
x64/
x86/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Ll]og/
[Ll]ogs/
# Test results
TestResults/
*.trx
*.coverage
*.coveragexml
coverage/
coveragereport/
# NuGet
*.nupkg
*.snupkg
packages/
.nuget/
project.lock.json
project.fragment.lock.json
# Database
*.db
*.db-shm
*.db-wal
# Rider
.idea/
*.sln.iml
# Visual Studio
.vs/
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# Others
*.log
*.bak
*.swp
*.tmp
.DS_Store
Thumbs.db
# App settings (sensitive)
appsettings.*.json
!appsettings.json
!appsettings.Development.json

230
colaflow-api/ColaFlow.sln Normal file
View File

@@ -0,0 +1,230 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ColaFlow.Domain", "src\ColaFlow.Domain\ColaFlow.Domain.csproj", "{0F399DDB-4292-4527-B2F0-2252516F7615}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ColaFlow.Application", "src\ColaFlow.Application\ColaFlow.Application.csproj", "{6ECE123E-3FD9-4146-B44E-B1332FAFC010}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ColaFlow.Infrastructure", "src\ColaFlow.Infrastructure\ColaFlow.Infrastructure.csproj", "{D6E0C1D8-CAA7-4F95-88E1-C253B0390494}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ColaFlow.API", "src\ColaFlow.API\ColaFlow.API.csproj", "{AED08D6B-D0A2-4B67-BF43-D8244C424145}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ColaFlow.Domain.Tests", "tests\ColaFlow.Domain.Tests\ColaFlow.Domain.Tests.csproj", "{931322BD-B4BD-436A-BEE8-FCF95FF4A09E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ColaFlow.Application.Tests", "tests\ColaFlow.Application.Tests\ColaFlow.Application.Tests.csproj", "{73C1CF97-527D-427B-842B-C4CBED3429B5}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ColaFlow.IntegrationTests", "tests\ColaFlow.IntegrationTests\ColaFlow.IntegrationTests.csproj", "{614DB4A0-24C4-457F-82BB-CE077BCA6E4E}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Shared", "Shared", "{C8E42992-5E42-0C2B-DBFE-AA848D06431C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ColaFlow.Shared.Kernel", "src\Shared\ColaFlow.Shared.Kernel\ColaFlow.Shared.Kernel.csproj", "{EAF2C884-939C-428D-981F-CDABE5D42852}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Modules", "Modules", "{EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ProjectManagement", "ProjectManagement", "{CA0D0B73-F1EC-F12F-54BA-8DF761F62CA4}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ColaFlow.Modules.ProjectManagement.Domain", "src\Modules\ProjectManagement\ColaFlow.Modules.ProjectManagement.Domain\ColaFlow.Modules.ProjectManagement.Domain.csproj", "{1D172B0D-9D60-4366-999B-E2D186B55D46}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ColaFlow.Modules.ProjectManagement.Application", "src\Modules\ProjectManagement\ColaFlow.Modules.ProjectManagement.Application\ColaFlow.Modules.ProjectManagement.Application.csproj", "{95343C64-EF22-40D0-ABA6-489CE65AF11F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ColaFlow.Modules.ProjectManagement.Infrastructure", "src\Modules\ProjectManagement\ColaFlow.Modules.ProjectManagement.Infrastructure\ColaFlow.Modules.ProjectManagement.Infrastructure.csproj", "{2AC4CB72-078B-44D7-A3E6-B1651F1B8C29}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ColaFlow.Modules.ProjectManagement.Contracts", "src\Modules\ProjectManagement\ColaFlow.Modules.ProjectManagement.Contracts\ColaFlow.Modules.ProjectManagement.Contracts.csproj", "{EF0BCA60-10E6-48AF-807D-416D262B85E3}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ColaFlow.ArchitectureTests", "tests\ColaFlow.ArchitectureTests\ColaFlow.ArchitectureTests.csproj", "{A059FDA9-5454-49A8-A025-0FC5130574EE}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{0F399DDB-4292-4527-B2F0-2252516F7615}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0F399DDB-4292-4527-B2F0-2252516F7615}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0F399DDB-4292-4527-B2F0-2252516F7615}.Debug|x64.ActiveCfg = Debug|Any CPU
{0F399DDB-4292-4527-B2F0-2252516F7615}.Debug|x64.Build.0 = Debug|Any CPU
{0F399DDB-4292-4527-B2F0-2252516F7615}.Debug|x86.ActiveCfg = Debug|Any CPU
{0F399DDB-4292-4527-B2F0-2252516F7615}.Debug|x86.Build.0 = Debug|Any CPU
{0F399DDB-4292-4527-B2F0-2252516F7615}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0F399DDB-4292-4527-B2F0-2252516F7615}.Release|Any CPU.Build.0 = Release|Any CPU
{0F399DDB-4292-4527-B2F0-2252516F7615}.Release|x64.ActiveCfg = Release|Any CPU
{0F399DDB-4292-4527-B2F0-2252516F7615}.Release|x64.Build.0 = Release|Any CPU
{0F399DDB-4292-4527-B2F0-2252516F7615}.Release|x86.ActiveCfg = Release|Any CPU
{0F399DDB-4292-4527-B2F0-2252516F7615}.Release|x86.Build.0 = Release|Any CPU
{6ECE123E-3FD9-4146-B44E-B1332FAFC010}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6ECE123E-3FD9-4146-B44E-B1332FAFC010}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6ECE123E-3FD9-4146-B44E-B1332FAFC010}.Debug|x64.ActiveCfg = Debug|Any CPU
{6ECE123E-3FD9-4146-B44E-B1332FAFC010}.Debug|x64.Build.0 = Debug|Any CPU
{6ECE123E-3FD9-4146-B44E-B1332FAFC010}.Debug|x86.ActiveCfg = Debug|Any CPU
{6ECE123E-3FD9-4146-B44E-B1332FAFC010}.Debug|x86.Build.0 = Debug|Any CPU
{6ECE123E-3FD9-4146-B44E-B1332FAFC010}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6ECE123E-3FD9-4146-B44E-B1332FAFC010}.Release|Any CPU.Build.0 = Release|Any CPU
{6ECE123E-3FD9-4146-B44E-B1332FAFC010}.Release|x64.ActiveCfg = Release|Any CPU
{6ECE123E-3FD9-4146-B44E-B1332FAFC010}.Release|x64.Build.0 = Release|Any CPU
{6ECE123E-3FD9-4146-B44E-B1332FAFC010}.Release|x86.ActiveCfg = Release|Any CPU
{6ECE123E-3FD9-4146-B44E-B1332FAFC010}.Release|x86.Build.0 = Release|Any CPU
{D6E0C1D8-CAA7-4F95-88E1-C253B0390494}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D6E0C1D8-CAA7-4F95-88E1-C253B0390494}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D6E0C1D8-CAA7-4F95-88E1-C253B0390494}.Debug|x64.ActiveCfg = Debug|Any CPU
{D6E0C1D8-CAA7-4F95-88E1-C253B0390494}.Debug|x64.Build.0 = Debug|Any CPU
{D6E0C1D8-CAA7-4F95-88E1-C253B0390494}.Debug|x86.ActiveCfg = Debug|Any CPU
{D6E0C1D8-CAA7-4F95-88E1-C253B0390494}.Debug|x86.Build.0 = Debug|Any CPU
{D6E0C1D8-CAA7-4F95-88E1-C253B0390494}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D6E0C1D8-CAA7-4F95-88E1-C253B0390494}.Release|Any CPU.Build.0 = Release|Any CPU
{D6E0C1D8-CAA7-4F95-88E1-C253B0390494}.Release|x64.ActiveCfg = Release|Any CPU
{D6E0C1D8-CAA7-4F95-88E1-C253B0390494}.Release|x64.Build.0 = Release|Any CPU
{D6E0C1D8-CAA7-4F95-88E1-C253B0390494}.Release|x86.ActiveCfg = Release|Any CPU
{D6E0C1D8-CAA7-4F95-88E1-C253B0390494}.Release|x86.Build.0 = Release|Any CPU
{AED08D6B-D0A2-4B67-BF43-D8244C424145}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AED08D6B-D0A2-4B67-BF43-D8244C424145}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AED08D6B-D0A2-4B67-BF43-D8244C424145}.Debug|x64.ActiveCfg = Debug|Any CPU
{AED08D6B-D0A2-4B67-BF43-D8244C424145}.Debug|x64.Build.0 = Debug|Any CPU
{AED08D6B-D0A2-4B67-BF43-D8244C424145}.Debug|x86.ActiveCfg = Debug|Any CPU
{AED08D6B-D0A2-4B67-BF43-D8244C424145}.Debug|x86.Build.0 = Debug|Any CPU
{AED08D6B-D0A2-4B67-BF43-D8244C424145}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AED08D6B-D0A2-4B67-BF43-D8244C424145}.Release|Any CPU.Build.0 = Release|Any CPU
{AED08D6B-D0A2-4B67-BF43-D8244C424145}.Release|x64.ActiveCfg = Release|Any CPU
{AED08D6B-D0A2-4B67-BF43-D8244C424145}.Release|x64.Build.0 = Release|Any CPU
{AED08D6B-D0A2-4B67-BF43-D8244C424145}.Release|x86.ActiveCfg = Release|Any CPU
{AED08D6B-D0A2-4B67-BF43-D8244C424145}.Release|x86.Build.0 = Release|Any CPU
{931322BD-B4BD-436A-BEE8-FCF95FF4A09E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{931322BD-B4BD-436A-BEE8-FCF95FF4A09E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{931322BD-B4BD-436A-BEE8-FCF95FF4A09E}.Debug|x64.ActiveCfg = Debug|Any CPU
{931322BD-B4BD-436A-BEE8-FCF95FF4A09E}.Debug|x64.Build.0 = Debug|Any CPU
{931322BD-B4BD-436A-BEE8-FCF95FF4A09E}.Debug|x86.ActiveCfg = Debug|Any CPU
{931322BD-B4BD-436A-BEE8-FCF95FF4A09E}.Debug|x86.Build.0 = Debug|Any CPU
{931322BD-B4BD-436A-BEE8-FCF95FF4A09E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{931322BD-B4BD-436A-BEE8-FCF95FF4A09E}.Release|Any CPU.Build.0 = Release|Any CPU
{931322BD-B4BD-436A-BEE8-FCF95FF4A09E}.Release|x64.ActiveCfg = Release|Any CPU
{931322BD-B4BD-436A-BEE8-FCF95FF4A09E}.Release|x64.Build.0 = Release|Any CPU
{931322BD-B4BD-436A-BEE8-FCF95FF4A09E}.Release|x86.ActiveCfg = Release|Any CPU
{931322BD-B4BD-436A-BEE8-FCF95FF4A09E}.Release|x86.Build.0 = Release|Any CPU
{73C1CF97-527D-427B-842B-C4CBED3429B5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{73C1CF97-527D-427B-842B-C4CBED3429B5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{73C1CF97-527D-427B-842B-C4CBED3429B5}.Debug|x64.ActiveCfg = Debug|Any CPU
{73C1CF97-527D-427B-842B-C4CBED3429B5}.Debug|x64.Build.0 = Debug|Any CPU
{73C1CF97-527D-427B-842B-C4CBED3429B5}.Debug|x86.ActiveCfg = Debug|Any CPU
{73C1CF97-527D-427B-842B-C4CBED3429B5}.Debug|x86.Build.0 = Debug|Any CPU
{73C1CF97-527D-427B-842B-C4CBED3429B5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{73C1CF97-527D-427B-842B-C4CBED3429B5}.Release|Any CPU.Build.0 = Release|Any CPU
{73C1CF97-527D-427B-842B-C4CBED3429B5}.Release|x64.ActiveCfg = Release|Any CPU
{73C1CF97-527D-427B-842B-C4CBED3429B5}.Release|x64.Build.0 = Release|Any CPU
{73C1CF97-527D-427B-842B-C4CBED3429B5}.Release|x86.ActiveCfg = Release|Any CPU
{73C1CF97-527D-427B-842B-C4CBED3429B5}.Release|x86.Build.0 = Release|Any CPU
{614DB4A0-24C4-457F-82BB-CE077BCA6E4E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{614DB4A0-24C4-457F-82BB-CE077BCA6E4E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{614DB4A0-24C4-457F-82BB-CE077BCA6E4E}.Debug|x64.ActiveCfg = Debug|Any CPU
{614DB4A0-24C4-457F-82BB-CE077BCA6E4E}.Debug|x64.Build.0 = Debug|Any CPU
{614DB4A0-24C4-457F-82BB-CE077BCA6E4E}.Debug|x86.ActiveCfg = Debug|Any CPU
{614DB4A0-24C4-457F-82BB-CE077BCA6E4E}.Debug|x86.Build.0 = Debug|Any CPU
{614DB4A0-24C4-457F-82BB-CE077BCA6E4E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{614DB4A0-24C4-457F-82BB-CE077BCA6E4E}.Release|Any CPU.Build.0 = Release|Any CPU
{614DB4A0-24C4-457F-82BB-CE077BCA6E4E}.Release|x64.ActiveCfg = Release|Any CPU
{614DB4A0-24C4-457F-82BB-CE077BCA6E4E}.Release|x64.Build.0 = Release|Any CPU
{614DB4A0-24C4-457F-82BB-CE077BCA6E4E}.Release|x86.ActiveCfg = Release|Any CPU
{614DB4A0-24C4-457F-82BB-CE077BCA6E4E}.Release|x86.Build.0 = Release|Any CPU
{EAF2C884-939C-428D-981F-CDABE5D42852}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{EAF2C884-939C-428D-981F-CDABE5D42852}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EAF2C884-939C-428D-981F-CDABE5D42852}.Debug|x64.ActiveCfg = Debug|Any CPU
{EAF2C884-939C-428D-981F-CDABE5D42852}.Debug|x64.Build.0 = Debug|Any CPU
{EAF2C884-939C-428D-981F-CDABE5D42852}.Debug|x86.ActiveCfg = Debug|Any CPU
{EAF2C884-939C-428D-981F-CDABE5D42852}.Debug|x86.Build.0 = Debug|Any CPU
{EAF2C884-939C-428D-981F-CDABE5D42852}.Release|Any CPU.ActiveCfg = Release|Any CPU
{EAF2C884-939C-428D-981F-CDABE5D42852}.Release|Any CPU.Build.0 = Release|Any CPU
{EAF2C884-939C-428D-981F-CDABE5D42852}.Release|x64.ActiveCfg = Release|Any CPU
{EAF2C884-939C-428D-981F-CDABE5D42852}.Release|x64.Build.0 = Release|Any CPU
{EAF2C884-939C-428D-981F-CDABE5D42852}.Release|x86.ActiveCfg = Release|Any CPU
{EAF2C884-939C-428D-981F-CDABE5D42852}.Release|x86.Build.0 = Release|Any CPU
{1D172B0D-9D60-4366-999B-E2D186B55D46}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1D172B0D-9D60-4366-999B-E2D186B55D46}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1D172B0D-9D60-4366-999B-E2D186B55D46}.Debug|x64.ActiveCfg = Debug|Any CPU
{1D172B0D-9D60-4366-999B-E2D186B55D46}.Debug|x64.Build.0 = Debug|Any CPU
{1D172B0D-9D60-4366-999B-E2D186B55D46}.Debug|x86.ActiveCfg = Debug|Any CPU
{1D172B0D-9D60-4366-999B-E2D186B55D46}.Debug|x86.Build.0 = Debug|Any CPU
{1D172B0D-9D60-4366-999B-E2D186B55D46}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1D172B0D-9D60-4366-999B-E2D186B55D46}.Release|Any CPU.Build.0 = Release|Any CPU
{1D172B0D-9D60-4366-999B-E2D186B55D46}.Release|x64.ActiveCfg = Release|Any CPU
{1D172B0D-9D60-4366-999B-E2D186B55D46}.Release|x64.Build.0 = Release|Any CPU
{1D172B0D-9D60-4366-999B-E2D186B55D46}.Release|x86.ActiveCfg = Release|Any CPU
{1D172B0D-9D60-4366-999B-E2D186B55D46}.Release|x86.Build.0 = Release|Any CPU
{95343C64-EF22-40D0-ABA6-489CE65AF11F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{95343C64-EF22-40D0-ABA6-489CE65AF11F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{95343C64-EF22-40D0-ABA6-489CE65AF11F}.Debug|x64.ActiveCfg = Debug|Any CPU
{95343C64-EF22-40D0-ABA6-489CE65AF11F}.Debug|x64.Build.0 = Debug|Any CPU
{95343C64-EF22-40D0-ABA6-489CE65AF11F}.Debug|x86.ActiveCfg = Debug|Any CPU
{95343C64-EF22-40D0-ABA6-489CE65AF11F}.Debug|x86.Build.0 = Debug|Any CPU
{95343C64-EF22-40D0-ABA6-489CE65AF11F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{95343C64-EF22-40D0-ABA6-489CE65AF11F}.Release|Any CPU.Build.0 = Release|Any CPU
{95343C64-EF22-40D0-ABA6-489CE65AF11F}.Release|x64.ActiveCfg = Release|Any CPU
{95343C64-EF22-40D0-ABA6-489CE65AF11F}.Release|x64.Build.0 = Release|Any CPU
{95343C64-EF22-40D0-ABA6-489CE65AF11F}.Release|x86.ActiveCfg = Release|Any CPU
{95343C64-EF22-40D0-ABA6-489CE65AF11F}.Release|x86.Build.0 = Release|Any CPU
{2AC4CB72-078B-44D7-A3E6-B1651F1B8C29}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2AC4CB72-078B-44D7-A3E6-B1651F1B8C29}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2AC4CB72-078B-44D7-A3E6-B1651F1B8C29}.Debug|x64.ActiveCfg = Debug|Any CPU
{2AC4CB72-078B-44D7-A3E6-B1651F1B8C29}.Debug|x64.Build.0 = Debug|Any CPU
{2AC4CB72-078B-44D7-A3E6-B1651F1B8C29}.Debug|x86.ActiveCfg = Debug|Any CPU
{2AC4CB72-078B-44D7-A3E6-B1651F1B8C29}.Debug|x86.Build.0 = Debug|Any CPU
{2AC4CB72-078B-44D7-A3E6-B1651F1B8C29}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2AC4CB72-078B-44D7-A3E6-B1651F1B8C29}.Release|Any CPU.Build.0 = Release|Any CPU
{2AC4CB72-078B-44D7-A3E6-B1651F1B8C29}.Release|x64.ActiveCfg = Release|Any CPU
{2AC4CB72-078B-44D7-A3E6-B1651F1B8C29}.Release|x64.Build.0 = Release|Any CPU
{2AC4CB72-078B-44D7-A3E6-B1651F1B8C29}.Release|x86.ActiveCfg = Release|Any CPU
{2AC4CB72-078B-44D7-A3E6-B1651F1B8C29}.Release|x86.Build.0 = Release|Any CPU
{EF0BCA60-10E6-48AF-807D-416D262B85E3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{EF0BCA60-10E6-48AF-807D-416D262B85E3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EF0BCA60-10E6-48AF-807D-416D262B85E3}.Debug|x64.ActiveCfg = Debug|Any CPU
{EF0BCA60-10E6-48AF-807D-416D262B85E3}.Debug|x64.Build.0 = Debug|Any CPU
{EF0BCA60-10E6-48AF-807D-416D262B85E3}.Debug|x86.ActiveCfg = Debug|Any CPU
{EF0BCA60-10E6-48AF-807D-416D262B85E3}.Debug|x86.Build.0 = Debug|Any CPU
{EF0BCA60-10E6-48AF-807D-416D262B85E3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{EF0BCA60-10E6-48AF-807D-416D262B85E3}.Release|Any CPU.Build.0 = Release|Any CPU
{EF0BCA60-10E6-48AF-807D-416D262B85E3}.Release|x64.ActiveCfg = Release|Any CPU
{EF0BCA60-10E6-48AF-807D-416D262B85E3}.Release|x64.Build.0 = Release|Any CPU
{EF0BCA60-10E6-48AF-807D-416D262B85E3}.Release|x86.ActiveCfg = Release|Any CPU
{EF0BCA60-10E6-48AF-807D-416D262B85E3}.Release|x86.Build.0 = Release|Any CPU
{A059FDA9-5454-49A8-A025-0FC5130574EE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A059FDA9-5454-49A8-A025-0FC5130574EE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A059FDA9-5454-49A8-A025-0FC5130574EE}.Debug|x64.ActiveCfg = Debug|Any CPU
{A059FDA9-5454-49A8-A025-0FC5130574EE}.Debug|x64.Build.0 = Debug|Any CPU
{A059FDA9-5454-49A8-A025-0FC5130574EE}.Debug|x86.ActiveCfg = Debug|Any CPU
{A059FDA9-5454-49A8-A025-0FC5130574EE}.Debug|x86.Build.0 = Debug|Any CPU
{A059FDA9-5454-49A8-A025-0FC5130574EE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A059FDA9-5454-49A8-A025-0FC5130574EE}.Release|Any CPU.Build.0 = Release|Any CPU
{A059FDA9-5454-49A8-A025-0FC5130574EE}.Release|x64.ActiveCfg = Release|Any CPU
{A059FDA9-5454-49A8-A025-0FC5130574EE}.Release|x64.Build.0 = Release|Any CPU
{A059FDA9-5454-49A8-A025-0FC5130574EE}.Release|x86.ActiveCfg = Release|Any CPU
{A059FDA9-5454-49A8-A025-0FC5130574EE}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{0F399DDB-4292-4527-B2F0-2252516F7615} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{6ECE123E-3FD9-4146-B44E-B1332FAFC010} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{D6E0C1D8-CAA7-4F95-88E1-C253B0390494} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{AED08D6B-D0A2-4B67-BF43-D8244C424145} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{931322BD-B4BD-436A-BEE8-FCF95FF4A09E} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{73C1CF97-527D-427B-842B-C4CBED3429B5} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{614DB4A0-24C4-457F-82BB-CE077BCA6E4E} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{C8E42992-5E42-0C2B-DBFE-AA848D06431C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{EAF2C884-939C-428D-981F-CDABE5D42852} = {C8E42992-5E42-0C2B-DBFE-AA848D06431C}
{EC447DCF-ABFA-6E24-52A5-D7FD48A5C558} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{CA0D0B73-F1EC-F12F-54BA-8DF761F62CA4} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
{1D172B0D-9D60-4366-999B-E2D186B55D46} = {CA0D0B73-F1EC-F12F-54BA-8DF761F62CA4}
{95343C64-EF22-40D0-ABA6-489CE65AF11F} = {CA0D0B73-F1EC-F12F-54BA-8DF761F62CA4}
{2AC4CB72-078B-44D7-A3E6-B1651F1B8C29} = {CA0D0B73-F1EC-F12F-54BA-8DF761F62CA4}
{EF0BCA60-10E6-48AF-807D-416D262B85E3} = {CA0D0B73-F1EC-F12F-54BA-8DF761F62CA4}
{A059FDA9-5454-49A8-A025-0FC5130574EE} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
EndGlobal

50
colaflow-api/Dockerfile Normal file
View File

@@ -0,0 +1,50 @@
# ColaFlow API Dockerfile
# Multi-stage build for .NET 9 application
# Stage 1: Build
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
# Copy solution and project files
COPY ColaFlow.sln .
COPY src/ColaFlow.Domain/ColaFlow.Domain.csproj src/ColaFlow.Domain/
COPY src/ColaFlow.Application/ColaFlow.Application.csproj src/ColaFlow.Application/
COPY src/ColaFlow.Infrastructure/ColaFlow.Infrastructure.csproj src/ColaFlow.Infrastructure/
COPY src/ColaFlow.API/ColaFlow.API.csproj src/ColaFlow.API/
# Restore dependencies
RUN dotnet restore
# Copy all source files
COPY src/ src/
# Build the application
WORKDIR /src/src/ColaFlow.API
RUN dotnet build -c Release -o /app/build --no-restore
# Stage 2: Publish
FROM build AS publish
RUN dotnet publish -c Release -o /app/publish --no-restore
# Stage 3: Runtime
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
WORKDIR /app
# Install curl for healthcheck
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
# Copy published files
COPY --from=publish /app/publish .
# Expose ports
EXPOSE 8080 8081
# Set environment
ENV ASPNETCORE_URLS=http://+:8080;https://+:8081
# Health check
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=40s \
CMD curl -f http://localhost:8080/health || exit 1
# Entry point
ENTRYPOINT ["dotnet", "ColaFlow.API.dll"]

477
colaflow-api/README.md Normal file
View File

@@ -0,0 +1,477 @@
# ColaFlow API
ColaFlow 后端 API 服务 - 基于 .NET 9 的 **Modular Monolith + Clean Architecture + DDD + CQRS** 实现。
## 架构亮点
- **Modular Monolith Architecture** - 模块化单体架构,清晰的模块边界
- **Clean Architecture** - 四层架构设计Domain → Application → Infrastructure → API
- **Domain-Driven Design (DDD)** - 领域驱动设计(战术模式)
- **CQRS** - 命令查询职责分离MediatR
- **Event Sourcing** - 事件溯源(用于审计日志)
- **Architecture Testing** - 自动化架构测试NetArchTest
## 技术栈
- **.NET 9** - 最新的 .NET 平台
- **Entity Framework Core 9** - ORM
- **PostgreSQL 16+** - 主数据库
- **Redis 7+** - 缓存和会话管理
- **MediatR** - 中介者模式CQRS 和模块间通信)
- **xUnit** - 单元测试框架
- **NetArchTest.Rules** - 架构测试
- **Testcontainers** - 集成测试
## 项目结构(模块化单体)
```
colaflow-api/
├── src/
│ ├── ColaFlow.API/ # API 层(统一入口)
│ │ └── Program.cs # 模块注册和启动
│ │
│ ├── Modules/ # 业务模块
│ │ ├── ProjectManagement/ # 项目管理模块
│ │ │ ├── ColaFlow.Modules.PM.Domain/
│ │ │ │ ├── Aggregates/ # Project, Epic, Story, Task
│ │ │ │ ├── ValueObjects/ # ProjectId, ProjectKey, etc.
│ │ │ │ ├── Events/ # Domain Events
│ │ │ │ └── Exceptions/ # Domain Exceptions
│ │ │ ├── ColaFlow.Modules.PM.Application/
│ │ │ │ ├── Commands/ # CQRS Commands
│ │ │ │ ├── Queries/ # CQRS Queries
│ │ │ │ └── DTOs/ # Data Transfer Objects
│ │ │ ├── ColaFlow.Modules.PM.Infrastructure/
│ │ │ │ ├── Persistence/ # EF Core Configurations
│ │ │ │ └── Repositories/ # Repository Implementations
│ │ │ └── ColaFlow.Modules.PM.Contracts/
│ │ │ └── Events/ # Integration Events
│ │ │
│ │ ├── Workflow/ # 工作流模块(待实现)
│ │ ├── UserManagement/ # 用户管理模块(待实现)
│ │ ├── Notifications/ # 通知模块(待实现)
│ │ ├── Audit/ # 审计模块(待实现)
│ │ └── AI/ # AI 模块(待实现)
│ │
│ ├── Shared/ # 共享内核
│ │ └── ColaFlow.Shared.Kernel/
│ │ ├── Common/ # Entity, ValueObject, AggregateRoot
│ │ ├── Events/ # DomainEvent
│ │ └── Modules/ # IModule 接口
│ │
│ └── [Legacy - To be removed] # 旧的单体结构(迁移中)
│ ├── ColaFlow.Domain/
│ ├── ColaFlow.Application/
│ └── ColaFlow.Infrastructure/
├── tests/
│ ├── ColaFlow.ArchitectureTests/ # 架构测试(模块边界)
│ ├── ColaFlow.Domain.Tests/ # 领域层单元测试
│ ├── ColaFlow.Application.Tests/ # 应用层单元测试
│ └── ColaFlow.IntegrationTests/ # 集成测试
└── ColaFlow.sln
```
## Modular Monolith 架构
### 模块边界规则
```
┌────────────────────────────────────────────────────┐
│ ColaFlow.API (Entry Point) │
└─────────────────┬──────────────────────────────────┘
┌─────────────┴─────────────┐
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ PM │ │ Workflow │ ... (其他模块)
│ Module │◄─────────►│ Module │
└─────────────┘ └─────────────┘
│ │
▼ ▼
┌─────────────────────────────────────┐
│ Shared.Kernel (Common Base) │
└─────────────────────────────────────┘
```
### 模块通信规则
1. **禁止直接引用其他模块的 Domain 实体**
2. **允许通过 MediatR 查询其他模块**Application Service Integration
3. **允许通过 Domain Events 解耦通信**Event-Driven
4. **使用 Contracts 项目定义模块对外接口**
### 架构测试
项目包含自动化架构测试,确保模块边界被严格遵守:
```bash
dotnet test tests/ColaFlow.ArchitectureTests
```
测试内容:
- Domain 层不依赖 Application 和 Infrastructure
- Domain 层只依赖 Shared.Kernel
- 模块间不直接引用其他模块的 Domain 实体
- AggregateRoot 正确继承
- ValueObject 是不可变的sealed
## Clean Architecture 层级依赖
每个模块内部仍然遵循 Clean Architecture
```
Module Structure:
API/Controllers ──┐
├──> Application ──> Domain
Infrastructure ───┘
```
**依赖规则:**
- **Domain 层**:仅依赖 Shared.Kernel无其他依赖
- **Application 层**:依赖 Domain 和 Contracts
- **Infrastructure 层**:依赖 Domain 和 Application
- **API 层**:依赖所有层
## 快速开始
### 前置要求
- .NET 9 SDK
- Docker Desktop用于 PostgreSQL 和 Redis
- IDEVisual Studio 2022 / JetBrains Rider / VS Code
### 1. 安装依赖
```bash
cd colaflow-api
dotnet restore
```
### 2. 启动数据库(使用 Docker
从项目根目录启动:
```bash
cd ..
docker-compose up -d postgres redis
```
### 3. 运行数据库迁移
```bash
# 创建迁移Infrastructure 层完成后)
dotnet ef migrations add InitialCreate --project src/ColaFlow.Infrastructure --startup-project src/ColaFlow.API
# 应用迁移
dotnet ef database update --project src/ColaFlow.Infrastructure --startup-project src/ColaFlow.API
```
### 4. 运行 API
```bash
cd src/ColaFlow.API
dotnet run
```
API 将运行在:
- HTTP: `http://localhost:5000`
- HTTPS: `https://localhost:5001`
- Swagger/Scalar: `https://localhost:5001/scalar/v1`
### 5. 运行测试
```bash
# 运行所有测试
dotnet test
# 运行单元测试
dotnet test --filter Category=Unit
# 运行集成测试
dotnet test --filter Category=Integration
# 生成覆盖率报告
dotnet test /p:CollectCoverage=true /p:CoverletOutputFormat=opencover
```
## 开发指南
### Domain Layer 开发
**聚合根示例:**
```csharp
public class Project : AggregateRoot
{
public ProjectId Id { get; private set; }
public string Name { get; private set; }
// 工厂方法
public static Project Create(string name, string description, UserId ownerId)
{
var project = new Project
{
Id = ProjectId.Create(),
Name = name,
OwnerId = ownerId
};
project.AddDomainEvent(new ProjectCreatedEvent(project.Id, project.Name));
return project;
}
// 业务方法
public void UpdateDetails(string name, string description)
{
Name = name;
Description = description;
AddDomainEvent(new ProjectUpdatedEvent(Id));
}
}
```
### Application Layer 开发CQRS
**Command 示例:**
```csharp
public sealed record CreateProjectCommand : IRequest<ProjectDto>
{
public string Name { get; init; }
public string Description { get; init; }
}
public sealed class CreateProjectCommandHandler : IRequestHandler<CreateProjectCommand, ProjectDto>
{
public async Task<ProjectDto> Handle(CreateProjectCommand request, CancellationToken cancellationToken)
{
// 1. 创建聚合
var project = Project.Create(request.Name, request.Description, currentUserId);
// 2. 保存
await _repository.AddAsync(project, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);
// 3. 返回 DTO
return _mapper.Map<ProjectDto>(project);
}
}
```
**Query 示例:**
```csharp
public sealed record GetProjectByIdQuery : IRequest<ProjectDto>
{
public Guid ProjectId { get; init; }
}
public sealed class GetProjectByIdQueryHandler : IQueryHandler<GetProjectByIdQuery, ProjectDto>
{
public async Task<ProjectDto> Handle(GetProjectByIdQuery request, CancellationToken cancellationToken)
{
var project = await _context.Projects
.AsNoTracking()
.FirstOrDefaultAsync(p => p.Id == request.ProjectId, cancellationToken);
return _mapper.Map<ProjectDto>(project);
}
}
```
### API Layer 开发
**Controller 示例:**
```csharp
[ApiController]
[Route("api/v1/[controller]")]
public class ProjectsController : ControllerBase
{
private readonly IMediator _mediator;
[HttpPost]
[ProducesResponseType(typeof(ProjectDto), StatusCodes.Status201Created)]
public async Task<IActionResult> CreateProject([FromBody] CreateProjectCommand command)
{
var result = await _mediator.Send(command);
return CreatedAtAction(nameof(GetProject), new { id = result.Id }, result);
}
[HttpGet("{id}")]
[ProducesResponseType(typeof(ProjectDto), StatusCodes.Status200OK)]
public async Task<IActionResult> GetProject(Guid id)
{
var result = await _mediator.Send(new GetProjectByIdQuery { ProjectId = id });
return Ok(result);
}
}
```
## 测试策略
### 测试金字塔
- **70% 单元测试** - Domain 和 Application 层
- **20% 集成测试** - API 端点测试
- **10% E2E 测试** - 关键用户流程
### 单元测试示例
```csharp
public class ProjectTests
{
[Fact]
public void Create_WithValidData_ShouldCreateProject()
{
// Arrange
var name = "Test Project";
var ownerId = UserId.Create();
// Act
var project = Project.Create(name, "Description", ownerId);
// Assert
project.Should().NotBeNull();
project.Name.Should().Be(name);
project.DomainEvents.Should().ContainSingle(e => e is ProjectCreatedEvent);
}
}
```
### 集成测试示例
```csharp
public class ProjectsControllerTests : IntegrationTestBase
{
[Fact]
public async Task CreateProject_WithValidData_ShouldReturn201()
{
// Arrange
var command = new CreateProjectCommand { Name = "Test", Description = "Test" };
// Act
var response = await _client.PostAsJsonAsync("/api/v1/projects", command);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var project = await response.Content.ReadFromJsonAsync<ProjectDto>();
project.Should().NotBeNull();
}
}
```
## 代码质量
### 覆盖率要求
- **最低要求80%**
- **目标90%+**
- **关键路径100%**
### 代码规范
- 遵循 C# 编码规范
- 使用 private 构造函数 + 工厂方法
- 所有 public 方法必须有 XML 注释
- 所有业务逻辑必须有单元测试
## NuGet 包版本
### Domain Layer
- 无外部依赖
### Application Layer
- MediatR 13.1.0
- FluentValidation 12.0.0
- AutoMapper 15.1.0
### Infrastructure Layer
- Microsoft.EntityFrameworkCore 9.0.10
- Npgsql.EntityFrameworkCore.PostgreSQL 9.0.4
- StackExchange.Redis 2.9.32
### API Layer
- Serilog.AspNetCore 9.0.0
- Scalar.AspNetCore 2.9.0
### Test Projects
- xUnit 2.9.2
- FluentAssertions 8.8.0
- Moq 4.20.72
- Testcontainers 4.x
## 环境变量
创建 `src/ColaFlow.API/appsettings.Development.json`:
```json
{
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=colaflow;Username=colaflow;Password=colaflow_password",
"Redis": "localhost:6379,password=colaflow_redis_password"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
```
## API 文档
启动应用后,访问:
- **Scalar UI**: `https://localhost:5001/scalar/v1`
- **OpenAPI JSON**: `https://localhost:5001/openapi/v1.json`
## 相关文档
- [完整架构设计](../docs/M1-Architecture-Design.md)
- [项目计划](../product.md)
- [Sprint 计划](../docs/Sprint-Plan.md)
- [Docker 使用指南](../DOCKER-README.md)
- [测试指南](tests/README.md)
## 下一步开发任务
### Infrastructure Layer进行中
- [ ] 配置 EF Core DbContext
- [ ] 创建 Entity Configurations
- [ ] 生成数据库 Migrations
- [ ] 实现 Repository 和 Unit of Work
### Application Layer待开发
- [ ] 实现 CQRS Commands
- [ ] 实现 CQRS Queries
- [ ] 配置 MediatR Pipeline Behaviors
- [ ] 实现 FluentValidation Validators
### API Layer待开发
- [ ] 实现 REST API Controllers
- [ ] 配置 OpenAPI/Scalar
- [ ] 实现异常处理中间件
- [ ] 配置 JWT 认证
### 测试(待开发)
- [ ] 编写 Domain 单元测试≥80% 覆盖率)
- [ ] 编写 Application 单元测试
- [ ] 编写 API 集成测试
## License
MIT License
## 团队
ColaFlow Development Team
---
**当前状态**: 🟡 Domain Layer 完成Infrastructure 和 Application 层开发中
**最后更新**: 2025-11-02

View File

@@ -0,0 +1,280 @@
# ColaFlow 模块化重构总结
**日期**: 2025-11-02
**状态**: ✅ 完成
**架构**: Modular Monolith + Clean Architecture + DDD + CQRS
---
## 重构概述
成功将 ColaFlow 后端从传统的单体架构重构为**模块化单体架构**Modular Monolith保留了 Clean Architecture + DDD 的优势,同时引入了清晰的模块边界,为未来可能的微服务迁移奠定基础。
## 架构决策
根据 `docs/Modular-Monolith-Architecture.md` 的分析和建议,我们选择了 **Modular Monolith** 而非 **Microservices**,原因如下:
1. **团队规模小**4-8 人):微服务需要 15+ 人的团队
2. **项目早期阶段**Sprint 1 of M1Week 1-2 of 48
3. **Domain 边界尚未稳定**:需要时间验证模块划分
4. **快速交付优先**:避免 8-12 周的微服务基础设施开发时间
5. **成本控制**Modular Monolith 基础设施成本仅为微服务的 1/10
## 实施成果
### 1. 新的目录结构
```
colaflow-api/
├── src/
│ ├── ColaFlow.API/ # API 层(统一入口)
│ │
│ ├── Modules/ # 业务模块
│ │ └── ProjectManagement/ # 项目管理模块 ✅
│ │ ├── ColaFlow.Modules.PM.Domain/
│ │ ├── ColaFlow.Modules.PM.Application/
│ │ ├── ColaFlow.Modules.PM.Infrastructure/
│ │ └── ColaFlow.Modules.PM.Contracts/
│ │
│ └── Shared/ # 共享内核
│ └── ColaFlow.Shared.Kernel/
│ ├── Common/ # Entity, ValueObject, AggregateRoot
│ ├── Events/ # DomainEvent
│ └── Modules/ # IModule 接口
├── tests/
│ └── ColaFlow.ArchitectureTests/ # 架构测试 ✅
```
### 2. 创建的项目
#### Shared.Kernel 项目
**路径**: `src/Shared/ColaFlow.Shared.Kernel/`
**内容**:
- `Common/Entity.cs` - 实体基类
- `Common/ValueObject.cs` - 值对象基类
- `Common/AggregateRoot.cs` - 聚合根基类
- `Common/Enumeration.cs` - 类型安全枚举基类
- `Events/DomainEvent.cs` - 领域事件基类
- `Modules/IModule.cs` - 模块接口
**用途**: 所有模块共享的基础类和接口
#### ProjectManagement 模块
**路径**: `src/Modules/ProjectManagement/`
**包含项目**:
1. **ColaFlow.Modules.PM.Domain** - 领域层
- 迁移自 `ColaFlow.Domain/Aggregates`
- 包含Project, Epic, Story, WorkTask 聚合
- 包含:所有 ValueObjectsProjectId, ProjectKey 等)
- 包含:所有 Domain Events
2. **ColaFlow.Modules.PM.Application** - 应用层
- 待实现 CQRS Commands 和 Queries
3. **ColaFlow.Modules.PM.Infrastructure** - 基础设施层
- 待实现 Repositories 和 EF Core Configurations
4. **ColaFlow.Modules.PM.Contracts** - 对外契约
- 定义模块对外暴露的接口和 Integration Events
#### 架构测试项目
**路径**: `tests/ColaFlow.ArchitectureTests/`
**测试内容**:
- Domain 层不依赖 Application 和 Infrastructure
- Domain 层只依赖 Shared.Kernel
- Project 继承自 AggregateRoot
- Entities 继承自 Entity
- ValueObjects 是不可变的sealed
- Domain Events 是 records
**测试结果**: ✅ 8/8 通过
### 3. 模块注册机制
创建了 `IModule` 接口,用于模块的服务注册和配置:
```csharp
public interface IModule
{
string Name { get; }
void RegisterServices(IServiceCollection services, IConfiguration configuration);
void ConfigureApplication(IApplicationBuilder app);
}
```
**ProjectManagementModule** 实现:
```csharp
public class ProjectManagementModule : IModule
{
public string Name => "ProjectManagement";
public void RegisterServices(IServiceCollection services, IConfiguration configuration)
{
// 注册 MediatR handlers
// 注册 Repositories
// 注册 Application Services
}
public void ConfigureApplication(IApplicationBuilder app)
{
// 配置模块特定的中间件
}
}
```
### 4. 命名空间迁移
**旧命名空间****新命名空间**:
- `ColaFlow.Domain.*``ColaFlow.Modules.PM.Domain.*`
- `ColaFlow.Domain.Common``ColaFlow.Shared.Kernel.Common`
- `ColaFlow.Domain.Events``ColaFlow.Shared.Kernel.Events`
### 5. 解决方案更新
更新了 `ColaFlow.sln`,新增以下项目:
- ColaFlow.Shared.Kernel
- ColaFlow.Modules.PM.Domain
- ColaFlow.Modules.PM.Application
- ColaFlow.Modules.PM.Infrastructure
- ColaFlow.Modules.PM.Contracts
- ColaFlow.ArchitectureTests
## 编译和测试结果
### 编译结果
```bash
dotnet build
```
**成功** - 19 个警告0 个错误
警告主要是 NuGet 包版本依赖冲突(非阻塞)
### 架构测试结果
```bash
dotnet test tests/ColaFlow.ArchitectureTests
```
**通过** - 8/8 测试通过
- Domain 层依赖检查 ✅
- 继承关系检查 ✅
- 不可变性检查 ✅
- 事件类型检查 ✅
## 模块边界规则
### ✅ 允许的依赖
1. 所有模块 → Shared.Kernel
2. Module.Application → Module.Domain
3. Module.Infrastructure → Module.Application, Module.Domain
4. 模块间通过 MediatR 进行查询Application Service Integration
5. 模块间通过 Domain Events 进行解耦通信
### ❌ 禁止的依赖
1. Module.Domain → Module.Application
2. Module.Domain → Module.Infrastructure
3. Module.Domain → 其他模块的 Domain
4. 直接引用其他模块的实体类
这些规则由 **ArchUnit 测试** 自动化验证。
## 未来迁移路径
### 短期M1-M3
- 保持 Modular Monolith 架构
- 继续完善 ProjectManagement 模块
- 添加新模块Workflow, User, Notifications
- 验证模块边界是否合理
### 中期M4-M6
- 如果团队增长到 15+ 人,考虑提取第一个微服务
- 优先提取 AI Module独立扩展需求
- 使用 Strangler Fig 模式逐步迁移
### 微服务迁移条件
只有满足以下条件时才考虑迁移到微服务:
1. ✅ 团队规模 > 15 人
2. ✅ 用户规模 > 50,000 活跃用户
3. ✅ 特定模块需要独立扩展
4. ✅ Domain 边界稳定1+ 年)
5. ✅ 团队具备分布式系统经验
## 成功指标
### ✅ 已完成
- [x] 清晰的模块目录结构
- [x] Shared.Kernel 项目创建
- [x] ProjectManagement 模块迁移
- [x] IModule 接口和注册机制
- [x] 架构测试自动化
- [x] 编译成功
- [x] 所有测试通过
- [x] 文档更新README.md
### 📋 下一步任务
- [ ] 完善 Application 层Commands/Queries
- [ ] 完善 Infrastructure 层Repositories
- [ ] 添加 Workflow 模块
- [ ] 添加 User 模块
- [ ] 实现跨模块通信示例
## 技术债务
### 当前遗留
1. **旧的单体项目**(待删除):
- `src/ColaFlow.Domain/`
- `src/ColaFlow.Application/`
- `src/ColaFlow.Infrastructure/`
**计划**: 在所有代码迁移完成后删除
2. **NuGet 包版本警告**:
- MediatR 版本冲突12.4.1 vs 11.x
- AutoMapper 版本冲突15.1.0 vs 12.0.1
**计划**: 统一升级到最新稳定版本
## 性能影响
### 分析结果
-**零性能损失** - Modular Monolith 与传统 Monolith 性能相同
- ✅ 相同的进程内调用(无网络开销)
- ✅ 相同的数据库连接池
- ✅ 无序列化/反序列化开销
### 对比微服务
- 🚀 **快 10-100x** - 无跨服务网络调用
- 💾 **内存占用更低** - 单一进程
- 🔧 **运维简单** - 单一部署单元
## 参考文档
1. [Modular-Monolith-Architecture.md](./Modular-Monolith-Architecture.md) - 完整的架构分析
2. [README.md](../README.md) - 更新后的项目文档
3. [ColaFlow.sln](../ColaFlow.sln) - 解决方案文件
## 结论
**重构成功!**
ColaFlow 后端现在拥有:
- 清晰的模块边界
- 可维护的代码结构
- 自动化的架构测试
- 未来迁移到微服务的路径
同时保持了:
- 简单的开发体验
- 低运维成本
- 快速迭代能力
- ACID 事务保证
这个架构非常适合 ColaFlow 当前的团队规模和项目阶段,能够支持到 M6100k+ 用户)而无需迁移到微服务。
---
**最后更新**: 2025-11-02
**责任人**: Architecture Team
**状态**: ✅ 完成并验证

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

Some files were not shown because too many files have changed in this diff Show More