Files
ColaFlow/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/ValueObjects/DiffPreview.cs
Yaojia Wang 63d0e20371 feat(backend): Implement MCP Domain Layer - PendingChange, TaskLock, DiffPreview (Story 5.3)
Implemented comprehensive domain layer for MCP module following DDD principles:

Domain Entities & Aggregates:
- PendingChange aggregate root with approval workflow (Pending/Approved/Rejected/Expired/Applied)
- TaskLock aggregate root for concurrency control with 5-minute expiration
- Business rule enforcement at domain level

Value Objects:
- DiffPreview for CREATE/UPDATE/DELETE operations with validation
- DiffField for field-level change tracking
- PendingChangeStatus and TaskLockStatus enums

Domain Events (8 total):
- PendingChange: Created, Approved, Rejected, Expired, Applied
- TaskLock: Acquired, Released, Expired

Repository Interfaces:
- IPendingChangeRepository with query methods for status, entity, and expiration
- ITaskLockRepository with concurrency control queries

Domain Services:
- DiffPreviewService for generating diffs via reflection and JSON comparison
- TaskLockService for lock acquisition, release, and expiration management

Unit Tests (112 total, all passing):
- DiffFieldTests: 13 tests for value object behavior and equality
- DiffPreviewTests: 20 tests for operation validation and factory methods
- PendingChangeTests: 29 tests for aggregate lifecycle and business rules
- TaskLockTests: 26 tests for lock management and expiration
- Test coverage > 90% for domain layer

Technical Implementation:
- Follows DDD aggregate root pattern with encapsulation
- Uses factory methods for entity creation with validation
- Domain events for audit trail and loose coupling
- Immutable value objects with equality comparison
- Business rules enforced in domain entities (not services)
- 24-hour expiration for PendingChange, 5-minute for TaskLock
- Supports diff preview with before/after snapshots (JSON)

Story 5.3 completed - provides solid foundation for Phase 3 Diff Preview and approval workflow.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 20:56:22 +01:00

219 lines
6.6 KiB
C#

using ColaFlow.Shared.Kernel.Common;
namespace ColaFlow.Modules.Mcp.Domain.ValueObjects;
/// <summary>
/// Value object representing a preview of changes proposed by an AI agent
/// Immutable - once created, cannot be modified
/// </summary>
public sealed class DiffPreview : ValueObject
{
/// <summary>
/// The type of operation: CREATE, UPDATE, DELETE
/// </summary>
public string Operation { get; private set; }
/// <summary>
/// The type of entity being changed (e.g., "Epic", "Story", "Task")
/// </summary>
public string EntityType { get; private set; }
/// <summary>
/// The ID of the entity being changed (null for CREATE operations)
/// </summary>
public Guid? EntityId { get; private set; }
/// <summary>
/// Human-readable key for the entity (e.g., "COLA-146")
/// </summary>
public string? EntityKey { get; private set; }
/// <summary>
/// Snapshot of the entity state before the change (null for CREATE)
/// Stored as JSON for flexibility
/// </summary>
public string? BeforeData { get; private set; }
/// <summary>
/// Snapshot of the entity state after the change (null for DELETE)
/// Stored as JSON for flexibility
/// </summary>
public string? AfterData { get; private set; }
/// <summary>
/// List of individual field changes (for UPDATE operations)
/// </summary>
public IReadOnlyList<DiffField> ChangedFields { get; private set; }
/// <summary>
/// Private constructor for EF Core
/// </summary>
private DiffPreview()
{
Operation = string.Empty;
EntityType = string.Empty;
ChangedFields = new List<DiffField>().AsReadOnly();
}
/// <summary>
/// Create a new DiffPreview
/// </summary>
public DiffPreview(
string operation,
string entityType,
Guid? entityId,
string? entityKey,
string? beforeData,
string? afterData,
IReadOnlyList<DiffField>? changedFields = null)
{
// Validation
if (string.IsNullOrWhiteSpace(operation))
throw new ArgumentException("Operation cannot be empty", nameof(operation));
if (string.IsNullOrWhiteSpace(entityType))
throw new ArgumentException("EntityType cannot be empty", nameof(entityType));
// Normalize operation to uppercase
operation = operation.ToUpperInvariant();
// Validate operation type
if (operation != "CREATE" && operation != "UPDATE" && operation != "DELETE")
throw new ArgumentException(
"Operation must be CREATE, UPDATE, or DELETE",
nameof(operation));
// Validate operation-specific requirements
if (operation == "UPDATE" && entityId == null)
throw new ArgumentException(
"UPDATE operation requires EntityId",
nameof(entityId));
if (operation == "UPDATE" && (changedFields == null || changedFields.Count == 0))
throw new ArgumentException(
"UPDATE operation must have at least one changed field",
nameof(changedFields));
if (operation == "DELETE" && entityId == null)
throw new ArgumentException(
"DELETE operation requires EntityId",
nameof(entityId));
if (operation == "CREATE" && string.IsNullOrWhiteSpace(afterData))
throw new ArgumentException(
"CREATE operation requires AfterData",
nameof(afterData));
Operation = operation;
EntityType = entityType;
EntityId = entityId;
EntityKey = entityKey;
BeforeData = beforeData;
AfterData = afterData;
ChangedFields = changedFields ?? new List<DiffField>().AsReadOnly();
}
/// <summary>
/// Factory method to create a CREATE operation diff
/// </summary>
public static DiffPreview ForCreate(
string entityType,
string afterData,
string? entityKey = null)
{
return new DiffPreview(
operation: "CREATE",
entityType: entityType,
entityId: null,
entityKey: entityKey,
beforeData: null,
afterData: afterData,
changedFields: null);
}
/// <summary>
/// Factory method to create an UPDATE operation diff
/// </summary>
public static DiffPreview ForUpdate(
string entityType,
Guid entityId,
string beforeData,
string afterData,
IReadOnlyList<DiffField> changedFields,
string? entityKey = null)
{
return new DiffPreview(
operation: "UPDATE",
entityType: entityType,
entityId: entityId,
entityKey: entityKey,
beforeData: beforeData,
afterData: afterData,
changedFields: changedFields);
}
/// <summary>
/// Factory method to create a DELETE operation diff
/// </summary>
public static DiffPreview ForDelete(
string entityType,
Guid entityId,
string beforeData,
string? entityKey = null)
{
return new DiffPreview(
operation: "DELETE",
entityType: entityType,
entityId: entityId,
entityKey: entityKey,
beforeData: beforeData,
afterData: null,
changedFields: null);
}
/// <summary>
/// Value object equality - compare all atomic values
/// </summary>
protected override IEnumerable<object> GetAtomicValues()
{
yield return Operation;
yield return EntityType;
yield return EntityId ?? Guid.Empty;
yield return EntityKey ?? string.Empty;
yield return BeforeData ?? string.Empty;
yield return AfterData ?? string.Empty;
foreach (var field in ChangedFields)
yield return field;
}
/// <summary>
/// Check if this is a CREATE operation
/// </summary>
public bool IsCreate() => Operation == "CREATE";
/// <summary>
/// Check if this is an UPDATE operation
/// </summary>
public bool IsUpdate() => Operation == "UPDATE";
/// <summary>
/// Check if this is a DELETE operation
/// </summary>
public bool IsDelete() => Operation == "DELETE";
/// <summary>
/// Get a human-readable summary of the change
/// </summary>
public string GetSummary()
{
var identifier = EntityKey ?? EntityId?.ToString() ?? "new entity";
return $"{Operation} {EntityType} ({identifier})";
}
/// <summary>
/// Get the count of changed fields
/// </summary>
public int GetChangedFieldCount() => ChangedFields.Count;
}