Files
ColaFlow/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/Services/PendingChangeService.cs
Yaojia Wang 63ff1a9914
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
Clean up
2025-11-09 18:40:36 +01:00

403 lines
14 KiB
C#

using ColaFlow.Modules.Mcp.Application.DTOs;
using ColaFlow.Modules.Mcp.Domain.Entities;
using ColaFlow.Modules.Mcp.Domain.Exceptions;
using ColaFlow.Modules.Mcp.Domain.Repositories;
using ColaFlow.Modules.Mcp.Domain.ValueObjects;
using ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
namespace ColaFlow.Modules.Mcp.Application.Services;
/// <summary>
/// Service implementation for PendingChange management
/// </summary>
public class PendingChangeService(
IPendingChangeRepository repository,
ITenantContext tenantContext,
IHttpContextAccessor httpContextAccessor,
IPublisher publisher,
ILogger<PendingChangeService> logger)
: IPendingChangeService
{
private readonly IPendingChangeRepository _repository = repository ?? throw new ArgumentNullException(nameof(repository));
private readonly ITenantContext _tenantContext = tenantContext ?? throw new ArgumentNullException(nameof(tenantContext));
private readonly IHttpContextAccessor _httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor));
private readonly IPublisher _publisher = publisher ?? throw new ArgumentNullException(nameof(publisher));
private readonly ILogger<PendingChangeService> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
public async Task<PendingChangeDto> CreateAsync(
CreatePendingChangeRequest request,
CancellationToken cancellationToken = default)
{
var tenantId = _tenantContext.GetCurrentTenantId();
// Get API Key ID from HttpContext (set by MCP authentication middleware)
var apiKeyIdNullable = _httpContextAccessor.HttpContext?.Items["ApiKeyId"] as Guid?;
if (!apiKeyIdNullable.HasValue)
{
throw new McpUnauthorizedException("API Key not found in request context");
}
var apiKeyId = apiKeyIdNullable.Value;
_logger.LogInformation(
"Creating PendingChange: Tool={ToolName}, Operation={Operation}, EntityType={EntityType}, Tenant={TenantId}",
request.ToolName, request.Diff.Operation, request.Diff.EntityType, tenantId);
// Create PendingChange entity
var pendingChange = PendingChange.Create(
request.ToolName,
request.Diff,
tenantId,
apiKeyId,
request.ExpirationHours);
// Save to database
await _repository.AddAsync(pendingChange, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
// Publish domain events
foreach (var domainEvent in pendingChange.DomainEvents)
{
await _publisher.Publish(domainEvent, cancellationToken);
}
pendingChange.ClearDomainEvents();
_logger.LogInformation(
"PendingChange created: {Id}, ExpiresAt={ExpiresAt}",
pendingChange.Id, pendingChange.ExpiresAt);
return MapToDto(pendingChange);
}
public async Task<PendingChangeDto?> GetByIdAsync(
Guid id,
CancellationToken cancellationToken = default)
{
var tenantId = _tenantContext.GetCurrentTenantId();
var pendingChange = await _repository.GetByIdAsync(id, cancellationToken);
if (pendingChange == null)
{
return null;
}
// Verify tenant isolation
if (pendingChange.TenantId != tenantId)
{
_logger.LogWarning(
"Attempted cross-tenant access: PendingChange {Id} belongs to Tenant {OwnerId}, but requested by Tenant {RequesterId}",
id, pendingChange.TenantId, tenantId);
return null;
}
return MapToDto(pendingChange);
}
public async Task<(List<PendingChangeDto> Items, int TotalCount)> GetPendingChangesAsync(
PendingChangeFilterDto filter,
CancellationToken cancellationToken = default)
{
var tenantId = _tenantContext.GetCurrentTenantId();
// Build query
var query = (await _repository.GetByTenantAsync(tenantId, cancellationToken))
.AsEnumerable();
// Apply filters
if (filter.Status.HasValue)
{
query = query.Where(x => x.Status == filter.Status.Value);
}
if (!string.IsNullOrWhiteSpace(filter.EntityType))
{
query = query.Where(x => x.Diff.EntityType == filter.EntityType);
}
if (filter.EntityId.HasValue)
{
query = query.Where(x => x.Diff.EntityId == filter.EntityId.Value);
}
if (filter.ApiKeyId.HasValue)
{
query = query.Where(x => x.ApiKeyId == filter.ApiKeyId.Value);
}
if (!string.IsNullOrWhiteSpace(filter.ToolName))
{
query = query.Where(x => x.ToolName == filter.ToolName);
}
if (filter.IncludeExpired == false)
{
query = query.Where(x => x.Status != PendingChangeStatus.Expired);
}
// Get total count before pagination
var totalCount = query.Count();
// Apply pagination
var items = query
.OrderByDescending(x => x.CreatedAt)
.Skip((filter.Page - 1) * filter.PageSize)
.Take(filter.PageSize)
.Select(MapToDto)
.ToList();
_logger.LogInformation(
"Retrieved {Count}/{Total} PendingChanges for Tenant {TenantId} (Page {Page}/{PageSize})",
items.Count, totalCount, tenantId, filter.Page, filter.PageSize);
return (items, totalCount);
}
public async Task ApproveAsync(
Guid pendingChangeId,
Guid approvedBy,
CancellationToken cancellationToken = default)
{
var tenantId = _tenantContext.GetCurrentTenantId();
var pendingChange = await _repository.GetByIdAsync(pendingChangeId, cancellationToken);
if (pendingChange == null)
{
throw new McpNotFoundException("PendingChange", pendingChangeId.ToString());
}
// Verify tenant isolation
if (pendingChange.TenantId != tenantId)
{
throw new McpForbiddenException(
$"Cannot approve PendingChange from different tenant");
}
_logger.LogInformation(
"Approving PendingChange {Id} by User {UserId}",
pendingChangeId, approvedBy);
// Domain method validates business rules and raises PendingChangeApprovedEvent
pendingChange.Approve(approvedBy);
await _repository.UpdateAsync(pendingChange, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
// Publish domain events (will trigger operation execution)
foreach (var domainEvent in pendingChange.DomainEvents)
{
await _publisher.Publish(domainEvent, cancellationToken);
}
pendingChange.ClearDomainEvents();
_logger.LogInformation(
"PendingChange {Id} approved successfully",
pendingChangeId);
}
public async Task RejectAsync(
Guid pendingChangeId,
Guid rejectedBy,
string reason,
CancellationToken cancellationToken = default)
{
var tenantId = _tenantContext.GetCurrentTenantId();
var pendingChange = await _repository.GetByIdAsync(pendingChangeId, cancellationToken);
if (pendingChange == null)
{
throw new McpNotFoundException("PendingChange", pendingChangeId.ToString());
}
// Verify tenant isolation
if (pendingChange.TenantId != tenantId)
{
throw new McpForbiddenException(
$"Cannot reject PendingChange from different tenant");
}
_logger.LogInformation(
"Rejecting PendingChange {Id} by User {UserId} - Reason: {Reason}",
pendingChangeId, rejectedBy, reason);
// Domain method validates business rules and raises PendingChangeRejectedEvent
pendingChange.Reject(rejectedBy, reason);
await _repository.UpdateAsync(pendingChange, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
// Publish domain events
foreach (var domainEvent in pendingChange.DomainEvents)
{
await _publisher.Publish(domainEvent, cancellationToken);
}
pendingChange.ClearDomainEvents();
_logger.LogInformation(
"PendingChange {Id} rejected successfully",
pendingChangeId);
}
public async Task MarkAsAppliedAsync(
Guid pendingChangeId,
string result,
CancellationToken cancellationToken = default)
{
var pendingChange = await _repository.GetByIdAsync(pendingChangeId, cancellationToken);
if (pendingChange == null)
{
throw new McpNotFoundException("PendingChange", pendingChangeId.ToString());
}
_logger.LogInformation(
"Marking PendingChange {Id} as Applied - Result: {Result}",
pendingChangeId, result);
// Domain method validates business rules and raises PendingChangeAppliedEvent
pendingChange.MarkAsApplied(result);
await _repository.UpdateAsync(pendingChange, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
// Publish domain events
foreach (var domainEvent in pendingChange.DomainEvents)
{
await _publisher.Publish(domainEvent, cancellationToken);
}
pendingChange.ClearDomainEvents();
_logger.LogInformation(
"PendingChange {Id} marked as Applied",
pendingChangeId);
}
public async Task<int> ExpireOldChangesAsync(CancellationToken cancellationToken = default)
{
_logger.LogInformation("Starting expiration check for old PendingChanges");
var expiredChanges = await _repository.GetExpiredAsync(cancellationToken);
var count = 0;
foreach (var change in expiredChanges)
{
try
{
change.Expire();
await _repository.UpdateAsync(change, cancellationToken);
// Publish domain events
foreach (var domainEvent in change.DomainEvents)
{
await _publisher.Publish(domainEvent, cancellationToken);
}
change.ClearDomainEvents();
count++;
_logger.LogWarning(
"PendingChange expired: {Id} - {ToolName} {Operation} {EntityType}",
change.Id, change.ToolName, change.Diff.Operation, change.Diff.EntityType);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to expire PendingChange {Id}",
change.Id);
}
}
if (count > 0)
{
await _repository.SaveChangesAsync(cancellationToken);
}
_logger.LogInformation(
"Expired {Count} PendingChanges",
count);
return count;
}
public async Task DeleteAsync(
Guid pendingChangeId,
CancellationToken cancellationToken = default)
{
var tenantId = _tenantContext.GetCurrentTenantId();
var pendingChange = await _repository.GetByIdAsync(pendingChangeId, cancellationToken);
if (pendingChange == null)
{
throw new McpNotFoundException("PendingChange", pendingChangeId.ToString());
}
// Verify tenant isolation
if (pendingChange.TenantId != tenantId)
{
throw new McpForbiddenException(
$"Cannot delete PendingChange from different tenant");
}
// Only allow deletion of Expired or Rejected changes
if (pendingChange.Status != PendingChangeStatus.Expired &&
pendingChange.Status != PendingChangeStatus.Rejected)
{
throw new McpValidationException(
$"Can only delete PendingChanges with Expired or Rejected status. Current status: {pendingChange.Status}");
}
_logger.LogInformation(
"Deleting PendingChange {Id} (Status: {Status})",
pendingChangeId, pendingChange.Status);
await _repository.DeleteAsync(pendingChange, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
_logger.LogInformation(
"PendingChange {Id} deleted successfully",
pendingChangeId);
}
private static PendingChangeDto MapToDto(PendingChange pendingChange)
{
return new PendingChangeDto
{
Id = pendingChange.Id,
TenantId = pendingChange.TenantId,
ApiKeyId = pendingChange.ApiKeyId,
ToolName = pendingChange.ToolName,
Diff = new DiffPreviewDto
{
Operation = pendingChange.Diff.Operation,
EntityType = pendingChange.Diff.EntityType,
EntityId = pendingChange.Diff.EntityId,
EntityKey = pendingChange.Diff.EntityKey,
BeforeData = pendingChange.Diff.BeforeData,
AfterData = pendingChange.Diff.AfterData,
ChangedFields = pendingChange.Diff.ChangedFields.Select(f => new DiffFieldDto
{
FieldName = f.FieldName,
DisplayName = f.DisplayName,
OldValue = f.OldValue,
NewValue = f.NewValue,
DiffHtml = f.DiffHtml
}).ToList()
},
Status = pendingChange.Status.ToString(),
CreatedAt = pendingChange.CreatedAt,
ExpiresAt = pendingChange.ExpiresAt,
ApprovedBy = pendingChange.ApprovedBy,
ApprovedAt = pendingChange.ApprovedAt,
RejectedBy = pendingChange.RejectedBy,
RejectedAt = pendingChange.RejectedAt,
RejectionReason = pendingChange.RejectionReason,
AppliedAt = pendingChange.AppliedAt,
ApplicationResult = pendingChange.ApplicationResult,
IsExpired = pendingChange.IsExpired(),
CanBeApproved = pendingChange.CanBeApproved(),
CanBeRejected = pendingChange.CanBeRejected(),
Summary = pendingChange.GetSummary()
};
}
}