Files
ColaFlow/docs/plans/DAY-15-16-IMPLEMENTATION-ROADMAP.md
Yaojia Wang 08b317e789
Some checks failed
Code Coverage / Generate Coverage Report (push) Has been cancelled
Tests / Run Tests (9.0.x) (push) Has been cancelled
Tests / Docker Build Test (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Add trace files.
2025-11-04 23:28:56 +01:00

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+ issues in psql
  • Verify index created
  • Test backward compatibility (existing queries still work)

Acceptance Criteria:

  • Migration runs without errors
  • parent_issue_id column 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 SetParent method with 4 validations
  • Implement RemoveParent method
  • Add IsValidHierarchy validation logic
  • Add WouldCreateCircularDependency check
  • Add GetDepth method
  • Add GetAncestorIds method
  • 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 GetDepth method
  • Test RemoveParent method
  • 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-child endpoint
  • Add DELETE /api/issues/{issueId}/remove-parent endpoint
  • Add GET /api/issues/{issueId}/hierarchy endpoint
  • Add GET /api/issues/{issueId}/children endpoint
  • 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 GetHierarchyAsync using PostgreSQL CTE
  • Implement GetChildrenAsync method
  • Add BuildHierarchyTree helper 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)