🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
2037 lines
71 KiB
Markdown
2037 lines
71 KiB
Markdown
# ColaFlow M1 Architecture Design
|
|
|
|
**Version:** 1.0
|
|
**Date:** 2025-11-02
|
|
**Milestone:** M1 - Core Project Management Module
|
|
**Duration:** 8 weeks (Sprints 1-4)
|
|
|
|
---
|
|
|
|
## Executive Summary
|
|
|
|
This document defines the complete system architecture for ColaFlow M1, implementing the Core Project Management Module. The architecture leverages modern technologies: **.NET 9** with **Domain-Driven Design (DDD)** for the backend, **PostgreSQL** for data persistence, **React 19 + Next.js 15** for the frontend, and **REST + SignalR** for API communication.
|
|
|
|
### Key Architecture Decisions
|
|
|
|
| Decision | Technology | Rationale |
|
|
|----------|-----------|-----------|
|
|
| **Backend Framework** | .NET 9 with Clean Architecture | Enterprise-grade, excellent DDD support, strong typing |
|
|
| **Architectural Pattern** | DDD + CQRS + Event Sourcing | Domain complexity, audit trail requirements, scalability |
|
|
| **Primary Database** | PostgreSQL 16+ | ACID transactions, JSONB flexibility, excellent for hierarchies |
|
|
| **Caching** | Redis 7+ | Session management, real-time scaling, pub/sub |
|
|
| **Frontend Framework** | React 19 + Next.js 15 | Largest community, excellent DX, SSR/SSG capabilities |
|
|
| **UI Components** | shadcn/ui + Radix UI + Tailwind | Component ownership, accessibility, modern design |
|
|
| **State Management** | TanStack Query + Zustand | Server state + client state separation |
|
|
| **API Protocol** | REST with OpenAPI 3.1 | Broad compatibility, excellent tooling, MCP-ready |
|
|
| **Real-time** | SignalR (WebSockets/HTTP2) | Native .NET integration, automatic fallback |
|
|
|
|
---
|
|
|
|
## 1. System Architecture Overview
|
|
|
|
### 1.1 High-Level Architecture
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────────┐
|
|
│ CLIENT LAYER │
|
|
│ ┌──────────────────┐ ┌──────────────────┐ ┌───────────────┐ │
|
|
│ │ Web Browser │ │ Desktop App │ │ Mobile App │ │
|
|
│ │ (Next.js 15) │ │ (Future) │ │ (Future) │ │
|
|
│ └────────┬─────────┘ └────────┬─────────┘ └───────┬───────┘ │
|
|
└───────────┼──────────────────────┼─────────────────────┼─────────┘
|
|
│ │ │
|
|
│ HTTPS / WSS │ │
|
|
└──────────────────────┴─────────────────────┘
|
|
│
|
|
┌─────────────────────────────────────────────────────────────────┐
|
|
│ API GATEWAY │
|
|
│ ┌──────────────────────────────────────────────────────────┐ │
|
|
│ │ ASP.NET Core 9 API Gateway (Future M2+) │ │
|
|
│ │ - Rate Limiting - Authentication - Routing │ │
|
|
│ └──────────────────────────────────────────────────────────┘ │
|
|
└───────────────────────────────┬─────────────────────────────────┘
|
|
│
|
|
┌─────────────────────────────────────────────────────────────────┐
|
|
│ APPLICATION LAYER │
|
|
│ ┌───────────────────────────────────────────────────────────┐ │
|
|
│ │ ColaFlow.API (.NET 9) │ │
|
|
│ │ ┌────────────────┐ ┌─────────────┐ ┌────────────────┐ │ │
|
|
│ │ │ REST API │ │ SignalR │ │ OpenAPI │ │ │
|
|
│ │ │ Controllers │ │ Hubs │ │ Documentation │ │ │
|
|
│ │ └────────────────┘ └─────────────┘ └────────────────┘ │ │
|
|
│ │ │ │
|
|
│ │ ┌────────────────────────────────────────────────────┐ │ │
|
|
│ │ │ APPLICATION SERVICES LAYER │ │ │
|
|
│ │ │ (CQRS - Commands & Queries via MediatR) │ │ │
|
|
│ │ │ - Project Management - User Management │ │ │
|
|
│ │ │ - Workflow Engine - Audit Logging │ │ │
|
|
│ │ └────────────────────────────────────────────────────┘ │ │
|
|
│ │ │ │
|
|
│ │ ┌────────────────────────────────────────────────────┐ │ │
|
|
│ │ │ DOMAIN LAYER │ │ │
|
|
│ │ │ (Business Logic - Framework Independent) │ │ │
|
|
│ │ │ - Aggregates - Entities - Value Objects │ │ │
|
|
│ │ │ - Domain Events - Domain Services │ │ │
|
|
│ │ └────────────────────────────────────────────────────┘ │ │
|
|
│ │ │ │
|
|
│ │ ┌────────────────────────────────────────────────────┐ │ │
|
|
│ │ │ INFRASTRUCTURE LAYER │ │ │
|
|
│ │ │ - EF Core Repositories - Event Store │ │ │
|
|
│ │ │ - External Services - Cross-cutting Concerns │ │ │
|
|
│ │ └────────────────────────────────────────────────────┘ │ │
|
|
│ └───────────────────────────────────────────────────────────┘ │
|
|
└───────────────────────────────┬─────────────────────────────────┘
|
|
│
|
|
┌─────────────────────────────────────────────────────────────────┐
|
|
│ DATA LAYER │
|
|
│ ┌──────────────────┐ ┌──────────────────┐ ┌───────────────┐ │
|
|
│ │ PostgreSQL 16 │ │ Redis 7 │ │ File Storage │ │
|
|
│ │ (Primary DB) │ │ (Cache/Session) │ │ (Attachments)│ │
|
|
│ │ - Transactional │ │ - SignalR Backpl│ │ - MinIO/S3 │ │
|
|
│ │ - Event Store │ │ - Rate Limiting │ │ (Future) │ │
|
|
│ │ - JSONB Support │ │ - Pub/Sub │ │ │ │
|
|
│ └──────────────────┘ └──────────────────┘ └───────────────┘ │
|
|
└─────────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
### 1.2 Architecture Principles
|
|
|
|
1. **Clean Architecture**: Separation of concerns with dependency inversion
|
|
2. **Domain-Driven Design**: Rich domain model, ubiquitous language
|
|
3. **CQRS**: Separate read and write models for performance
|
|
4. **Event Sourcing**: Complete audit trail via domain events
|
|
5. **API-First**: OpenAPI specification drives implementation
|
|
6. **Testability**: All layers independently testable
|
|
7. **Scalability**: Stateless services, horizontal scaling ready
|
|
|
|
---
|
|
|
|
## 2. Backend Architecture (.NET 9 with DDD)
|
|
|
|
### 2.1 Clean Architecture Layers
|
|
|
|
```
|
|
ColaFlow.sln
|
|
│
|
|
├── src/
|
|
│ ├── ColaFlow.Domain/ # Core business logic (innermost layer)
|
|
│ │ ├── Aggregates/
|
|
│ │ │ ├── ProjectAggregate/
|
|
│ │ │ │ ├── Project.cs # Aggregate Root
|
|
│ │ │ │ ├── Epic.cs # Entity
|
|
│ │ │ │ ├── Story.cs # Entity
|
|
│ │ │ │ ├── Task.cs # Entity
|
|
│ │ │ │ └── Subtask.cs # Entity
|
|
│ │ │ ├── UserAggregate/
|
|
│ │ │ └── WorkflowAggregate/
|
|
│ │ ├── ValueObjects/
|
|
│ │ │ ├── ProjectId.cs
|
|
│ │ │ ├── TaskPriority.cs
|
|
│ │ │ ├── TaskStatus.cs
|
|
│ │ │ └── CustomField.cs
|
|
│ │ ├── DomainEvents/
|
|
│ │ │ ├── ProjectCreatedEvent.cs
|
|
│ │ │ ├── TaskStatusChangedEvent.cs
|
|
│ │ │ └── TaskAssignedEvent.cs
|
|
│ │ ├── Interfaces/
|
|
│ │ │ ├── IProjectRepository.cs
|
|
│ │ │ └── IUnitOfWork.cs
|
|
│ │ ├── Services/
|
|
│ │ │ └── WorkflowService.cs # Domain Service
|
|
│ │ └── Exceptions/
|
|
│ │ └── DomainException.cs
|
|
│ │
|
|
│ ├── ColaFlow.Application/ # Use cases and orchestration
|
|
│ │ ├── Commands/ # CQRS Commands
|
|
│ │ │ ├── Projects/
|
|
│ │ │ │ ├── CreateProject/
|
|
│ │ │ │ │ ├── CreateProjectCommand.cs
|
|
│ │ │ │ │ ├── CreateProjectCommandHandler.cs
|
|
│ │ │ │ │ └── CreateProjectCommandValidator.cs
|
|
│ │ │ │ ├── UpdateProject/
|
|
│ │ │ │ └── DeleteProject/
|
|
│ │ │ ├── Tasks/
|
|
│ │ │ │ ├── CreateTask/
|
|
│ │ │ │ ├── UpdateTaskStatus/
|
|
│ │ │ │ └── AssignTask/
|
|
│ │ │ └── Workflows/
|
|
│ │ ├── Queries/ # CQRS Queries
|
|
│ │ │ ├── Projects/
|
|
│ │ │ │ ├── GetProjectById/
|
|
│ │ │ │ │ ├── GetProjectByIdQuery.cs
|
|
│ │ │ │ │ └── GetProjectByIdQueryHandler.cs
|
|
│ │ │ │ ├── GetProjectList/
|
|
│ │ │ │ └── GetProjectKanban/
|
|
│ │ │ ├── Tasks/
|
|
│ │ │ └── AuditLogs/
|
|
│ │ ├── DTOs/
|
|
│ │ │ ├── ProjectDto.cs
|
|
│ │ │ ├── TaskDto.cs
|
|
│ │ │ └── KanbanBoardDto.cs
|
|
│ │ ├── Mappings/
|
|
│ │ │ └── AutoMapperProfile.cs
|
|
│ │ ├── Behaviors/ # MediatR Pipeline Behaviors
|
|
│ │ │ ├── ValidationBehavior.cs
|
|
│ │ │ ├── LoggingBehavior.cs
|
|
│ │ │ └── TransactionBehavior.cs
|
|
│ │ └── Interfaces/
|
|
│ │ └── ICurrentUserService.cs
|
|
│ │
|
|
│ ├── ColaFlow.Infrastructure/ # External concerns
|
|
│ │ ├── Persistence/
|
|
│ │ │ ├── ColaFlowDbContext.cs # EF Core DbContext
|
|
│ │ │ ├── Configurations/ # Entity Configurations
|
|
│ │ │ │ ├── ProjectConfiguration.cs
|
|
│ │ │ │ └── TaskConfiguration.cs
|
|
│ │ │ ├── Repositories/
|
|
│ │ │ │ ├── ProjectRepository.cs
|
|
│ │ │ │ └── UserRepository.cs
|
|
│ │ │ ├── Migrations/
|
|
│ │ │ └── EventStore/
|
|
│ │ │ └── EventStoreRepository.cs
|
|
│ │ ├── Identity/
|
|
│ │ │ ├── IdentityService.cs
|
|
│ │ │ └── CurrentUserService.cs
|
|
│ │ ├── Services/
|
|
│ │ │ ├── DateTimeService.cs
|
|
│ │ │ └── EmailService.cs
|
|
│ │ ├── Caching/
|
|
│ │ │ └── RedisCacheService.cs
|
|
│ │ └── SignalR/
|
|
│ │ └── NotificationHub.cs
|
|
│ │
|
|
│ └── ColaFlow.API/ # Presentation layer
|
|
│ ├── Controllers/
|
|
│ │ ├── ProjectsController.cs
|
|
│ │ ├── TasksController.cs
|
|
│ │ ├── WorkflowsController.cs
|
|
│ │ └── AuditLogsController.cs
|
|
│ ├── Hubs/
|
|
│ │ └── ProjectHub.cs # SignalR Hub
|
|
│ ├── Middleware/
|
|
│ │ ├── ExceptionHandlingMiddleware.cs
|
|
│ │ └── RequestLoggingMiddleware.cs
|
|
│ ├── Filters/
|
|
│ │ ├── ValidateModelStateFilter.cs
|
|
│ │ └── ApiExceptionFilter.cs
|
|
│ ├── Program.cs # Application entry point
|
|
│ ├── appsettings.json
|
|
│ └── appsettings.Development.json
|
|
│
|
|
└── tests/
|
|
├── ColaFlow.Domain.Tests/ # Domain unit tests
|
|
├── ColaFlow.Application.Tests/ # Application unit tests
|
|
├── ColaFlow.Infrastructure.Tests/ # Infrastructure integration tests
|
|
└── ColaFlow.API.Tests/ # API integration tests
|
|
```
|
|
|
|
### 2.2 Domain Layer - DDD Tactical Patterns
|
|
|
|
#### 2.2.1 Project Aggregate Root
|
|
|
|
```csharp
|
|
namespace ColaFlow.Domain.Aggregates.ProjectAggregate
|
|
{
|
|
/// <summary>
|
|
/// Project Aggregate Root
|
|
/// Enforces consistency boundary for Project -> Epic -> Story -> Task hierarchy
|
|
/// </summary>
|
|
public class Project : AggregateRoot
|
|
{
|
|
public ProjectId Id { get; private set; }
|
|
public string Name { get; private set; }
|
|
public string Description { get; private set; }
|
|
public ProjectKey Key { get; private set; } // e.g., "COLA"
|
|
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; }
|
|
|
|
// 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 (key.Length > 10)
|
|
throw new DomainException("Project key cannot exceed 10 characters");
|
|
|
|
var project = new Project
|
|
{
|
|
Id = ProjectId.Create(),
|
|
Name = name,
|
|
Description = description,
|
|
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");
|
|
|
|
Name = name;
|
|
Description = description;
|
|
UpdatedAt = DateTime.UtcNow;
|
|
|
|
AddDomainEvent(new ProjectUpdatedEvent(Id, Name, Description));
|
|
}
|
|
|
|
public Epic CreateEpic(string name, string description, UserId createdBy)
|
|
{
|
|
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));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Epic Entity (part of Project aggregate)
|
|
/// </summary>
|
|
public class Epic : Entity
|
|
{
|
|
public EpicId Id { get; private set; }
|
|
public string Name { get; private set; }
|
|
public string Description { get; private set; }
|
|
public ProjectId ProjectId { get; private set; }
|
|
public TaskStatus Status { 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 static Epic Create(string name, string description, ProjectId projectId, UserId createdBy)
|
|
{
|
|
return new Epic
|
|
{
|
|
Id = EpicId.Create(),
|
|
Name = name,
|
|
Description = description,
|
|
ProjectId = projectId,
|
|
Status = TaskStatus.ToDo,
|
|
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;
|
|
}
|
|
}
|
|
|
|
// Story and Task entities follow similar patterns...
|
|
}
|
|
```
|
|
|
|
#### 2.2.2 Value Objects
|
|
|
|
```csharp
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// TaskPriority Value Object (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) { }
|
|
}
|
|
|
|
/// <summary>
|
|
/// CustomField Value Object (flexible schema)
|
|
/// </summary>
|
|
public sealed class CustomField : ValueObject
|
|
{
|
|
public string Key { get; private set; }
|
|
public string Value { get; private set; }
|
|
public CustomFieldType Type { get; private set; }
|
|
|
|
public static CustomField Create(string key, string value, CustomFieldType type)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(key))
|
|
throw new DomainException("Custom field key cannot be empty");
|
|
|
|
return new CustomField
|
|
{
|
|
Key = key,
|
|
Value = value,
|
|
Type = type
|
|
};
|
|
}
|
|
|
|
protected override IEnumerable<object> GetAtomicValues()
|
|
{
|
|
yield return Key;
|
|
yield return Value;
|
|
yield return Type;
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
#### 2.2.3 Domain Events
|
|
|
|
```csharp
|
|
namespace ColaFlow.Domain.DomainEvents
|
|
{
|
|
/// <summary>
|
|
/// Base Domain Event
|
|
/// </summary>
|
|
public abstract record DomainEvent
|
|
{
|
|
public Guid EventId { get; init; } = Guid.NewGuid();
|
|
public DateTime OccurredOn { get; init; } = DateTime.UtcNow;
|
|
}
|
|
|
|
/// <summary>
|
|
/// ProjectCreatedEvent
|
|
/// </summary>
|
|
public record ProjectCreatedEvent(
|
|
ProjectId ProjectId,
|
|
string ProjectName,
|
|
UserId CreatedBy
|
|
) : DomainEvent;
|
|
|
|
/// <summary>
|
|
/// TaskStatusChangedEvent (for audit trail and real-time notifications)
|
|
/// </summary>
|
|
public record TaskStatusChangedEvent(
|
|
TaskId TaskId,
|
|
TaskStatus OldStatus,
|
|
TaskStatus NewStatus,
|
|
UserId ChangedBy,
|
|
string Reason
|
|
) : DomainEvent;
|
|
|
|
/// <summary>
|
|
/// TaskAssignedEvent
|
|
/// </summary>
|
|
public record TaskAssignedEvent(
|
|
TaskId TaskId,
|
|
UserId AssigneeId,
|
|
UserId AssignedBy
|
|
) : DomainEvent;
|
|
}
|
|
```
|
|
|
|
### 2.3 Application Layer - CQRS with MediatR
|
|
|
|
#### 2.3.1 Command Example
|
|
|
|
```csharp
|
|
namespace ColaFlow.Application.Commands.Projects.CreateProject
|
|
{
|
|
// Command (request)
|
|
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;
|
|
}
|
|
|
|
// Command Validator (FluentValidation)
|
|
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")
|
|
.Matches("^[A-Z]{2,10}$").WithMessage("Project key must be 2-10 uppercase letters");
|
|
}
|
|
}
|
|
|
|
// Command Handler
|
|
public sealed class CreateProjectCommandHandler : IRequestHandler<CreateProjectCommand, ProjectDto>
|
|
{
|
|
private readonly IProjectRepository _projectRepository;
|
|
private readonly IUnitOfWork _unitOfWork;
|
|
private readonly ICurrentUserService _currentUserService;
|
|
private readonly IMapper _mapper;
|
|
private readonly ILogger<CreateProjectCommandHandler> _logger;
|
|
|
|
public CreateProjectCommandHandler(
|
|
IProjectRepository projectRepository,
|
|
IUnitOfWork unitOfWork,
|
|
ICurrentUserService currentUserService,
|
|
IMapper mapper,
|
|
ILogger<CreateProjectCommandHandler> logger)
|
|
{
|
|
_projectRepository = projectRepository;
|
|
_unitOfWork = unitOfWork;
|
|
_currentUserService = currentUserService;
|
|
_mapper = mapper;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<ProjectDto> Handle(CreateProjectCommand request, CancellationToken cancellationToken)
|
|
{
|
|
_logger.LogInformation("Creating project: {ProjectName}", request.Name);
|
|
|
|
// 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");
|
|
|
|
// Get current user
|
|
var currentUserId = UserId.Create(_currentUserService.UserId);
|
|
|
|
// Create aggregate
|
|
var project = Project.Create(
|
|
request.Name,
|
|
request.Description,
|
|
request.Key,
|
|
currentUserId
|
|
);
|
|
|
|
// Save to repository
|
|
await _projectRepository.AddAsync(project, cancellationToken);
|
|
await _unitOfWork.CommitAsync(cancellationToken);
|
|
|
|
_logger.LogInformation("Project created successfully: {ProjectId}", project.Id);
|
|
|
|
// Return DTO
|
|
return _mapper.Map<ProjectDto>(project);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
#### 2.3.2 Query Example
|
|
|
|
```csharp
|
|
namespace ColaFlow.Application.Queries.Projects.GetProjectKanban
|
|
{
|
|
// Query (request)
|
|
public sealed record GetProjectKanbanQuery(Guid ProjectId) : IRequest<KanbanBoardDto>;
|
|
|
|
// Query Handler (can use Dapper for performance)
|
|
public sealed class GetProjectKanbanQueryHandler : IRequestHandler<GetProjectKanbanQuery, KanbanBoardDto>
|
|
{
|
|
private readonly ColaFlowDbContext _context;
|
|
private readonly IMapper _mapper;
|
|
|
|
public GetProjectKanbanQueryHandler(ColaFlowDbContext context, IMapper mapper)
|
|
{
|
|
_context = context;
|
|
_mapper = mapper;
|
|
}
|
|
|
|
public async Task<KanbanBoardDto> Handle(GetProjectKanbanQuery request, CancellationToken cancellationToken)
|
|
{
|
|
// Optimized query for read side
|
|
var project = await _context.Projects
|
|
.AsNoTracking()
|
|
.Include(p => p.Epics)
|
|
.ThenInclude(e => e.Stories)
|
|
.ThenInclude(s => s.Tasks)
|
|
.FirstOrDefaultAsync(p => p.Id == ProjectId.Create(request.ProjectId), cancellationToken);
|
|
|
|
if (project == null)
|
|
throw new NotFoundException(nameof(Project), request.ProjectId);
|
|
|
|
// Transform to Kanban view model
|
|
var kanban = new KanbanBoardDto
|
|
{
|
|
ProjectId = project.Id.Value,
|
|
ProjectName = project.Name,
|
|
Columns = new List<KanbanColumnDto>
|
|
{
|
|
new() { Status = "To Do", Tasks = GetTasksByStatus(project, TaskStatus.ToDo) },
|
|
new() { Status = "In Progress", Tasks = GetTasksByStatus(project, TaskStatus.InProgress) },
|
|
new() { Status = "Review", Tasks = GetTasksByStatus(project, TaskStatus.InReview) },
|
|
new() { Status = "Done", Tasks = GetTasksByStatus(project, TaskStatus.Done) }
|
|
}
|
|
};
|
|
|
|
return kanban;
|
|
}
|
|
|
|
private List<TaskCardDto> GetTasksByStatus(Project project, TaskStatus status)
|
|
{
|
|
return project.Epics
|
|
.SelectMany(e => e.Stories)
|
|
.SelectMany(s => s.Tasks)
|
|
.Where(t => t.Status == status)
|
|
.Select(t => _mapper.Map<TaskCardDto>(t))
|
|
.ToList();
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
#### 2.3.3 MediatR Pipeline Behaviors
|
|
|
|
```csharp
|
|
namespace ColaFlow.Application.Behaviors
|
|
{
|
|
/// <summary>
|
|
/// Validation Behavior (runs before handler)
|
|
/// </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();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Transaction Behavior (commits UnitOfWork after handler)
|
|
/// </summary>
|
|
public sealed class TransactionBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
|
|
where TRequest : IRequest<TResponse>
|
|
{
|
|
private readonly IUnitOfWork _unitOfWork;
|
|
|
|
public TransactionBehavior(IUnitOfWork unitOfWork)
|
|
{
|
|
_unitOfWork = unitOfWork;
|
|
}
|
|
|
|
public async Task<TResponse> Handle(
|
|
TRequest request,
|
|
RequestHandlerDelegate<TResponse> next,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// Execute handler
|
|
var response = await next();
|
|
|
|
// Commit transaction and dispatch domain events
|
|
await _unitOfWork.CommitAsync(cancellationToken);
|
|
|
|
return response;
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### 2.4 Infrastructure Layer - Persistence
|
|
|
|
#### 2.4.1 EF Core DbContext
|
|
|
|
```csharp
|
|
namespace ColaFlow.Infrastructure.Persistence
|
|
{
|
|
public class ColaFlowDbContext : DbContext, IUnitOfWork
|
|
{
|
|
private readonly IDomainEventDispatcher _domainEventDispatcher;
|
|
|
|
public DbSet<Project> Projects => Set<Project>();
|
|
public DbSet<User> Users => Set<User>();
|
|
public DbSet<Workflow> Workflows => Set<Workflow>();
|
|
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
|
|
public DbSet<DomainEventRecord> DomainEvents => Set<DomainEventRecord>();
|
|
|
|
public ColaFlowDbContext(
|
|
DbContextOptions<ColaFlowDbContext> options,
|
|
IDomainEventDispatcher domainEventDispatcher) : base(options)
|
|
{
|
|
_domainEventDispatcher = domainEventDispatcher;
|
|
}
|
|
|
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
|
{
|
|
modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());
|
|
|
|
// Global query filters
|
|
modelBuilder.Entity<Project>().HasQueryFilter(p => !p.IsDeleted);
|
|
modelBuilder.Entity<User>().HasQueryFilter(u => !u.IsDeleted);
|
|
}
|
|
|
|
public async Task<int> CommitAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
// Dispatch domain events before saving
|
|
await DispatchDomainEventsAsync(cancellationToken);
|
|
|
|
// Save changes
|
|
return await base.SaveChangesAsync(cancellationToken);
|
|
}
|
|
|
|
private async Task DispatchDomainEventsAsync(CancellationToken cancellationToken)
|
|
{
|
|
var domainEntities = ChangeTracker
|
|
.Entries<AggregateRoot>()
|
|
.Where(x => x.Entity.DomainEvents.Any())
|
|
.Select(x => x.Entity)
|
|
.ToList();
|
|
|
|
var domainEvents = domainEntities
|
|
.SelectMany(x => x.DomainEvents)
|
|
.ToList();
|
|
|
|
// Clear domain events
|
|
domainEntities.ForEach(entity => entity.ClearDomainEvents());
|
|
|
|
// Dispatch events
|
|
foreach (var domainEvent in domainEvents)
|
|
{
|
|
await _domainEventDispatcher.DispatchAsync(domainEvent, cancellationToken);
|
|
}
|
|
|
|
// Store events in event store
|
|
foreach (var domainEvent in domainEvents)
|
|
{
|
|
DomainEvents.Add(new DomainEventRecord
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
EventType = domainEvent.GetType().Name,
|
|
EventData = JsonSerializer.Serialize(domainEvent),
|
|
OccurredOn = domainEvent.OccurredOn
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
#### 2.4.2 Entity Configuration (Fluent API)
|
|
|
|
```csharp
|
|
namespace ColaFlow.Infrastructure.Persistence.Configurations
|
|
{
|
|
public class ProjectConfiguration : IEntityTypeConfiguration<Project>
|
|
{
|
|
public void Configure(EntityTypeBuilder<Project> builder)
|
|
{
|
|
builder.ToTable("Projects");
|
|
|
|
builder.HasKey(p => p.Id);
|
|
builder.Property(p => p.Id)
|
|
.HasConversion(
|
|
id => id.Value,
|
|
value => ProjectId.Create(value))
|
|
.IsRequired();
|
|
|
|
builder.Property(p => p.Name)
|
|
.IsRequired()
|
|
.HasMaxLength(200);
|
|
|
|
builder.Property(p => p.Description)
|
|
.HasMaxLength(2000);
|
|
|
|
builder.OwnsOne(p => p.Key, keyBuilder =>
|
|
{
|
|
keyBuilder.Property(k => k.Value)
|
|
.HasColumnName("Key")
|
|
.IsRequired()
|
|
.HasMaxLength(10);
|
|
|
|
keyBuilder.HasIndex(k => k.Value).IsUnique();
|
|
});
|
|
|
|
builder.Property(p => p.Status)
|
|
.HasConversion<string>()
|
|
.IsRequired();
|
|
|
|
// Owned collection - Custom Fields (stored as JSONB in PostgreSQL)
|
|
builder.OwnsMany(p => p.CustomFields, cfBuilder =>
|
|
{
|
|
cfBuilder.ToJson("CustomFieldsJson");
|
|
});
|
|
|
|
// Navigation properties
|
|
builder.HasMany(p => p.Epics)
|
|
.WithOne()
|
|
.HasForeignKey("ProjectId")
|
|
.OnDelete(DeleteBehavior.Cascade);
|
|
|
|
builder.HasOne<User>()
|
|
.WithMany()
|
|
.HasForeignKey(p => p.OwnerId)
|
|
.OnDelete(DeleteBehavior.Restrict);
|
|
|
|
// Indexes
|
|
builder.HasIndex(p => p.CreatedAt);
|
|
builder.HasIndex(p => p.Status);
|
|
|
|
// Soft delete
|
|
builder.Property<bool>("IsDeleted").HasDefaultValue(false);
|
|
builder.Property<DateTime?>("DeletedAt");
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
#### 2.4.3 Repository Implementation
|
|
|
|
```csharp
|
|
namespace ColaFlow.Infrastructure.Persistence.Repositories
|
|
{
|
|
public class ProjectRepository : IProjectRepository
|
|
{
|
|
private readonly ColaFlowDbContext _context;
|
|
|
|
public ProjectRepository(ColaFlowDbContext context)
|
|
{
|
|
_context = context;
|
|
}
|
|
|
|
public async Task<Project?> GetByIdAsync(ProjectId id, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.Projects
|
|
.Include(p => p.Epics)
|
|
.ThenInclude(e => e.Stories)
|
|
.ThenInclude(s => s.Tasks)
|
|
.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(int page, int pageSize, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.Projects
|
|
.OrderByDescending(p => p.CreatedAt)
|
|
.Skip((page - 1) * pageSize)
|
|
.Take(pageSize)
|
|
.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)
|
|
{
|
|
// Soft delete
|
|
_context.Entry(project).Property("IsDeleted").CurrentValue = true;
|
|
_context.Entry(project).Property("DeletedAt").CurrentValue = DateTime.UtcNow;
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 3. Database Design (PostgreSQL)
|
|
|
|
### 3.1 Database Schema
|
|
|
|
```sql
|
|
-- Projects Table
|
|
CREATE TABLE "Projects" (
|
|
"Id" UUID PRIMARY KEY,
|
|
"Name" VARCHAR(200) NOT NULL,
|
|
"Description" VARCHAR(2000),
|
|
"Key" VARCHAR(10) NOT NULL UNIQUE,
|
|
"Status" VARCHAR(50) NOT NULL,
|
|
"OwnerId" UUID NOT NULL,
|
|
"CustomFieldsJson" JSONB,
|
|
"CreatedAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
"UpdatedAt" TIMESTAMP,
|
|
"IsDeleted" BOOLEAN NOT NULL DEFAULT FALSE,
|
|
"DeletedAt" TIMESTAMP,
|
|
CONSTRAINT "FK_Projects_Users" FOREIGN KEY ("OwnerId") REFERENCES "Users"("Id")
|
|
);
|
|
|
|
CREATE INDEX "IX_Projects_Key" ON "Projects"("Key");
|
|
CREATE INDEX "IX_Projects_Status" ON "Projects"("Status");
|
|
CREATE INDEX "IX_Projects_CreatedAt" ON "Projects"("CreatedAt");
|
|
CREATE INDEX "IX_Projects_OwnerId" ON "Projects"("OwnerId");
|
|
|
|
-- Epics Table
|
|
CREATE TABLE "Epics" (
|
|
"Id" UUID PRIMARY KEY,
|
|
"Name" VARCHAR(200) NOT NULL,
|
|
"Description" VARCHAR(2000),
|
|
"ProjectId" UUID NOT NULL,
|
|
"Status" VARCHAR(50) NOT NULL,
|
|
"Priority" VARCHAR(50) NOT NULL DEFAULT 'Medium',
|
|
"CreatedBy" UUID NOT NULL,
|
|
"CreatedAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
"UpdatedAt" TIMESTAMP,
|
|
"IsDeleted" BOOLEAN NOT NULL DEFAULT FALSE,
|
|
CONSTRAINT "FK_Epics_Projects" FOREIGN KEY ("ProjectId") REFERENCES "Projects"("Id") ON DELETE CASCADE,
|
|
CONSTRAINT "FK_Epics_Users" FOREIGN KEY ("CreatedBy") REFERENCES "Users"("Id")
|
|
);
|
|
|
|
CREATE INDEX "IX_Epics_ProjectId" ON "Epics"("ProjectId");
|
|
CREATE INDEX "IX_Epics_Status" ON "Epics"("Status");
|
|
|
|
-- Stories Table
|
|
CREATE TABLE "Stories" (
|
|
"Id" UUID PRIMARY KEY,
|
|
"Title" VARCHAR(200) NOT NULL,
|
|
"Description" TEXT,
|
|
"EpicId" UUID NOT NULL,
|
|
"Status" VARCHAR(50) NOT NULL,
|
|
"Priority" VARCHAR(50) NOT NULL DEFAULT 'Medium',
|
|
"EstimatedHours" DECIMAL(10,2),
|
|
"ActualHours" DECIMAL(10,2),
|
|
"AssigneeId" UUID,
|
|
"CreatedBy" UUID NOT NULL,
|
|
"CreatedAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
"UpdatedAt" TIMESTAMP,
|
|
"IsDeleted" BOOLEAN NOT NULL DEFAULT FALSE,
|
|
CONSTRAINT "FK_Stories_Epics" FOREIGN KEY ("EpicId") REFERENCES "Epics"("Id") ON DELETE CASCADE,
|
|
CONSTRAINT "FK_Stories_AssigneeId" FOREIGN KEY ("AssigneeId") REFERENCES "Users"("Id"),
|
|
CONSTRAINT "FK_Stories_CreatedBy" FOREIGN KEY ("CreatedBy") REFERENCES "Users"("Id")
|
|
);
|
|
|
|
CREATE INDEX "IX_Stories_EpicId" ON "Stories"("EpicId");
|
|
CREATE INDEX "IX_Stories_Status" ON "Stories"("Status");
|
|
CREATE INDEX "IX_Stories_AssigneeId" ON "Stories"("AssigneeId");
|
|
|
|
-- Tasks Table
|
|
CREATE TABLE "Tasks" (
|
|
"Id" UUID PRIMARY KEY,
|
|
"Title" VARCHAR(200) NOT NULL,
|
|
"Description" TEXT,
|
|
"StoryId" UUID NOT NULL,
|
|
"Status" VARCHAR(50) NOT NULL,
|
|
"Priority" VARCHAR(50) NOT NULL DEFAULT 'Medium',
|
|
"EstimatedHours" DECIMAL(10,2),
|
|
"ActualHours" DECIMAL(10,2),
|
|
"AssigneeId" UUID,
|
|
"CustomFieldsJson" JSONB,
|
|
"CreatedBy" UUID NOT NULL,
|
|
"CreatedAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
"UpdatedAt" TIMESTAMP,
|
|
"IsDeleted" BOOLEAN NOT NULL DEFAULT FALSE,
|
|
CONSTRAINT "FK_Tasks_Stories" FOREIGN KEY ("StoryId") REFERENCES "Stories"("Id") ON DELETE CASCADE,
|
|
CONSTRAINT "FK_Tasks_AssigneeId" FOREIGN KEY ("AssigneeId") REFERENCES "Users"("Id"),
|
|
CONSTRAINT "FK_Tasks_CreatedBy" FOREIGN KEY ("CreatedBy") REFERENCES "Users"("Id")
|
|
);
|
|
|
|
CREATE INDEX "IX_Tasks_StoryId" ON "Tasks"("StoryId");
|
|
CREATE INDEX "IX_Tasks_Status" ON "Tasks"("Status");
|
|
CREATE INDEX "IX_Tasks_AssigneeId" ON "Tasks"("AssigneeId");
|
|
CREATE INDEX "IX_Tasks_Priority" ON "Tasks"("Priority");
|
|
|
|
-- Users Table
|
|
CREATE TABLE "Users" (
|
|
"Id" UUID PRIMARY KEY,
|
|
"Email" VARCHAR(255) NOT NULL UNIQUE,
|
|
"FirstName" VARCHAR(100) NOT NULL,
|
|
"LastName" VARCHAR(100) NOT NULL,
|
|
"PasswordHash" VARCHAR(500) NOT NULL,
|
|
"Role" VARCHAR(50) NOT NULL DEFAULT 'User',
|
|
"CreatedAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
"UpdatedAt" TIMESTAMP,
|
|
"IsDeleted" BOOLEAN NOT NULL DEFAULT FALSE
|
|
);
|
|
|
|
CREATE INDEX "IX_Users_Email" ON "Users"("Email");
|
|
|
|
-- Workflows Table
|
|
CREATE TABLE "Workflows" (
|
|
"Id" UUID PRIMARY KEY,
|
|
"Name" VARCHAR(200) NOT NULL,
|
|
"ProjectId" UUID NOT NULL,
|
|
"IsDefault" BOOLEAN NOT NULL DEFAULT FALSE,
|
|
"StatusesJson" JSONB NOT NULL, -- Array of statuses with transitions
|
|
"CreatedAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
"UpdatedAt" TIMESTAMP,
|
|
CONSTRAINT "FK_Workflows_Projects" FOREIGN KEY ("ProjectId") REFERENCES "Projects"("Id") ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE INDEX "IX_Workflows_ProjectId" ON "Workflows"("ProjectId");
|
|
|
|
-- Audit Logs Table (Event Store)
|
|
CREATE TABLE "AuditLogs" (
|
|
"Id" BIGSERIAL PRIMARY KEY,
|
|
"EntityType" VARCHAR(100) NOT NULL,
|
|
"EntityId" UUID NOT NULL,
|
|
"Action" VARCHAR(100) NOT NULL,
|
|
"Changes" JSONB NOT NULL,
|
|
"UserId" UUID NOT NULL,
|
|
"Timestamp" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
"IpAddress" VARCHAR(50),
|
|
CONSTRAINT "FK_AuditLogs_Users" FOREIGN KEY ("UserId") REFERENCES "Users"("Id")
|
|
);
|
|
|
|
CREATE INDEX "IX_AuditLogs_EntityType_EntityId" ON "AuditLogs"("EntityType", "EntityId");
|
|
CREATE INDEX "IX_AuditLogs_Timestamp" ON "AuditLogs"("Timestamp" DESC);
|
|
CREATE INDEX "IX_AuditLogs_UserId" ON "AuditLogs"("UserId");
|
|
|
|
-- Domain Events Table (Event Store)
|
|
CREATE TABLE "DomainEvents" (
|
|
"Id" BIGSERIAL PRIMARY KEY,
|
|
"EventType" VARCHAR(200) NOT NULL,
|
|
"AggregateId" UUID NOT NULL,
|
|
"EventData" JSONB NOT NULL,
|
|
"OccurredOn" TIMESTAMP NOT NULL,
|
|
"ProcessedOn" TIMESTAMP
|
|
);
|
|
|
|
CREATE INDEX "IX_DomainEvents_AggregateId" ON "DomainEvents"("AggregateId");
|
|
CREATE INDEX "IX_DomainEvents_EventType" ON "DomainEvents"("EventType");
|
|
CREATE INDEX "IX_DomainEvents_OccurredOn" ON "DomainEvents"("OccurredOn" DESC);
|
|
```
|
|
|
|
### 3.2 Database Strategy Decisions
|
|
|
|
#### Why PostgreSQL?
|
|
|
|
1. **ACID Transactions**: Essential for DDD aggregate consistency
|
|
2. **JSONB Support**: Flexible schema for custom fields without schema migrations
|
|
3. **Recursive Queries**: Excellent for hierarchical data (Projects → Epics → Stories → Tasks)
|
|
4. **Full-Text Search**: Built-in search capabilities
|
|
5. **Event Sourcing**: Perfect for audit logs and event store
|
|
6. **Performance**: Fast with proper indexing
|
|
7. **Open Source**: No licensing costs
|
|
|
|
#### JSONB Usage
|
|
|
|
- **Custom Fields**: Store user-defined fields without schema changes
|
|
- **Workflow Configuration**: Store workflow states and transitions
|
|
- **Event Data**: Store domain events as JSON documents
|
|
- **Metadata**: Store flexible metadata on entities
|
|
|
|
---
|
|
|
|
## 4. Frontend Architecture (Next.js 15)
|
|
|
|
### 4.1 Project Structure
|
|
|
|
```
|
|
colaflow-web/
|
|
├── app/ # Next.js 15 App Router
|
|
│ ├── (auth)/ # Auth route group
|
|
│ │ ├── login/
|
|
│ │ │ └── page.tsx
|
|
│ │ ├── register/
|
|
│ │ │ └── page.tsx
|
|
│ │ └── layout.tsx
|
|
│ ├── (dashboard)/ # Dashboard route group
|
|
│ │ ├── projects/
|
|
│ │ │ ├── page.tsx # Project list
|
|
│ │ │ ├── [id]/
|
|
│ │ │ │ ├── page.tsx # Project detail
|
|
│ │ │ │ ├── board/ # Kanban board
|
|
│ │ │ │ │ └── page.tsx
|
|
│ │ │ │ ├── tasks/
|
|
│ │ │ │ │ ├── page.tsx # Task list
|
|
│ │ │ │ │ └── [taskId]/
|
|
│ │ │ │ │ └── page.tsx # Task detail
|
|
│ │ │ │ ├── workflows/
|
|
│ │ │ │ │ └── page.tsx
|
|
│ │ │ │ └── audit/
|
|
│ │ │ │ └── page.tsx # Audit logs
|
|
│ │ │ └── create/
|
|
│ │ │ └── page.tsx
|
|
│ │ ├── dashboard/
|
|
│ │ │ └── page.tsx # Main dashboard
|
|
│ │ └── layout.tsx # Dashboard layout
|
|
│ ├── api/ # API routes (BFF pattern)
|
|
│ │ └── auth/
|
|
│ │ └── [...nextauth]/
|
|
│ │ └── route.ts
|
|
│ ├── layout.tsx # Root layout
|
|
│ └── page.tsx # Home page
|
|
│
|
|
├── components/ # Shared components
|
|
│ ├── ui/ # shadcn/ui components
|
|
│ │ ├── button.tsx
|
|
│ │ ├── card.tsx
|
|
│ │ ├── dialog.tsx
|
|
│ │ ├── input.tsx
|
|
│ │ └── ...
|
|
│ ├── layouts/
|
|
│ │ ├── Header.tsx
|
|
│ │ ├── Sidebar.tsx
|
|
│ │ └── Footer.tsx
|
|
│ ├── features/ # Feature-specific components
|
|
│ │ ├── projects/
|
|
│ │ │ ├── ProjectCard.tsx
|
|
│ │ │ ├── ProjectForm.tsx
|
|
│ │ │ └── ProjectList.tsx
|
|
│ │ ├── kanban/
|
|
│ │ │ ├── KanbanBoard.tsx
|
|
│ │ │ ├── KanbanColumn.tsx
|
|
│ │ │ └── TaskCard.tsx
|
|
│ │ └── audit/
|
|
│ │ ├── AuditLogList.tsx
|
|
│ │ └── AuditLogDetail.tsx
|
|
│ └── common/
|
|
│ ├── Loading.tsx
|
|
│ ├── ErrorBoundary.tsx
|
|
│ └── NotFound.tsx
|
|
│
|
|
├── lib/ # Utilities and configurations
|
|
│ ├── api/ # API client
|
|
│ │ ├── client.ts # Axios instance
|
|
│ │ ├── projects.ts # Project API calls
|
|
│ │ ├── tasks.ts # Task API calls
|
|
│ │ └── users.ts # User API calls
|
|
│ ├── hooks/ # Custom React hooks
|
|
│ │ ├── useProjects.ts
|
|
│ │ ├── useTasks.ts
|
|
│ │ └── useAuth.ts
|
|
│ ├── store/ # Zustand stores
|
|
│ │ ├── authStore.ts
|
|
│ │ ├── uiStore.ts
|
|
│ │ └── notificationStore.ts
|
|
│ ├── utils/
|
|
│ │ ├── cn.ts # Tailwind merge utility
|
|
│ │ ├── format.ts # Formatting utilities
|
|
│ │ └── validation.ts # Form validation
|
|
│ └── signalr/
|
|
│ └── hubConnection.ts # SignalR connection setup
|
|
│
|
|
├── types/ # TypeScript types
|
|
│ ├── api.ts # API response types
|
|
│ ├── models.ts # Domain models
|
|
│ └── components.ts # Component prop types
|
|
│
|
|
├── styles/
|
|
│ └── globals.css # Global styles with Tailwind
|
|
│
|
|
├── public/
|
|
│ ├── images/
|
|
│ └── icons/
|
|
│
|
|
├── .env.local # Environment variables
|
|
├── next.config.js
|
|
├── tailwind.config.ts
|
|
├── tsconfig.json
|
|
└── package.json
|
|
```
|
|
|
|
### 4.2 State Management Strategy
|
|
|
|
#### 4.2.1 TanStack Query (Server State)
|
|
|
|
```typescript
|
|
// lib/hooks/useProjects.ts
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { projectsApi } from '@/lib/api/projects';
|
|
import type { Project, CreateProjectDto } from '@/types/models';
|
|
|
|
export function useProjects() {
|
|
return useQuery<Project[]>({
|
|
queryKey: ['projects'],
|
|
queryFn: projectsApi.getAll,
|
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
|
});
|
|
}
|
|
|
|
export function useProject(id: string) {
|
|
return useQuery<Project>({
|
|
queryKey: ['projects', id],
|
|
queryFn: () => projectsApi.getById(id),
|
|
enabled: !!id,
|
|
});
|
|
}
|
|
|
|
export function useCreateProject() {
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation({
|
|
mutationFn: (data: CreateProjectDto) => projectsApi.create(data),
|
|
onSuccess: (newProject) => {
|
|
// Invalidate and refetch projects list
|
|
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
|
|
|
// Optimistically update cache
|
|
queryClient.setQueryData<Project[]>(['projects'], (old) =>
|
|
old ? [...old, newProject] : [newProject]
|
|
);
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useUpdateProject(id: string) {
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation({
|
|
mutationFn: (data: Partial<Project>) => projectsApi.update(id, data),
|
|
onMutate: async (updatedData) => {
|
|
// Optimistic update
|
|
await queryClient.cancelQueries({ queryKey: ['projects', id] });
|
|
|
|
const previousProject = queryClient.getQueryData<Project>(['projects', id]);
|
|
|
|
queryClient.setQueryData<Project>(['projects', id], (old) => ({
|
|
...old!,
|
|
...updatedData,
|
|
}));
|
|
|
|
return { previousProject };
|
|
},
|
|
onError: (err, variables, context) => {
|
|
// Rollback on error
|
|
if (context?.previousProject) {
|
|
queryClient.setQueryData(['projects', id], context.previousProject);
|
|
}
|
|
},
|
|
onSettled: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['projects', id] });
|
|
},
|
|
});
|
|
}
|
|
```
|
|
|
|
#### 4.2.2 Zustand (Client/UI State)
|
|
|
|
```typescript
|
|
// lib/store/uiStore.ts
|
|
import { create } from 'zustand';
|
|
import { devtools, persist } from 'zustand/middleware';
|
|
|
|
interface UIState {
|
|
// Sidebar
|
|
sidebarOpen: boolean;
|
|
toggleSidebar: () => void;
|
|
setSidebarOpen: (open: boolean) => void;
|
|
|
|
// Theme
|
|
theme: 'light' | 'dark' | 'system';
|
|
setTheme: (theme: 'light' | 'dark' | 'system') => void;
|
|
|
|
// Modals
|
|
openModal: string | null;
|
|
setOpenModal: (modal: string | null) => void;
|
|
|
|
// Notifications
|
|
notifications: Notification[];
|
|
addNotification: (notification: Omit<Notification, 'id'>) => void;
|
|
removeNotification: (id: string) => void;
|
|
}
|
|
|
|
export const useUIStore = create<UIState>()(
|
|
devtools(
|
|
persist(
|
|
(set) => ({
|
|
sidebarOpen: true,
|
|
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
|
setSidebarOpen: (open) => set({ sidebarOpen: open }),
|
|
|
|
theme: 'system',
|
|
setTheme: (theme) => set({ theme }),
|
|
|
|
openModal: null,
|
|
setOpenModal: (modal) => set({ openModal: modal }),
|
|
|
|
notifications: [],
|
|
addNotification: (notification) =>
|
|
set((state) => ({
|
|
notifications: [
|
|
...state.notifications,
|
|
{ ...notification, id: crypto.randomUUID() },
|
|
],
|
|
})),
|
|
removeNotification: (id) =>
|
|
set((state) => ({
|
|
notifications: state.notifications.filter((n) => n.id !== id),
|
|
})),
|
|
}),
|
|
{
|
|
name: 'colaflow-ui-storage',
|
|
partialize: (state) => ({ sidebarOpen: state.sidebarOpen, theme: state.theme }),
|
|
}
|
|
)
|
|
)
|
|
);
|
|
```
|
|
|
|
#### 4.2.3 SignalR Real-time Updates
|
|
|
|
```typescript
|
|
// lib/signalr/hubConnection.ts
|
|
import * as signalR from '@microsoft/signalr';
|
|
|
|
class SignalRService {
|
|
private connection: signalR.HubConnection | null = null;
|
|
|
|
async connect(token: string) {
|
|
this.connection = new signalR.HubConnectionBuilder()
|
|
.withUrl(`${process.env.NEXT_PUBLIC_API_URL}/hubs/project`, {
|
|
accessTokenFactory: () => token,
|
|
})
|
|
.withAutomaticReconnect()
|
|
.build();
|
|
|
|
await this.connection.start();
|
|
console.log('SignalR Connected');
|
|
}
|
|
|
|
onTaskUpdated(callback: (task: Task) => void) {
|
|
this.connection?.on('TaskUpdated', callback);
|
|
}
|
|
|
|
onTaskCreated(callback: (task: Task) => void) {
|
|
this.connection?.on('TaskCreated', callback);
|
|
}
|
|
|
|
offTaskUpdated() {
|
|
this.connection?.off('TaskUpdated');
|
|
}
|
|
|
|
async disconnect() {
|
|
await this.connection?.stop();
|
|
}
|
|
}
|
|
|
|
export const signalRService = new SignalRService();
|
|
|
|
// Usage in a component
|
|
import { useEffect } from 'react';
|
|
import { useQueryClient } from '@tanstack/react-query';
|
|
import { signalRService } from '@/lib/signalr/hubConnection';
|
|
|
|
export function useRealtimeUpdates(projectId: string) {
|
|
const queryClient = useQueryClient();
|
|
|
|
useEffect(() => {
|
|
signalRService.onTaskUpdated((task) => {
|
|
// Update cache with new task data
|
|
queryClient.setQueryData(['tasks', task.id], task);
|
|
queryClient.invalidateQueries({ queryKey: ['projects', projectId, 'kanban'] });
|
|
});
|
|
|
|
return () => {
|
|
signalRService.offTaskUpdated();
|
|
};
|
|
}, [projectId, queryClient]);
|
|
}
|
|
```
|
|
|
|
### 4.3 Key Components
|
|
|
|
#### 4.3.1 Kanban Board Component
|
|
|
|
```typescript
|
|
// components/features/kanban/KanbanBoard.tsx
|
|
'use client';
|
|
|
|
import { DndContext, DragEndEvent, DragOverlay, DragStartEvent } from '@dnd-kit/core';
|
|
import { useState } from 'react';
|
|
import { KanbanColumn } from './KanbanColumn';
|
|
import { TaskCard } from './TaskCard';
|
|
import { useKanbanBoard, useUpdateTaskStatus } from '@/lib/hooks/useKanban';
|
|
import { useRealtimeUpdates } from '@/lib/hooks/useRealtimeUpdates';
|
|
import type { Task } from '@/types/models';
|
|
|
|
interface KanbanBoardProps {
|
|
projectId: string;
|
|
}
|
|
|
|
export function KanbanBoard({ projectId }: KanbanBoardProps) {
|
|
const { data: kanban, isLoading } = useKanbanBoard(projectId);
|
|
const updateStatus = useUpdateTaskStatus();
|
|
const [activeTask, setActiveTask] = useState<Task | null>(null);
|
|
|
|
// Real-time updates
|
|
useRealtimeUpdates(projectId);
|
|
|
|
const handleDragStart = (event: DragStartEvent) => {
|
|
const task = event.active.data.current as Task;
|
|
setActiveTask(task);
|
|
};
|
|
|
|
const handleDragEnd = (event: DragEndEvent) => {
|
|
const { active, over } = event;
|
|
|
|
if (!over) return;
|
|
|
|
const taskId = active.id as string;
|
|
const newStatus = over.id as string;
|
|
|
|
// Optimistic update
|
|
updateStatus.mutate({ taskId, newStatus });
|
|
|
|
setActiveTask(null);
|
|
};
|
|
|
|
if (isLoading) return <div>Loading...</div>;
|
|
|
|
return (
|
|
<DndContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
|
<div className="flex gap-4 overflow-x-auto p-4">
|
|
{kanban?.columns.map((column) => (
|
|
<KanbanColumn
|
|
key={column.status}
|
|
column={column}
|
|
tasks={column.tasks}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
<DragOverlay>
|
|
{activeTask ? <TaskCard task={activeTask} isDragging /> : null}
|
|
</DragOverlay>
|
|
</DndContext>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 5. API Design (REST + OpenAPI)
|
|
|
|
### 5.1 RESTful API Endpoints
|
|
|
|
```
|
|
# Projects
|
|
GET /api/v1/projects # List all projects
|
|
POST /api/v1/projects # Create project
|
|
GET /api/v1/projects/{id} # Get project by ID
|
|
PUT /api/v1/projects/{id} # Update project
|
|
DELETE /api/v1/projects/{id} # Delete project
|
|
GET /api/v1/projects/{id}/kanban # Get Kanban board
|
|
|
|
# Epics
|
|
GET /api/v1/projects/{projectId}/epics # List epics
|
|
POST /api/v1/projects/{projectId}/epics # Create epic
|
|
GET /api/v1/projects/{projectId}/epics/{id} # Get epic
|
|
PUT /api/v1/projects/{projectId}/epics/{id} # Update epic
|
|
DELETE /api/v1/projects/{projectId}/epics/{id} # Delete epic
|
|
|
|
# Stories
|
|
GET /api/v1/epics/{epicId}/stories # List stories
|
|
POST /api/v1/epics/{epicId}/stories # Create story
|
|
GET /api/v1/epics/{epicId}/stories/{id} # Get story
|
|
PUT /api/v1/epics/{epicId}/stories/{id} # Update story
|
|
DELETE /api/v1/epics/{epicId}/stories/{id} # Delete story
|
|
|
|
# Tasks
|
|
GET /api/v1/stories/{storyId}/tasks # List tasks
|
|
POST /api/v1/stories/{storyId}/tasks # Create task
|
|
GET /api/v1/tasks/{id} # Get task by ID
|
|
PUT /api/v1/tasks/{id} # Update task
|
|
PATCH /api/v1/tasks/{id}/status # Update task status
|
|
DELETE /api/v1/tasks/{id} # Delete task
|
|
POST /api/v1/tasks/{id}/assign # Assign task to user
|
|
|
|
# Workflows
|
|
GET /api/v1/projects/{projectId}/workflows # List workflows
|
|
POST /api/v1/projects/{projectId}/workflows # Create workflow
|
|
GET /api/v1/workflows/{id} # Get workflow
|
|
PUT /api/v1/workflows/{id} # Update workflow
|
|
DELETE /api/v1/workflows/{id} # Delete workflow
|
|
|
|
# Audit Logs
|
|
GET /api/v1/audit-logs # List all audit logs
|
|
GET /api/v1/audit-logs/{entityType}/{entityId} # Get entity audit logs
|
|
POST /api/v1/audit-logs/{id}/rollback # Rollback changes
|
|
|
|
# Users
|
|
GET /api/v1/users # List users
|
|
GET /api/v1/users/{id} # Get user
|
|
POST /api/v1/users # Create user (admin)
|
|
PUT /api/v1/users/{id} # Update user
|
|
|
|
# Authentication
|
|
POST /api/v1/auth/login # Login
|
|
POST /api/v1/auth/register # Register
|
|
POST /api/v1/auth/refresh # Refresh token
|
|
POST /api/v1/auth/logout # Logout
|
|
```
|
|
|
|
### 5.2 Controller Example
|
|
|
|
```csharp
|
|
namespace ColaFlow.API.Controllers
|
|
{
|
|
[ApiController]
|
|
[Route("api/v1/[controller]")]
|
|
[Authorize]
|
|
public class ProjectsController : ControllerBase
|
|
{
|
|
private readonly IMediator _mediator;
|
|
|
|
public ProjectsController(IMediator mediator)
|
|
{
|
|
_mediator = mediator;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get all projects
|
|
/// </summary>
|
|
[HttpGet]
|
|
[ProducesResponseType(typeof(List<ProjectDto>), StatusCodes.Status200OK)]
|
|
public async Task<IActionResult> GetProjects(
|
|
[FromQuery] int page = 1,
|
|
[FromQuery] int pageSize = 20,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var query = new GetProjectsQuery(page, pageSize);
|
|
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(typeof(ValidationProblemDetails), 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Update project
|
|
/// </summary>
|
|
[HttpPut("{id:guid}")]
|
|
[ProducesResponseType(typeof(ProjectDto), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
public async Task<IActionResult> UpdateProject(
|
|
Guid id,
|
|
[FromBody] UpdateProjectCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
command = command with { Id = id };
|
|
var result = await _mediator.Send(command, cancellationToken);
|
|
return Ok(result);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Delete project
|
|
/// </summary>
|
|
[HttpDelete("{id:guid}")]
|
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
public async Task<IActionResult> DeleteProject(
|
|
Guid id,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var command = new DeleteProjectCommand(id);
|
|
await _mediator.Send(command, cancellationToken);
|
|
return NoContent();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get Kanban board for project
|
|
/// </summary>
|
|
[HttpGet("{id:guid}/kanban")]
|
|
[ProducesResponseType(typeof(KanbanBoardDto), StatusCodes.Status200OK)]
|
|
public async Task<IActionResult> GetKanbanBoard(
|
|
Guid id,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var query = new GetProjectKanbanQuery(id);
|
|
var result = await _mediator.Send(query, cancellationToken);
|
|
return Ok(result);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### 5.3 OpenAPI Configuration
|
|
|
|
```csharp
|
|
// Program.cs
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
// Add OpenAPI
|
|
builder.Services.AddOpenApi(options =>
|
|
{
|
|
options.AddDocumentTransformer((document, context, cancellationToken) =>
|
|
{
|
|
document.Info = new()
|
|
{
|
|
Title = "ColaFlow API",
|
|
Version = "v1",
|
|
Description = "AI-powered project management system API",
|
|
Contact = new() { Name = "ColaFlow Team", Email = "api@colaflow.com" }
|
|
};
|
|
return Task.CompletedTask;
|
|
});
|
|
});
|
|
|
|
var app = builder.Build();
|
|
|
|
if (app.Environment.IsDevelopment())
|
|
{
|
|
app.MapOpenApi();
|
|
app.MapScalarApiReference(); // Modern Swagger UI alternative
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 6. Security Architecture
|
|
|
|
### 6.1 Authentication & Authorization
|
|
|
|
#### JWT Token-based Authentication
|
|
|
|
```csharp
|
|
// Infrastructure/Identity/JwtTokenService.cs
|
|
public class JwtTokenService : ITokenService
|
|
{
|
|
private readonly JwtSettings _jwtSettings;
|
|
|
|
public string GenerateAccessToken(User user)
|
|
{
|
|
var claims = new[]
|
|
{
|
|
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
|
new Claim(ClaimTypes.Email, user.Email),
|
|
new Claim(ClaimTypes.Name, $"{user.FirstName} {user.LastName}"),
|
|
new Claim(ClaimTypes.Role, user.Role.ToString())
|
|
};
|
|
|
|
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSettings.SecretKey));
|
|
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
|
|
|
var token = new JwtSecurityToken(
|
|
issuer: _jwtSettings.Issuer,
|
|
audience: _jwtSettings.Audience,
|
|
claims: claims,
|
|
expires: DateTime.UtcNow.AddHours(_jwtSettings.ExpirationHours),
|
|
signingCredentials: credentials
|
|
);
|
|
|
|
return new JwtSecurityTokenHandler().WriteToken(token);
|
|
}
|
|
}
|
|
```
|
|
|
|
#### Authorization Policies
|
|
|
|
```csharp
|
|
// Program.cs
|
|
builder.Services.AddAuthorization(options =>
|
|
{
|
|
// Role-based policies
|
|
options.AddPolicy("Admin", policy => policy.RequireRole("Admin"));
|
|
options.AddPolicy("ProjectManager", policy => policy.RequireRole("Admin", "ProjectManager"));
|
|
|
|
// Resource-based policies
|
|
options.AddPolicy("ProjectOwner", policy =>
|
|
policy.Requirements.Add(new ProjectOwnerRequirement()));
|
|
|
|
options.AddPolicy("TaskAssignee", policy =>
|
|
policy.Requirements.Add(new TaskAssigneeRequirement()));
|
|
});
|
|
```
|
|
|
|
### 6.2 Data Security
|
|
|
|
- **Encryption at Rest**: PostgreSQL encryption
|
|
- **Encryption in Transit**: HTTPS/TLS 1.3
|
|
- **Password Hashing**: BCrypt with salt
|
|
- **SQL Injection Protection**: Parameterized queries (EF Core)
|
|
- **XSS Protection**: Input sanitization, CSP headers
|
|
- **CSRF Protection**: Anti-forgery tokens
|
|
|
|
---
|
|
|
|
## 7. Deployment Architecture
|
|
|
|
### 7.1 Containerization (Docker)
|
|
|
|
```dockerfile
|
|
# Dockerfile for Backend
|
|
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
|
|
WORKDIR /app
|
|
EXPOSE 80
|
|
EXPOSE 443
|
|
|
|
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
|
WORKDIR /src
|
|
COPY ["src/ColaFlow.API/ColaFlow.API.csproj", "ColaFlow.API/"]
|
|
COPY ["src/ColaFlow.Application/ColaFlow.Application.csproj", "ColaFlow.Application/"]
|
|
COPY ["src/ColaFlow.Domain/ColaFlow.Domain.csproj", "ColaFlow.Domain/"]
|
|
COPY ["src/ColaFlow.Infrastructure/ColaFlow.Infrastructure.csproj", "ColaFlow.Infrastructure/"]
|
|
RUN dotnet restore "ColaFlow.API/ColaFlow.API.csproj"
|
|
COPY src/ .
|
|
WORKDIR "/src/ColaFlow.API"
|
|
RUN dotnet build "ColaFlow.API.csproj" -c Release -o /app/build
|
|
|
|
FROM build AS publish
|
|
RUN dotnet publish "ColaFlow.API.csproj" -c Release -o /app/publish
|
|
|
|
FROM base AS final
|
|
WORKDIR /app
|
|
COPY --from=publish /app/publish .
|
|
ENTRYPOINT ["dotnet", "ColaFlow.API.dll"]
|
|
```
|
|
|
|
```dockerfile
|
|
# Dockerfile for Frontend
|
|
FROM node:20-alpine AS base
|
|
|
|
FROM base AS deps
|
|
WORKDIR /app
|
|
COPY package.json package-lock.json ./
|
|
RUN npm ci
|
|
|
|
FROM base AS builder
|
|
WORKDIR /app
|
|
COPY --from=deps /app/node_modules ./node_modules
|
|
COPY . .
|
|
RUN npm run build
|
|
|
|
FROM base AS runner
|
|
WORKDIR /app
|
|
ENV NODE_ENV production
|
|
COPY --from=builder /app/public ./public
|
|
COPY --from=builder /app/.next/standalone ./
|
|
COPY --from=builder /app/.next/static ./.next/static
|
|
|
|
EXPOSE 3000
|
|
ENV PORT 3000
|
|
CMD ["node", "server.js"]
|
|
```
|
|
|
|
### 7.2 Docker Compose (Development)
|
|
|
|
```yaml
|
|
# docker-compose.yml
|
|
version: '3.8'
|
|
|
|
services:
|
|
postgres:
|
|
image: postgres:16-alpine
|
|
environment:
|
|
POSTGRES_DB: colaflow
|
|
POSTGRES_USER: colaflow
|
|
POSTGRES_PASSWORD: colaflow_dev_password
|
|
ports:
|
|
- "5432:5432"
|
|
volumes:
|
|
- postgres_data:/var/lib/postgresql/data
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "pg_isready -U colaflow"]
|
|
interval: 10s
|
|
timeout: 5s
|
|
retries: 5
|
|
|
|
redis:
|
|
image: redis:7-alpine
|
|
ports:
|
|
- "6379:6379"
|
|
volumes:
|
|
- redis_data:/data
|
|
healthcheck:
|
|
test: ["CMD", "redis-cli", "ping"]
|
|
interval: 10s
|
|
timeout: 3s
|
|
retries: 5
|
|
|
|
backend:
|
|
build:
|
|
context: .
|
|
dockerfile: Dockerfile.backend
|
|
ports:
|
|
- "5000:80"
|
|
environment:
|
|
ASPNETCORE_ENVIRONMENT: Development
|
|
ConnectionStrings__DefaultConnection: Host=postgres;Database=colaflow;Username=colaflow;Password=colaflow_dev_password
|
|
ConnectionStrings__Redis: redis:6379
|
|
JwtSettings__SecretKey: your-secret-key-here-min-32-chars
|
|
depends_on:
|
|
postgres:
|
|
condition: service_healthy
|
|
redis:
|
|
condition: service_healthy
|
|
|
|
frontend:
|
|
build:
|
|
context: ./colaflow-web
|
|
dockerfile: Dockerfile
|
|
ports:
|
|
- "3000:3000"
|
|
environment:
|
|
NEXT_PUBLIC_API_URL: http://backend:80
|
|
depends_on:
|
|
- backend
|
|
|
|
volumes:
|
|
postgres_data:
|
|
redis_data:
|
|
```
|
|
|
|
---
|
|
|
|
## 8. Testing Strategy
|
|
|
|
### 8.1 Test Pyramid
|
|
|
|
```
|
|
Unit Tests (80%):
|
|
- Domain logic (aggregates, entities, value objects)
|
|
- Application services (commands, queries, handlers)
|
|
- Utilities and helpers
|
|
|
|
Integration Tests (15%):
|
|
- API endpoints
|
|
- Database operations
|
|
- External service integrations
|
|
|
|
End-to-End Tests (5%):
|
|
- Critical user flows
|
|
- Kanban drag-and-drop
|
|
- Authentication flows
|
|
```
|
|
|
|
### 8.2 Example Tests
|
|
|
|
```csharp
|
|
// Domain.Tests/Aggregates/ProjectTests.cs
|
|
public class ProjectTests
|
|
{
|
|
[Fact]
|
|
public void Create_ValidData_ShouldCreateProject()
|
|
{
|
|
// Arrange
|
|
var name = "Test Project";
|
|
var description = "Test Description";
|
|
var key = "TEST";
|
|
var ownerId = UserId.Create(Guid.NewGuid());
|
|
|
|
// Act
|
|
var project = Project.Create(name, description, key, ownerId);
|
|
|
|
// Assert
|
|
project.Should().NotBeNull();
|
|
project.Name.Should().Be(name);
|
|
project.Key.Value.Should().Be(key);
|
|
project.Status.Should().Be(ProjectStatus.Active);
|
|
project.DomainEvents.Should().ContainSingle(e => e is ProjectCreatedEvent);
|
|
}
|
|
|
|
[Fact]
|
|
public void Create_EmptyName_ShouldThrowException()
|
|
{
|
|
// Arrange
|
|
var name = "";
|
|
var key = "TEST";
|
|
var ownerId = UserId.Create(Guid.NewGuid());
|
|
|
|
// Act
|
|
Action act = () => Project.Create(name, "", key, ownerId);
|
|
|
|
// Assert
|
|
act.Should().Throw<DomainException>()
|
|
.WithMessage("Project name cannot be empty");
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 9. Performance Considerations
|
|
|
|
### 9.1 Caching Strategy
|
|
|
|
1. **Redis Caching**:
|
|
- User sessions (30 min)
|
|
- Project metadata (5 min)
|
|
- Kanban board data (2 min)
|
|
|
|
2. **EF Core Query Optimization**:
|
|
- `.AsNoTracking()` for read-only queries
|
|
- Explicit loading with `.Include()`
|
|
- Pagination for large datasets
|
|
|
|
3. **SignalR Backplane**:
|
|
- Redis for scaling SignalR across multiple servers
|
|
|
|
### 9.2 Database Indexing
|
|
|
|
- Index all foreign keys
|
|
- Index frequently queried columns (Status, CreatedAt, AssigneeId)
|
|
- JSONB GIN indexes for custom field queries
|
|
|
|
---
|
|
|
|
## 10. Risk Mitigation
|
|
|
|
| Risk | Impact | Mitigation |
|
|
|------|--------|-----------|
|
|
| **Complex DDD learning curve** | High | Use established templates (Ardalis, EquinoxProject), pair programming |
|
|
| **EF Core performance issues** | Medium | Use Dapper for complex queries, implement caching |
|
|
| **PostgreSQL migration challenges** | Medium | Careful schema design, automated migration testing |
|
|
| **Real-time performance (SignalR)** | Medium | Redis backplane, connection pooling, load testing |
|
|
| **Frontend state management complexity** | Low | Clear separation (TanStack Query + Zustand), documentation |
|
|
|
|
---
|
|
|
|
## 11. Success Criteria
|
|
|
|
### M1 Completion Criteria:
|
|
|
|
✅ Complete project hierarchy (Project → Epic → Story → Task → Subtask)
|
|
✅ Functional Kanban board with drag-and-drop
|
|
✅ Workflow system with customization
|
|
✅ Audit log and rollback capability
|
|
✅ All M1 stories complete and tested (80%+ coverage)
|
|
✅ API documented with OpenAPI
|
|
✅ Frontend responsive and accessible
|
|
✅ Real-time updates via SignalR
|
|
✅ Docker Compose development environment
|
|
✅ CI/CD pipeline ready
|
|
|
|
---
|
|
|
|
## 12. Next Steps
|
|
|
|
### Immediate Actions:
|
|
|
|
1. **Setup Development Environment**:
|
|
- Initialize .NET 9 solution with Clean Architecture template
|
|
- Setup PostgreSQL and Redis via Docker Compose
|
|
- Initialize Next.js 15 project with App Router
|
|
- Configure shadcn/ui and Tailwind CSS
|
|
|
|
2. **Sprint 1 Planning**:
|
|
- Review this architecture document with team
|
|
- Create initial database schema
|
|
- Setup CI/CD pipeline (GitHub Actions)
|
|
- Begin Project aggregate implementation
|
|
|
|
3. **Technical Proof of Concepts**:
|
|
- DDD + CQRS + Event Sourcing with MediatR
|
|
- EF Core with PostgreSQL JSONB
|
|
- SignalR real-time updates
|
|
- Next.js + TanStack Query + Zustand integration
|
|
|
|
---
|
|
|
|
## Appendix A: Technology Versions
|
|
|
|
| Technology | Version | Notes |
|
|
|------------|---------|-------|
|
|
| .NET | 9.0 | Released November 2024 |
|
|
| C# | 13.0 | With .NET 9 |
|
|
| Entity Framework Core | 9.0 | Native AOT support |
|
|
| PostgreSQL | 16+ | Latest stable |
|
|
| Redis | 7+ | Latest stable |
|
|
| Node.js | 20 LTS | For Next.js |
|
|
| React | 19 | Latest |
|
|
| Next.js | 15 | App Router |
|
|
| TypeScript | 5.x | Latest |
|
|
| Tailwind CSS | 3.x | Latest |
|
|
| shadcn/ui | Latest | Component library |
|
|
|
|
---
|
|
|
|
## Appendix B: References
|
|
|
|
- [Microsoft DDD Microservices](https://learn.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/)
|
|
- [Clean Architecture by Uncle Bob](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html)
|
|
- [Ardalis CleanArchitecture Template](https://github.com/ardalis/CleanArchitecture)
|
|
- [Next.js 15 Documentation](https://nextjs.org/docs)
|
|
- [PostgreSQL JSONB Documentation](https://www.postgresql.org/docs/current/datatype-json.html)
|
|
- [SignalR Documentation](https://learn.microsoft.com/en-us/aspnet/core/signalr/)
|
|
|
|
---
|
|
|
|
**Document Status:** ✅ Complete - Ready for Implementation
|
|
**Next Review:** After Sprint 1 completion
|
|
**Owner:** Architecture Team
|
|
**Last Updated:** 2025-11-02
|