41 KiB
Day 15-16 Implementation Roadmap
Date: 2025-11-05 至 2025-11-06 Focus: Epic/Story/Task Hierarchy Implementation Module: Issue Management Module (Enhancement) Architecture Decision: ADR-035
Overview
Day 15-16 will implement parent-child hierarchy support in the existing Issue Management Module, enabling Epic → Story → Task relationships without breaking existing functionality.
Key Principles:
- Enhance, don't replace
- Backward compatible
- Zero breaking changes
- Multi-tenant secure by default
- Performance first
Day 15: Database & Domain Layer (6-8 hours)
Morning Session (3-4 hours): Database Design
Task 1.1: Create Database Migration (1-1.5h)
Owner: Backend Developer
Steps:
// File: colaflow-api/src/Modules/IssueManagement/Infrastructure/Persistence/Migrations/
// 20251105_AddIssueHierarchy.cs
public partial class AddIssueHierarchy : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
// Add ParentIssueId column
migrationBuilder.AddColumn<Guid?>(
name: "parent_issue_id",
table: "issues",
type: "uuid",
nullable: true);
// Add foreign key constraint
migrationBuilder.AddForeignKey(
name: "fk_issues_parent",
table: "issues",
column: "parent_issue_id",
principalTable: "issues",
principalColumn: "id",
onDelete: ReferentialAction.SetNull); // Prevent cascade delete
// Add index for performance
migrationBuilder.CreateIndex(
name: "ix_issues_parent_issue_id",
table: "issues",
column: "parent_issue_id",
filter: "parent_issue_id IS NOT NULL"); // Partial index
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_issues_parent",
table: "issues");
migrationBuilder.DropIndex(
name: "ix_issues_parent_issue_id",
table: "issues");
migrationBuilder.DropColumn(
name: "parent_issue_id",
table: "issues");
}
}
Checklist:
- Create migration file
- Test migration:
dotnet ef migrations add AddIssueHierarchy - Apply to dev DB:
dotnet ef database update - Verify column added:
\d+ issuesin psql - Verify index created
- Test backward compatibility (existing queries still work)
Acceptance Criteria:
- Migration runs without errors
parent_issue_idcolumn is nullable (existing data unaffected)- Foreign key prevents invalid parent references
- Index improves query performance
Task 1.2: Update EF Core Configuration (0.5-1h)
Owner: Backend Developer
Steps:
// File: colaflow-api/src/Modules/IssueManagement/Infrastructure/Persistence/Configurations/IssueConfiguration.cs
public class IssueConfiguration : IEntityTypeConfiguration<Issue>
{
public void Configure(EntityTypeBuilder<Issue> builder)
{
// Existing configuration...
// NEW: Hierarchy configuration
builder.Property(i => i.ParentIssueId)
.HasColumnName("parent_issue_id")
.IsRequired(false);
builder.HasOne(i => i.ParentIssue)
.WithMany(i => i.ChildIssues)
.HasForeignKey(i => i.ParentIssueId)
.OnDelete(DeleteBehavior.SetNull) // When parent deleted, set child's parent to NULL
.IsRequired(false);
builder.HasMany(i => i.ChildIssues)
.WithOne(i => i.ParentIssue)
.HasForeignKey(i => i.ParentIssueId)
.OnDelete(DeleteBehavior.SetNull);
}
}
Checklist:
- Update
IssueConfiguration.cs - Verify EF Core generates correct SQL
- Test lazy loading disabled (no N+1 queries)
- Test eager loading works:
.Include(i => i.ChildIssues)
Acceptance Criteria:
- EF Core configuration matches database schema
- Navigation properties work correctly
- Delete behavior is SetNull (not Cascade)
Afternoon Session (3-4 hours): Domain Logic
Task 1.3: Update Issue Entity (2-3h)
Owner: Backend Developer
Steps:
// File: colaflow-api/src/Modules/IssueManagement/Domain/Entities/Issue.cs
public class Issue : TenantEntity, IAggregateRoot
{
// Existing properties...
public IssueType Type { get; private set; }
public string Title { get; private set; }
public IssueStatus Status { get; private set; }
// NEW: Hierarchy support
public Guid? ParentIssueId { get; private set; }
public virtual Issue? ParentIssue { get; private set; }
public virtual ICollection<Issue> ChildIssues { get; private set; } = new List<Issue>();
// NEW: Hierarchy methods
public Result SetParent(Issue parent)
{
// Validation 1: Same tenant
if (parent.TenantId != this.TenantId)
return Result.Failure("Cannot link issues across tenants");
// Validation 2: Valid hierarchy rules
if (!IsValidHierarchy(parent))
return Result.Failure($"{parent.Type} cannot be parent of {this.Type}");
// Validation 3: Prevent circular dependency
if (WouldCreateCircularDependency(parent))
return Result.Failure("Circular dependency detected");
// Validation 4: Depth limit
if (parent.GetDepth() >= 2) // Max 3 levels (0, 1, 2)
return Result.Failure("Maximum hierarchy depth exceeded (3 levels)");
// Set parent
ParentIssueId = parent.Id;
ParentIssue = parent;
// Raise domain event
AddDomainEvent(new IssueHierarchyChangedEvent(
issueId: this.Id,
newParentId: parent.Id,
oldParentId: null
));
return Result.Success();
}
public Result RemoveParent()
{
if (!ParentIssueId.HasValue)
return Result.Failure("Issue does not have a parent");
var oldParentId = ParentIssueId.Value;
ParentIssueId = null;
ParentIssue = null;
// Raise domain event
AddDomainEvent(new IssueHierarchyChangedEvent(
issueId: this.Id,
newParentId: null,
oldParentId: oldParentId
));
return Result.Success();
}
// NEW: Validation rules
private bool IsValidHierarchy(Issue parent)
{
return (parent.Type, this.Type) switch
{
(IssueType.Epic, IssueType.Story) => true,
(IssueType.Story, IssueType.Task) => true,
(IssueType.Story, IssueType.Bug) => true,
_ => false
};
}
private bool WouldCreateCircularDependency(Issue proposedParent)
{
var current = proposedParent;
var visitedIds = new HashSet<Guid> { this.Id };
int depth = 0;
int maxDepth = 10; // Safety limit
while (current != null && depth < maxDepth)
{
if (visitedIds.Contains(current.Id))
return true; // Circular dependency detected
visitedIds.Add(current.Id);
current = current.ParentIssue;
depth++;
}
return false;
}
public int GetDepth()
{
int depth = 0;
var current = this.ParentIssue;
while (current != null && depth < 10)
{
depth++;
current = current.ParentIssue;
}
return depth;
}
public List<Guid> GetAncestorIds()
{
var ancestors = new List<Guid>();
var current = this.ParentIssue;
while (current != null && ancestors.Count < 10)
{
ancestors.Add(current.Id);
current = current.ParentIssue;
}
return ancestors;
}
}
Checklist:
- Add hierarchy properties (ParentIssueId, ParentIssue, ChildIssues)
- Implement
SetParentmethod with 4 validations - Implement
RemoveParentmethod - Add
IsValidHierarchyvalidation logic - Add
WouldCreateCircularDependencycheck - Add
GetDepthmethod - Add
GetAncestorIdsmethod - Ensure domain events are raised
Acceptance Criteria:
- All hierarchy rules enforced (Epic → Story → Task)
- Circular dependency prevented
- Max depth 3 levels enforced
- Tenant isolation maintained
- Domain events raised on hierarchy changes
Task 1.4: Create Domain Events (0.5-1h)
Owner: Backend Developer
Steps:
// File: colaflow-api/src/Modules/IssueManagement/Domain/Events/IssueHierarchyChangedEvent.cs
public class IssueHierarchyChangedEvent : DomainEvent
{
public Guid IssueId { get; }
public Guid? NewParentId { get; }
public Guid? OldParentId { get; }
public IssueHierarchyChangedEvent(
Guid issueId,
Guid? newParentId,
Guid? oldParentId)
{
IssueId = issueId;
NewParentId = newParentId;
OldParentId = oldParentId;
}
}
Checklist:
- Create
IssueHierarchyChangedEvent.cs - Add event handler (if needed for future use)
- Test event is raised when parent set/removed
Acceptance Criteria:
- Event is raised on hierarchy changes
- Event contains correct issueId, newParentId, oldParentId
Task 1.5: Unit Tests for Domain Logic (1-1.5h)
Owner: Backend Developer
Steps:
// File: colaflow-api/tests/Modules/IssueManagement.Domain.Tests/IssueHierarchyTests.cs
public class IssueHierarchyTests
{
[Fact]
public void SetParent_ValidEpicToStory_ShouldSucceed()
{
// Arrange
var epic = CreateIssue(IssueType.Epic);
var story = CreateIssue(IssueType.Story);
// Act
var result = story.SetParent(epic);
// Assert
result.IsSuccess.Should().BeTrue();
story.ParentIssueId.Should().Be(epic.Id);
}
[Fact]
public void SetParent_InvalidTaskToEpic_ShouldFail()
{
// Arrange
var epic = CreateIssue(IssueType.Epic);
var task = CreateIssue(IssueType.Task);
// Act
var result = task.SetParent(epic);
// Assert
result.IsSuccess.Should().BeFalse();
result.Error.Should().Contain("cannot be parent");
}
[Fact]
public void SetParent_CircularDependency_ShouldFail()
{
// Arrange
var epic = CreateIssue(IssueType.Epic);
var story = CreateIssue(IssueType.Story);
story.SetParent(epic);
// Act - Try to set epic's parent as story (circular)
var result = epic.SetParent(story);
// Assert
result.IsSuccess.Should().BeFalse();
result.Error.Should().Contain("Circular dependency");
}
[Fact]
public void SetParent_CrossTenant_ShouldFail()
{
// Arrange
var epicTenant1 = CreateIssue(IssueType.Epic, tenantId: Guid.NewGuid());
var storyTenant2 = CreateIssue(IssueType.Story, tenantId: Guid.NewGuid());
// Act
var result = storyTenant2.SetParent(epicTenant1);
// Assert
result.IsSuccess.Should().BeFalse();
result.Error.Should().Contain("across tenants");
}
[Fact]
public void SetParent_MaxDepthExceeded_ShouldFail()
{
// Arrange
var epic = CreateIssue(IssueType.Epic);
var story1 = CreateIssue(IssueType.Story);
var story2 = CreateIssue(IssueType.Story);
var task = CreateIssue(IssueType.Task);
story1.SetParent(epic); // Depth 1
story2.SetParent(story1); // Depth 2
task.SetParent(story2); // Depth 3
// Act - Try to add one more level (would be depth 4)
var extraTask = CreateIssue(IssueType.Task);
var result = extraTask.SetParent(task);
// Assert
result.IsSuccess.Should().BeFalse();
result.Error.Should().Contain("Maximum hierarchy depth");
}
[Fact]
public void GetDepth_ThreeLevels_ShouldReturnTwo()
{
// Arrange
var epic = CreateIssue(IssueType.Epic); // Depth 0
var story = CreateIssue(IssueType.Story);
var task = CreateIssue(IssueType.Task);
story.SetParent(epic); // Depth 1
task.SetParent(story); // Depth 2
// Act
var depth = task.GetDepth();
// Assert
depth.Should().Be(2);
}
[Fact]
public void RemoveParent_HasParent_ShouldSucceed()
{
// Arrange
var epic = CreateIssue(IssueType.Epic);
var story = CreateIssue(IssueType.Story);
story.SetParent(epic);
// Act
var result = story.RemoveParent();
// Assert
result.IsSuccess.Should().BeTrue();
story.ParentIssueId.Should().BeNull();
}
[Fact]
public void SetParent_RaisesHierarchyChangedEvent()
{
// Arrange
var epic = CreateIssue(IssueType.Epic);
var story = CreateIssue(IssueType.Story);
// Act
story.SetParent(epic);
// Assert
var domainEvent = story.DomainEvents
.OfType<IssueHierarchyChangedEvent>()
.FirstOrDefault();
domainEvent.Should().NotBeNull();
domainEvent.IssueId.Should().Be(story.Id);
domainEvent.NewParentId.Should().Be(epic.Id);
}
private Issue CreateIssue(
IssueType type,
Guid? tenantId = null)
{
return Issue.Create(
tenantId: tenantId ?? Guid.NewGuid(),
projectId: Guid.NewGuid(),
type: type,
title: $"Test {type}",
description: "Test description",
priority: IssuePriority.Medium,
createdBy: Guid.NewGuid()
);
}
}
Checklist:
- Test valid hierarchy: Epic → Story, Story → Task
- Test invalid hierarchy: Task → Story, Epic → Task
- Test circular dependency prevention
- Test cross-tenant rejection
- Test max depth enforcement (3 levels)
- Test
GetDepthmethod - Test
RemoveParentmethod - Test domain events raised
- All 10+ tests passing
Acceptance Criteria:
- 100% code coverage for hierarchy logic
- All edge cases tested
- Tests run in < 1 second
Day 16: Application & API Layer (6-8 hours)
Morning Session (3-4 hours): Commands & Queries
Task 2.1: Create Commands (1.5-2h)
Owner: Backend Developer
Files to Create:
1. AddChildIssueCommand.cs
// File: colaflow-api/src/Modules/IssueManagement/Application/Commands/AddChildIssueCommand.cs
public record AddChildIssueCommand(
Guid ParentIssueId,
Guid ChildIssueId
) : IRequest<Result<IssueDto>>;
public class AddChildIssueCommandHandler : IRequestHandler<AddChildIssueCommand, Result<IssueDto>>
{
private readonly IIssueRepository _issueRepository;
private readonly ITenantContextAccessor _tenantContext;
private readonly IMapper _mapper;
public AddChildIssueCommandHandler(
IIssueRepository issueRepository,
ITenantContextAccessor tenantContext,
IMapper mapper)
{
_issueRepository = issueRepository;
_tenantContext = tenantContext;
_mapper = mapper;
}
public async Task<Result<IssueDto>> Handle(
AddChildIssueCommand request,
CancellationToken cancellationToken)
{
var tenantId = _tenantContext.GetTenantId();
// Get parent issue (with tenant filter)
var parent = await _issueRepository.GetByIdAsync(
request.ParentIssueId,
cancellationToken);
if (parent == null)
return Result<IssueDto>.Failure("Parent issue not found");
// Get child issue (with tenant filter)
var child = await _issueRepository.GetByIdAsync(
request.ChildIssueId,
cancellationToken);
if (child == null)
return Result<IssueDto>.Failure("Child issue not found");
// Set parent (domain logic validates)
var result = child.SetParent(parent);
if (!result.IsSuccess)
return Result<IssueDto>.Failure(result.Error);
// Save changes
await _issueRepository.UpdateAsync(child, cancellationToken);
await _issueRepository.UnitOfWork.SaveChangesAsync(cancellationToken);
// Return updated child issue
var dto = _mapper.Map<IssueDto>(child);
return Result<IssueDto>.Success(dto);
}
}
2. RemoveChildIssueCommand.cs
// File: colaflow-api/src/Modules/IssueManagement/Application/Commands/RemoveChildIssueCommand.cs
public record RemoveChildIssueCommand(
Guid IssueId
) : IRequest<Result>;
public class RemoveChildIssueCommandHandler : IRequestHandler<RemoveChildIssueCommand, Result>
{
private readonly IIssueRepository _issueRepository;
private readonly ITenantContextAccessor _tenantContext;
public RemoveChildIssueCommandHandler(
IIssueRepository issueRepository,
ITenantContextAccessor tenantContext)
{
_issueRepository = issueRepository;
_tenantContext = tenantContext;
}
public async Task<Result> Handle(
RemoveChildIssueCommand request,
CancellationToken cancellationToken)
{
var tenantId = _tenantContext.GetTenantId();
// Get issue (with tenant filter)
var issue = await _issueRepository.GetByIdAsync(
request.IssueId,
cancellationToken);
if (issue == null)
return Result.Failure("Issue not found");
// Remove parent
var result = issue.RemoveParent();
if (!result.IsSuccess)
return result;
// Save changes
await _issueRepository.UpdateAsync(issue, cancellationToken);
await _issueRepository.UnitOfWork.SaveChangesAsync(cancellationToken);
return Result.Success();
}
}
Checklist:
- Create
AddChildIssueCommand.cs+ handler - Create
RemoveChildIssueCommand.cs+ handler - Add tenant context validation
- Add authorization checks (future: role-based)
- Add FluentValidation rules
- Test command handlers (unit tests)
Acceptance Criteria:
- Commands follow CQRS pattern
- Tenant isolation enforced in handlers
- Domain validation errors propagated correctly
- Unit tests passing
Task 2.2: Create Queries (1-1.5h)
Owner: Backend Developer
Files to Create:
1. GetIssueHierarchyQuery.cs
// File: colaflow-api/src/Modules/IssueManagement/Application/Queries/GetIssueHierarchyQuery.cs
public record GetIssueHierarchyQuery(
Guid IssueId
) : IRequest<Result<IssueHierarchyDto>>;
public class GetIssueHierarchyQueryHandler : IRequestHandler<GetIssueHierarchyQuery, Result<IssueHierarchyDto>>
{
private readonly IIssueRepository _issueRepository;
private readonly ITenantContextAccessor _tenantContext;
private readonly IMapper _mapper;
public GetIssueHierarchyQueryHandler(
IIssueRepository issueRepository,
ITenantContextAccessor tenantContext,
IMapper mapper)
{
_issueRepository = issueRepository;
_tenantContext = tenantContext;
_mapper = mapper;
}
public async Task<Result<IssueHierarchyDto>> Handle(
GetIssueHierarchyQuery request,
CancellationToken cancellationToken)
{
var tenantId = _tenantContext.GetTenantId();
// Get issue with full hierarchy (using CTE for performance)
var hierarchy = await _issueRepository.GetHierarchyAsync(
request.IssueId,
cancellationToken);
if (hierarchy == null)
return Result<IssueHierarchyDto>.Failure("Issue not found");
// Map to DTO
var dto = _mapper.Map<IssueHierarchyDto>(hierarchy);
return Result<IssueHierarchyDto>.Success(dto);
}
}
// DTO
public class IssueHierarchyDto
{
public Guid Id { get; set; }
public string Title { get; set; }
public IssueType Type { get; set; }
public IssueStatus Status { get; set; }
public int Depth { get; set; }
public List<IssueHierarchyDto> Children { get; set; } = new();
}
2. GetChildIssuesQuery.cs
// File: colaflow-api/src/Modules/IssueManagement/Application/Queries/GetChildIssuesQuery.cs
public record GetChildIssuesQuery(
Guid ParentIssueId
) : IRequest<Result<List<IssueDto>>>;
public class GetChildIssuesQueryHandler : IRequestHandler<GetChildIssuesQuery, Result<List<IssueDto>>>
{
private readonly IIssueRepository _issueRepository;
private readonly ITenantContextAccessor _tenantContext;
private readonly IMapper _mapper;
public GetChildIssuesQueryHandler(
IIssueRepository issueRepository,
ITenantContextAccessor tenantContext,
IMapper mapper)
{
_issueRepository = issueRepository;
_tenantContext = tenantContext;
_mapper = mapper;
}
public async Task<Result<List<IssueDto>>> Handle(
GetChildIssuesQuery request,
CancellationToken cancellationToken)
{
var tenantId = _tenantContext.GetTenantId();
// Get children (single level)
var children = await _issueRepository.GetChildrenAsync(
request.ParentIssueId,
cancellationToken);
// Map to DTOs
var dtos = _mapper.Map<List<IssueDto>>(children);
return Result<List<IssueDto>>.Success(dtos);
}
}
Checklist:
- Create
GetIssueHierarchyQuery.cs+ handler - Create
GetChildIssuesQuery.cs+ handler - Implement repository methods (GetHierarchyAsync, GetChildrenAsync)
- Use PostgreSQL CTE for recursive queries
- Add query performance tests (< 50ms for 100+ issues)
- Test tenant isolation in queries
Acceptance Criteria:
- Queries return correct hierarchy data
- Performance < 50ms for 100+ issues
- Tenant isolation enforced
- No N+1 query problems
Afternoon Session (3-4 hours): API Endpoints
Task 2.3: Add API Endpoints (1.5-2h)
Owner: Backend Developer
File to Update:
// File: colaflow-api/src/Modules/IssueManagement/Api/Controllers/IssuesController.cs
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class IssuesController : ControllerBase
{
private readonly IMediator _mediator;
public IssuesController(IMediator mediator)
{
_mediator = mediator;
}
// Existing endpoints...
/// <summary>
/// Add a child issue to a parent issue
/// </summary>
[HttpPost("{parentId}/add-child")]
[ProducesResponseType(typeof(IssueDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> AddChildIssue(
[FromRoute] Guid parentId,
[FromBody] AddChildIssueRequest request)
{
var command = new AddChildIssueCommand(parentId, request.ChildIssueId);
var result = await _mediator.Send(command);
if (!result.IsSuccess)
return BadRequest(result.Error);
return Ok(result.Value);
}
/// <summary>
/// Remove parent from an issue
/// </summary>
[HttpDelete("{issueId}/remove-parent")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> RemoveParent(
[FromRoute] Guid issueId)
{
var command = new RemoveChildIssueCommand(issueId);
var result = await _mediator.Send(command);
if (!result.IsSuccess)
return BadRequest(result.Error);
return NoContent();
}
/// <summary>
/// Get issue hierarchy (full tree)
/// </summary>
[HttpGet("{issueId}/hierarchy")]
[ProducesResponseType(typeof(IssueHierarchyDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetHierarchy(
[FromRoute] Guid issueId)
{
var query = new GetIssueHierarchyQuery(issueId);
var result = await _mediator.Send(query);
if (!result.IsSuccess)
return NotFound(result.Error);
return Ok(result.Value);
}
/// <summary>
/// Get direct children of an issue
/// </summary>
[HttpGet("{issueId}/children")]
[ProducesResponseType(typeof(List<IssueDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetChildren(
[FromRoute] Guid issueId)
{
var query = new GetChildIssuesQuery(issueId);
var result = await _mediator.Send(query);
return Ok(result.Value);
}
}
// Request DTO
public class AddChildIssueRequest
{
public Guid ChildIssueId { get; set; }
}
Checklist:
- Add
POST /api/issues/{parentId}/add-childendpoint - Add
DELETE /api/issues/{issueId}/remove-parentendpoint - Add
GET /api/issues/{issueId}/hierarchyendpoint - Add
GET /api/issues/{issueId}/childrenendpoint - Add Swagger documentation (XML comments)
- Add authorization attributes
- Test endpoints with Postman/cURL
Acceptance Criteria:
- All 4 new endpoints working
- Swagger documentation complete
- Authorization enforced
- Proper HTTP status codes (200, 400, 404)
Task 2.4: Implement Repository Methods (1-1.5h)
Owner: Backend Developer
File to Update:
// File: colaflow-api/src/Modules/IssueManagement/Infrastructure/Persistence/Repositories/IssueRepository.cs
public class IssueRepository : IIssueRepository
{
private readonly IssueManagementDbContext _context;
public IssueRepository(IssueManagementDbContext context)
{
_context = context;
}
// Existing methods...
public async Task<IssueHierarchy?> GetHierarchyAsync(
Guid issueId,
CancellationToken cancellationToken = default)
{
// Use PostgreSQL CTE for recursive query
var sql = @"
WITH RECURSIVE hierarchy AS (
-- Base case: Root issue
SELECT
id,
tenant_id,
parent_issue_id,
title,
type,
status,
priority,
0 AS depth
FROM issues
WHERE id = @issueId
AND tenant_id = @tenantId
UNION ALL
-- Recursive case: Children
SELECT
i.id,
i.tenant_id,
i.parent_issue_id,
i.title,
i.type,
i.status,
i.priority,
h.depth + 1
FROM issues i
INNER JOIN hierarchy h ON i.parent_issue_id = h.id
WHERE i.tenant_id = @tenantId
AND h.depth < 3 -- Max depth limit
)
SELECT * FROM hierarchy
ORDER BY depth, title;
";
var tenantId = _context.TenantId; // From DbContext
var issues = await _context.Issues
.FromSqlRaw(sql, new { issueId, tenantId })
.ToListAsync(cancellationToken);
if (!issues.Any())
return null;
// Build hierarchy tree
return BuildHierarchyTree(issues);
}
public async Task<List<Issue>> GetChildrenAsync(
Guid parentIssueId,
CancellationToken cancellationToken = default)
{
return await _context.Issues
.Where(i => i.ParentIssueId == parentIssueId)
.OrderBy(i => i.CreatedAt)
.ToListAsync(cancellationToken);
}
private IssueHierarchy BuildHierarchyTree(List<Issue> flatList)
{
var lookup = flatList.ToDictionary(i => i.Id);
var root = flatList.First(i => i.Depth == 0);
foreach (var issue in flatList.Where(i => i.ParentIssueId.HasValue))
{
if (lookup.TryGetValue(issue.ParentIssueId.Value, out var parent))
{
parent.ChildIssues.Add(issue);
}
}
return new IssueHierarchy
{
RootIssue = root,
AllIssues = flatList,
TotalCount = flatList.Count
};
}
}
// Interface
public interface IIssueRepository : IRepository<Issue>
{
// Existing methods...
Task<IssueHierarchy?> GetHierarchyAsync(Guid issueId, CancellationToken cancellationToken = default);
Task<List<Issue>> GetChildrenAsync(Guid parentIssueId, CancellationToken cancellationToken = default);
}
// Helper class
public class IssueHierarchy
{
public Issue RootIssue { get; set; }
public List<Issue> AllIssues { get; set; }
public int TotalCount { get; set; }
}
Checklist:
- Implement
GetHierarchyAsyncusing PostgreSQL CTE - Implement
GetChildrenAsyncmethod - Add
BuildHierarchyTreehelper method - Test query performance (< 50ms)
- Test tenant isolation in raw SQL
- Test with 100+ issues in hierarchy
Acceptance Criteria:
- CTE query returns correct hierarchy
- Performance < 50ms for 100+ issues
- Tenant filter applied correctly
- Max depth limit enforced (3 levels)
Task 2.5: Integration Tests (1.5-2h)
Owner: Backend Developer + QA
File to Create:
// File: colaflow-api/tests/Modules/IssueManagement.IntegrationTests/IssueHierarchyIntegrationTests.cs
public class IssueHierarchyIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
private readonly IServiceScope _scope;
public IssueHierarchyIntegrationTests(WebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
_scope = factory.Services.CreateScope();
}
[Fact]
public async Task AddChildIssue_ValidHierarchy_ShouldSucceed()
{
// Arrange
var epic = await CreateIssueAsync(IssueType.Epic, "Epic 1");
var story = await CreateIssueAsync(IssueType.Story, "Story 1");
var request = new AddChildIssueRequest { ChildIssueId = story.Id };
// Act
var response = await _client.PostAsJsonAsync(
$"/api/issues/{epic.Id}/add-child",
request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var updatedStory = await response.Content.ReadFromJsonAsync<IssueDto>();
updatedStory.ParentIssueId.Should().Be(epic.Id);
}
[Fact]
public async Task AddChildIssue_InvalidHierarchy_ShouldReturn400()
{
// Arrange
var task = await CreateIssueAsync(IssueType.Task, "Task 1");
var epic = await CreateIssueAsync(IssueType.Epic, "Epic 1");
var request = new AddChildIssueRequest { ChildIssueId = epic.Id };
// Act - Try to set Task as parent of Epic (invalid)
var response = await _client.PostAsJsonAsync(
$"/api/issues/{task.Id}/add-child",
request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task AddChildIssue_CircularDependency_ShouldReturn400()
{
// Arrange
var epic = await CreateIssueAsync(IssueType.Epic, "Epic 1");
var story = await CreateIssueAsync(IssueType.Story, "Story 1");
await _client.PostAsJsonAsync(
$"/api/issues/{epic.Id}/add-child",
new { ChildIssueId = story.Id });
// Act - Try to set Epic's parent as Story (circular)
var response = await _client.PostAsJsonAsync(
$"/api/issues/{story.Id}/add-child",
new { ChildIssueId = epic.Id });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task AddChildIssue_CrossTenant_ShouldReturn404()
{
// Arrange
var epicTenant1 = await CreateIssueAsync(IssueType.Epic, "Epic T1", tenantId: Guid.NewGuid());
// Switch to tenant 2
SetTenantContext(Guid.NewGuid());
var storyTenant2 = await CreateIssueAsync(IssueType.Story, "Story T2");
// Act - Try to link across tenants
var response = await _client.PostAsJsonAsync(
$"/api/issues/{epicTenant1.Id}/add-child",
new { ChildIssueId = storyTenant2.Id });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound); // Parent not found in tenant 2
}
[Fact]
public async Task GetHierarchy_ThreeLevels_ShouldReturnFullTree()
{
// Arrange
var epic = await CreateIssueAsync(IssueType.Epic, "Epic 1");
var story = await CreateIssueAsync(IssueType.Story, "Story 1");
var task = await CreateIssueAsync(IssueType.Task, "Task 1");
await AddChildAsync(epic.Id, story.Id);
await AddChildAsync(story.Id, task.Id);
// Act
var response = await _client.GetAsync($"/api/issues/{epic.Id}/hierarchy");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var hierarchy = await response.Content.ReadFromJsonAsync<IssueHierarchyDto>();
hierarchy.Id.Should().Be(epic.Id);
hierarchy.Children.Should().HaveCount(1);
hierarchy.Children[0].Id.Should().Be(story.Id);
hierarchy.Children[0].Children.Should().HaveCount(1);
hierarchy.Children[0].Children[0].Id.Should().Be(task.Id);
}
[Fact]
public async Task GetChildren_ParentWithTwoChildren_ShouldReturnBoth()
{
// Arrange
var epic = await CreateIssueAsync(IssueType.Epic, "Epic 1");
var story1 = await CreateIssueAsync(IssueType.Story, "Story 1");
var story2 = await CreateIssueAsync(IssueType.Story, "Story 2");
await AddChildAsync(epic.Id, story1.Id);
await AddChildAsync(epic.Id, story2.Id);
// Act
var response = await _client.GetAsync($"/api/issues/{epic.Id}/children");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var children = await response.Content.ReadFromJsonAsync<List<IssueDto>>();
children.Should().HaveCount(2);
children.Should().Contain(c => c.Id == story1.Id);
children.Should().Contain(c => c.Id == story2.Id);
}
[Fact]
public async Task RemoveParent_HasParent_ShouldSucceed()
{
// Arrange
var epic = await CreateIssueAsync(IssueType.Epic, "Epic 1");
var story = await CreateIssueAsync(IssueType.Story, "Story 1");
await AddChildAsync(epic.Id, story.Id);
// Act
var response = await _client.DeleteAsync($"/api/issues/{story.Id}/remove-parent");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
// Verify parent removed
var children = await GetChildrenAsync(epic.Id);
children.Should().BeEmpty();
}
[Fact]
public async Task HierarchyQuery_Performance_ShouldBeLessThan50ms()
{
// Arrange - Create large hierarchy (100+ issues)
var epic = await CreateIssueAsync(IssueType.Epic, "Epic 1");
for (int i = 0; i < 10; i++)
{
var story = await CreateIssueAsync(IssueType.Story, $"Story {i}");
await AddChildAsync(epic.Id, story.Id);
for (int j = 0; j < 10; j++)
{
var task = await CreateIssueAsync(IssueType.Task, $"Task {i}-{j}");
await AddChildAsync(story.Id, task.Id);
}
}
// Act
var stopwatch = Stopwatch.StartNew();
var response = await _client.GetAsync($"/api/issues/{epic.Id}/hierarchy");
stopwatch.Stop();
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
stopwatch.ElapsedMilliseconds.Should().BeLessThan(50); // Performance requirement
}
// Helper methods...
private async Task<IssueDto> CreateIssueAsync(IssueType type, string title, Guid? tenantId = null) { /* ... */ }
private async Task AddChildAsync(Guid parentId, Guid childId) { /* ... */ }
private async Task<List<IssueDto>> GetChildrenAsync(Guid parentId) { /* ... */ }
private void SetTenantContext(Guid tenantId) { /* ... */ }
}
Checklist:
- Test valid hierarchy (Epic → Story → Task)
- Test invalid hierarchy (Task → Epic)
- Test circular dependency prevention
- Test cross-tenant isolation
- Test GetHierarchy endpoint
- Test GetChildren endpoint
- Test RemoveParent endpoint
- Test performance (< 50ms for 100+ issues)
- All 10+ integration tests passing
Acceptance Criteria:
- All integration tests pass (10+/10+)
- Performance tests pass (< 50ms)
- Multi-tenant isolation verified
- No N+1 query issues
Day 16 Evening: Frontend Integration (Optional, 2-3h)
Task 3.1: Update Kanban Board (1-1.5h)
Owner: Frontend Developer
Files to Update:
// File: colaflow-frontend/src/components/Kanban/IssueCard.tsx
interface IssueCardProps {
issue: Issue;
onUpdate: (issue: Issue) => void;
}
export const IssueCard: React.FC<IssueCardProps> = ({ issue, onUpdate }) => {
// Existing code...
// NEW: Show parent issue breadcrumb
const renderParentBreadcrumb = () => {
if (!issue.parentIssue) return null;
return (
<div className="text-xs text-gray-500 mb-2">
<Link to={`/issues/${issue.parentIssue.id}`}>
<IconType type={issue.parentIssue.type} />
{issue.parentIssue.title}
</Link>
</div>
);
};
// NEW: Show child count
const renderChildCount = () => {
if (issue.childCount === 0) return null;
return (
<div className="text-xs text-blue-600 mt-2">
<IconChildIssues />
{issue.childCount} subtasks
</div>
);
};
return (
<div className="issue-card">
{renderParentBreadcrumb()}
<h3>{issue.title}</h3>
<p>{issue.description}</p>
{renderChildCount()}
{/* Existing code... */}
</div>
);
};
Checklist:
- Display parent issue breadcrumb on cards
- Display child issue count
- Add "Create Child Issue" button
- Test UI updates with real data
Acceptance Criteria:
- Kanban board shows hierarchy information
- UI is responsive and intuitive
- No performance degradation
Success Criteria (Day 15-16)
Functional Requirements
- Can create Epic → Story → Task hierarchy
- Can add/remove parent-child relationships via API
- Can query full hierarchy tree
- Hierarchy rules enforced (Epic → Story → Task only)
- Circular dependency prevention works
- Max depth 3 levels enforced
Non-Functional Requirements
- Query performance < 50ms (100+ issues)
- Multi-tenant isolation 100% verified
- Backward compatible (no breaking changes)
- Integration tests pass rate ≥ 95% (10+/10+)
- API response time < 100ms
Documentation Requirements
- API documentation updated (Swagger)
- Database schema documented
- ADR-035 architecture decision recorded
- Implementation notes for future reference
Risk Mitigation
Risk 1: Performance Issues
Mitigation: Use PostgreSQL CTE for recursive queries, add index on parent_issue_id
Risk 2: Multi-Tenant Security
Mitigation: All queries filtered by TenantId, integration tests verify isolation
Risk 3: Breaking Changes
Mitigation: ParentIssueId is nullable, existing queries unaffected
Risk 4: Circular Dependencies
Mitigation: Domain logic validates before save, integration tests verify
Testing Checklist
Unit Tests (Day 15)
- Domain logic: 10+ test cases
- All edge cases covered
- 100% code coverage for hierarchy logic
Integration Tests (Day 16)
- Valid hierarchy: Epic → Story → Task
- Invalid hierarchy: Task → Epic (rejected)
- Circular dependency (rejected)
- Cross-tenant (rejected)
- GetHierarchy endpoint
- GetChildren endpoint
- RemoveParent endpoint
- Performance: < 50ms for 100+ issues
- Multi-tenant isolation verified
Manual Testing (Day 16 Evening)
- Postman: All 4 new endpoints work
- Frontend: Kanban shows hierarchy
- Frontend: Create child issue works
- Frontend: Real-time updates work (SignalR)
Delivery Checklist
Code Artifacts
- Database migration file
- Issue entity updated
- 2 new commands (AddChild, RemoveParent)
- 2 new queries (GetHierarchy, GetChildren)
- 4 new API endpoints
- Repository methods (CTE queries)
- 10+ unit tests
- 10+ integration tests
- Frontend updates (optional)
Documentation
- API documentation (Swagger)
- ADR-035 architecture decision
- M1_REMAINING_TASKS.md updated
- Database schema documented
- Performance test results
Git Commits
- Day 15 AM: Database migration + EF Core config
- Day 15 PM: Domain logic + unit tests
- Day 16 AM: Commands + queries
- Day 16 PM: API endpoints + integration tests
- Day 16 Evening: Frontend integration (optional)
Next Steps (Day 17)
If On Schedule
- Start Audit Log System Phase 1 (Day 17-23)
- Run full regression test suite
- Code review for hierarchy feature
If Behind Schedule
- Focus on P0 features only (skip frontend integration)
- Defer performance optimization to Day 17
- Request additional development time
Contact & Escalation
Technical Questions: Backend Agent, Architect Agent Requirements Clarification: Product Manager Agent Testing Issues: QA Agent Progress Updates: Main Coordinator Agent
Document Version: 1.0 Last Updated: 2025-11-04 Next Review: End of Day 16 (2025-11-06)