diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/DTOs/DiffPreviewDto.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/DTOs/DiffPreviewDto.cs new file mode 100644 index 0000000..f8357f3 --- /dev/null +++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/DTOs/DiffPreviewDto.cs @@ -0,0 +1,27 @@ +namespace ColaFlow.Modules.Mcp.Application.DTOs; + +/// +/// DTO for Diff Preview response +/// +public sealed class DiffPreviewDto +{ + public string Operation { get; set; } = null!; + public string EntityType { get; set; } = null!; + public Guid? EntityId { get; set; } + public string? EntityKey { get; set; } + public string? BeforeData { get; set; } + public string? AfterData { get; set; } + public List ChangedFields { get; set; } = new(); +} + +/// +/// DTO for a single field difference +/// +public sealed class DiffFieldDto +{ + public string FieldName { get; set; } = null!; + public string DisplayName { get; set; } = null!; + public object? OldValue { get; set; } + public object? NewValue { get; set; } + public string? DiffHtml { get; set; } +} diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Services/DiffPreviewService.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Services/DiffPreviewService.cs index fa7c097..787d1f1 100644 --- a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Services/DiffPreviewService.cs +++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Services/DiffPreviewService.cs @@ -208,4 +208,99 @@ public sealed class DiffPreviewService return false; } } + + /// + /// Generate HTML diff for changed fields in a diff preview + /// Adds visual highlighting for additions, deletions, and modifications + /// + public string GenerateHtmlDiff(DiffPreview diff) + { + if (diff == null) + throw new ArgumentNullException(nameof(diff)); + + var html = new System.Text.StringBuilder(); + html.AppendLine("
"); + html.AppendLine($"
"); + html.AppendLine($" {diff.Operation}"); + html.AppendLine($" {diff.EntityType}"); + + if (diff.EntityKey != null) + html.AppendLine($" {System.Net.WebUtility.HtmlEncode(diff.EntityKey)}"); + + html.AppendLine($"
"); + + if (diff.IsCreate()) + { + html.AppendLine($"
"); + html.AppendLine($"

New {diff.EntityType}

"); + html.AppendLine($"
{System.Net.WebUtility.HtmlEncode(diff.AfterData ?? "")}
"); + html.AppendLine($"
"); + } + else if (diff.IsDelete()) + { + html.AppendLine($"
"); + html.AppendLine($"

Deleted {diff.EntityType}

"); + html.AppendLine($"
{System.Net.WebUtility.HtmlEncode(diff.BeforeData ?? "")}
"); + html.AppendLine($"
"); + } + else if (diff.IsUpdate()) + { + html.AppendLine($"
"); + html.AppendLine($"

Changed Fields ({diff.ChangedFields.Count})

"); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + + foreach (var field in diff.ChangedFields) + { + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + } + + html.AppendLine($" "); + html.AppendLine($"
FieldOld ValueNew Value
{System.Net.WebUtility.HtmlEncode(field.DisplayName)}"); + html.AppendLine($" {System.Net.WebUtility.HtmlEncode(FormatValue(field.OldValue))}"); + html.AppendLine($" "); + html.AppendLine($" {System.Net.WebUtility.HtmlEncode(FormatValue(field.NewValue))}"); + html.AppendLine($"
"); + html.AppendLine($"
"); + } + + html.AppendLine("
"); + return html.ToString(); + } + + /// + /// Format a value for display in HTML + /// Handles nulls, dates, and truncates long strings + /// + private string FormatValue(object? value) + { + if (value == null) + return "(null)"; + + if (value is DateTime dateTime) + return dateTime.ToString("yyyy-MM-dd HH:mm:ss UTC"); + + if (value is DateTimeOffset dateTimeOffset) + return dateTimeOffset.ToString("yyyy-MM-dd HH:mm:ss UTC"); + + var stringValue = value.ToString() ?? ""; + + // Truncate long strings + const int maxLength = 500; + if (stringValue.Length > maxLength) + return stringValue.Substring(0, maxLength) + "... (truncated)"; + + return stringValue; + } } diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Extensions/McpServiceExtensions.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Extensions/McpServiceExtensions.cs index a616126..158ff07 100644 --- a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Extensions/McpServiceExtensions.cs +++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Extensions/McpServiceExtensions.cs @@ -34,8 +34,12 @@ public static class McpServiceExtensions // Register repositories services.AddScoped(); + services.AddScoped(); + services.AddScoped(); - // Register application services + // Register domain services + services.AddScoped(); + services.AddScoped(); services.AddScoped(); // Register resource registry (Singleton - shared across all requests) diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Persistence/Configurations/PendingChangeConfiguration.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Persistence/Configurations/PendingChangeConfiguration.cs new file mode 100644 index 0000000..10ca905 --- /dev/null +++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Persistence/Configurations/PendingChangeConfiguration.cs @@ -0,0 +1,107 @@ +using ColaFlow.Modules.Mcp.Domain.Entities; +using ColaFlow.Modules.Mcp.Domain.ValueObjects; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using System.Text.Json; + +namespace ColaFlow.Modules.Mcp.Infrastructure.Persistence.Configurations; + +/// +/// EF Core configuration for PendingChange entity +/// +public class PendingChangeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("pending_changes"); + + builder.HasKey(p => p.Id); + + builder.Property(p => p.Id) + .HasColumnName("id") + .IsRequired(); + + builder.Property(p => p.TenantId) + .HasColumnName("tenant_id") + .IsRequired(); + + builder.Property(p => p.ApiKeyId) + .HasColumnName("api_key_id") + .IsRequired(); + + builder.Property(p => p.ToolName) + .HasColumnName("tool_name") + .HasMaxLength(200) + .IsRequired(); + + // Store DiffPreview as JSONB column using value converter + builder.Property(p => p.Diff) + .HasColumnName("diff") + .HasColumnType("jsonb") + .HasConversion( + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), + v => JsonSerializer.Deserialize(v, (JsonSerializerOptions?)null)! + ) + .IsRequired(); + + builder.Property(p => p.Status) + .HasColumnName("status") + .HasConversion() + .HasMaxLength(50) + .IsRequired(); + + builder.Property(p => p.CreatedAt) + .HasColumnName("created_at") + .IsRequired(); + + builder.Property(p => p.ExpiresAt) + .HasColumnName("expires_at") + .IsRequired(); + + builder.Property(p => p.ApprovedBy) + .HasColumnName("approved_by"); + + builder.Property(p => p.ApprovedAt) + .HasColumnName("approved_at"); + + builder.Property(p => p.RejectedBy) + .HasColumnName("rejected_by"); + + builder.Property(p => p.RejectedAt) + .HasColumnName("rejected_at"); + + builder.Property(p => p.RejectionReason) + .HasColumnName("rejection_reason") + .HasMaxLength(1000); + + builder.Property(p => p.AppliedAt) + .HasColumnName("applied_at"); + + builder.Property(p => p.ApplicationResult) + .HasColumnName("application_result") + .HasMaxLength(2000); + + // Indexes + builder.HasIndex(p => p.TenantId) + .HasDatabaseName("ix_pending_changes_tenant_id"); + + builder.HasIndex(p => p.ApiKeyId) + .HasDatabaseName("ix_pending_changes_api_key_id"); + + builder.HasIndex(p => p.Status) + .HasDatabaseName("ix_pending_changes_status"); + + builder.HasIndex(p => p.CreatedAt) + .HasDatabaseName("ix_pending_changes_created_at"); + + builder.HasIndex(p => p.ExpiresAt) + .HasDatabaseName("ix_pending_changes_expires_at"); + + // Composite index for querying by tenant and status + builder.HasIndex(p => new { p.TenantId, p.Status }) + .HasDatabaseName("ix_pending_changes_tenant_status"); + + // Ignore domain events (handled by base class) + builder.Ignore(p => p.DomainEvents); + } +} diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Persistence/Configurations/TaskLockConfiguration.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Persistence/Configurations/TaskLockConfiguration.cs new file mode 100644 index 0000000..3565575 --- /dev/null +++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Persistence/Configurations/TaskLockConfiguration.cs @@ -0,0 +1,86 @@ +using ColaFlow.Modules.Mcp.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ColaFlow.Modules.Mcp.Infrastructure.Persistence.Configurations; + +/// +/// EF Core configuration for TaskLock entity +/// +public class TaskLockConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("task_locks"); + + builder.HasKey(t => t.Id); + + builder.Property(t => t.Id) + .HasColumnName("id") + .IsRequired(); + + builder.Property(t => t.TenantId) + .HasColumnName("tenant_id") + .IsRequired(); + + builder.Property(t => t.ResourceType) + .HasColumnName("resource_type") + .HasMaxLength(200) + .IsRequired(); + + builder.Property(t => t.ResourceId) + .HasColumnName("resource_id") + .IsRequired(); + + builder.Property(t => t.LockHolderType) + .HasColumnName("lock_holder_type") + .HasMaxLength(50) + .IsRequired(); + + builder.Property(t => t.LockHolderId) + .HasColumnName("lock_holder_id") + .IsRequired(); + + builder.Property(t => t.LockHolderName) + .HasColumnName("lock_holder_name") + .HasMaxLength(500); + + builder.Property(t => t.Purpose) + .HasColumnName("purpose") + .HasMaxLength(1000); + + builder.Property(t => t.AcquiredAt) + .HasColumnName("acquired_at") + .IsRequired(); + + builder.Property(t => t.ExpiresAt) + .HasColumnName("expires_at") + .IsRequired(); + + builder.Property(t => t.Status) + .HasColumnName("status") + .HasConversion() + .HasMaxLength(50) + .IsRequired(); + + builder.Property(t => t.ReleasedAt) + .HasColumnName("released_at"); + + // Indexes + builder.HasIndex(t => new { t.TenantId, t.ResourceType, t.ResourceId }) + .HasDatabaseName("ix_task_locks_resource") + .IsUnique(); + + builder.HasIndex(t => t.Status) + .HasDatabaseName("ix_task_locks_status"); + + builder.HasIndex(t => t.ExpiresAt) + .HasDatabaseName("ix_task_locks_expires_at"); + + builder.HasIndex(t => t.LockHolderId) + .HasDatabaseName("ix_task_locks_lock_holder_id"); + + // Ignore domain events + builder.Ignore(t => t.DomainEvents); + } +} diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Persistence/McpDbContext.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Persistence/McpDbContext.cs index 9aef0fa..441374a 100644 --- a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Persistence/McpDbContext.cs +++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Persistence/McpDbContext.cs @@ -14,6 +14,8 @@ public class McpDbContext : DbContext } public DbSet ApiKeys => Set(); + public DbSet PendingChanges => Set(); + public DbSet TaskLocks => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -21,6 +23,8 @@ public class McpDbContext : DbContext // Apply configurations modelBuilder.ApplyConfiguration(new McpApiKeyConfiguration()); + modelBuilder.ApplyConfiguration(new PendingChangeConfiguration()); + modelBuilder.ApplyConfiguration(new TaskLockConfiguration()); // Set default schema modelBuilder.HasDefaultSchema("mcp"); diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Persistence/Migrations/20251109163824_AddPendingChangeAndTaskLock.Designer.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Persistence/Migrations/20251109163824_AddPendingChangeAndTaskLock.Designer.cs new file mode 100644 index 0000000..580101f --- /dev/null +++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Persistence/Migrations/20251109163824_AddPendingChangeAndTaskLock.Designer.cs @@ -0,0 +1,295 @@ +// +using System; +using ColaFlow.Modules.Mcp.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ColaFlow.Modules.Mcp.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(McpDbContext))] + [Migration("20251109163824_AddPendingChangeAndTaskLock")] + partial class AddPendingChangeAndTaskLock + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("mcp") + .HasAnnotation("ProductVersion", "9.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ColaFlow.Modules.Mcp.Domain.Entities.McpApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("IpWhitelist") + .HasColumnType("jsonb") + .HasColumnName("ip_whitelist"); + + b.Property("KeyHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("key_hash"); + + b.Property("KeyPrefix") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("key_prefix"); + + b.Property("LastUsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("name"); + + b.Property("Permissions") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("permissions"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.Property("RevokedBy") + .HasColumnType("uuid") + .HasColumnName("revoked_by"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UsageCount") + .HasColumnType("bigint") + .HasColumnName("usage_count"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("ix_mcp_api_keys_expires_at"); + + b.HasIndex("KeyPrefix") + .IsUnique() + .HasDatabaseName("ix_mcp_api_keys_key_prefix"); + + b.HasIndex("Status") + .HasDatabaseName("ix_mcp_api_keys_status"); + + b.HasIndex("TenantId") + .HasDatabaseName("ix_mcp_api_keys_tenant_id"); + + b.HasIndex("TenantId", "UserId") + .HasDatabaseName("ix_mcp_api_keys_tenant_user"); + + b.ToTable("mcp_api_keys", "mcp"); + }); + + modelBuilder.Entity("ColaFlow.Modules.Mcp.Domain.Entities.PendingChange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ApiKeyId") + .HasColumnType("uuid") + .HasColumnName("api_key_id"); + + b.Property("ApplicationResult") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("application_result"); + + b.Property("AppliedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("applied_at"); + + b.Property("ApprovedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("approved_at"); + + b.Property("ApprovedBy") + .HasColumnType("uuid") + .HasColumnName("approved_by"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Diff") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("diff"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("RejectedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("rejected_at"); + + b.Property("RejectedBy") + .HasColumnType("uuid") + .HasColumnName("rejected_by"); + + b.Property("RejectionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("rejection_reason"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("ToolName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("tool_name"); + + b.HasKey("Id"); + + b.HasIndex("ApiKeyId") + .HasDatabaseName("ix_pending_changes_api_key_id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_pending_changes_created_at"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("ix_pending_changes_expires_at"); + + b.HasIndex("Status") + .HasDatabaseName("ix_pending_changes_status"); + + b.HasIndex("TenantId") + .HasDatabaseName("ix_pending_changes_tenant_id"); + + b.HasIndex("TenantId", "Status") + .HasDatabaseName("ix_pending_changes_tenant_status"); + + b.ToTable("pending_changes", "mcp"); + }); + + modelBuilder.Entity("ColaFlow.Modules.Mcp.Domain.Entities.TaskLock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AcquiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("acquired_at"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("LockHolderId") + .HasColumnType("uuid") + .HasColumnName("lock_holder_id"); + + b.Property("LockHolderName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("lock_holder_name"); + + b.Property("LockHolderType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("lock_holder_type"); + + b.Property("Purpose") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("purpose"); + + b.Property("ReleasedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("released_at"); + + b.Property("ResourceId") + .HasColumnType("uuid") + .HasColumnName("resource_id"); + + b.Property("ResourceType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("resource_type"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("ix_task_locks_expires_at"); + + b.HasIndex("LockHolderId") + .HasDatabaseName("ix_task_locks_lock_holder_id"); + + b.HasIndex("Status") + .HasDatabaseName("ix_task_locks_status"); + + b.HasIndex("TenantId", "ResourceType", "ResourceId") + .IsUnique() + .HasDatabaseName("ix_task_locks_resource"); + + b.ToTable("task_locks", "mcp"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Persistence/Migrations/20251109163824_AddPendingChangeAndTaskLock.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Persistence/Migrations/20251109163824_AddPendingChangeAndTaskLock.cs new file mode 100644 index 0000000..ae2b276 --- /dev/null +++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Persistence/Migrations/20251109163824_AddPendingChangeAndTaskLock.cs @@ -0,0 +1,137 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ColaFlow.Modules.Mcp.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddPendingChangeAndTaskLock : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "pending_changes", + schema: "mcp", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + tenant_id = table.Column(type: "uuid", nullable: false), + api_key_id = table.Column(type: "uuid", nullable: false), + tool_name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + diff = table.Column(type: "jsonb", nullable: false), + status = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + expires_at = table.Column(type: "timestamp with time zone", nullable: false), + approved_by = table.Column(type: "uuid", nullable: true), + approved_at = table.Column(type: "timestamp with time zone", nullable: true), + rejected_by = table.Column(type: "uuid", nullable: true), + rejected_at = table.Column(type: "timestamp with time zone", nullable: true), + rejection_reason = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true), + applied_at = table.Column(type: "timestamp with time zone", nullable: true), + application_result = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_pending_changes", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "task_locks", + schema: "mcp", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + tenant_id = table.Column(type: "uuid", nullable: false), + resource_type = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + resource_id = table.Column(type: "uuid", nullable: false), + lock_holder_type = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + lock_holder_id = table.Column(type: "uuid", nullable: false), + lock_holder_name = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + status = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + acquired_at = table.Column(type: "timestamp with time zone", nullable: false), + expires_at = table.Column(type: "timestamp with time zone", nullable: false), + released_at = table.Column(type: "timestamp with time zone", nullable: true), + purpose = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_task_locks", x => x.id); + }); + + migrationBuilder.CreateIndex( + name: "ix_pending_changes_api_key_id", + schema: "mcp", + table: "pending_changes", + column: "api_key_id"); + + migrationBuilder.CreateIndex( + name: "ix_pending_changes_created_at", + schema: "mcp", + table: "pending_changes", + column: "created_at"); + + migrationBuilder.CreateIndex( + name: "ix_pending_changes_expires_at", + schema: "mcp", + table: "pending_changes", + column: "expires_at"); + + migrationBuilder.CreateIndex( + name: "ix_pending_changes_status", + schema: "mcp", + table: "pending_changes", + column: "status"); + + migrationBuilder.CreateIndex( + name: "ix_pending_changes_tenant_id", + schema: "mcp", + table: "pending_changes", + column: "tenant_id"); + + migrationBuilder.CreateIndex( + name: "ix_pending_changes_tenant_status", + schema: "mcp", + table: "pending_changes", + columns: new[] { "tenant_id", "status" }); + + migrationBuilder.CreateIndex( + name: "ix_task_locks_expires_at", + schema: "mcp", + table: "task_locks", + column: "expires_at"); + + migrationBuilder.CreateIndex( + name: "ix_task_locks_lock_holder_id", + schema: "mcp", + table: "task_locks", + column: "lock_holder_id"); + + migrationBuilder.CreateIndex( + name: "ix_task_locks_resource", + schema: "mcp", + table: "task_locks", + columns: new[] { "tenant_id", "resource_type", "resource_id" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_task_locks_status", + schema: "mcp", + table: "task_locks", + column: "status"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "pending_changes", + schema: "mcp"); + + migrationBuilder.DropTable( + name: "task_locks", + schema: "mcp"); + } + } +} diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Persistence/Migrations/McpDbContextModelSnapshot.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Persistence/Migrations/McpDbContextModelSnapshot.cs index 59563a5..a71d2cf 100644 --- a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Persistence/Migrations/McpDbContextModelSnapshot.cs +++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Persistence/Migrations/McpDbContextModelSnapshot.cs @@ -116,6 +116,176 @@ namespace ColaFlow.Modules.Mcp.Infrastructure.Persistence.Migrations b.ToTable("mcp_api_keys", "mcp"); }); + + modelBuilder.Entity("ColaFlow.Modules.Mcp.Domain.Entities.PendingChange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ApiKeyId") + .HasColumnType("uuid") + .HasColumnName("api_key_id"); + + b.Property("ApplicationResult") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("application_result"); + + b.Property("AppliedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("applied_at"); + + b.Property("ApprovedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("approved_at"); + + b.Property("ApprovedBy") + .HasColumnType("uuid") + .HasColumnName("approved_by"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Diff") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("diff"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("RejectedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("rejected_at"); + + b.Property("RejectedBy") + .HasColumnType("uuid") + .HasColumnName("rejected_by"); + + b.Property("RejectionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("rejection_reason"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("ToolName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("tool_name"); + + b.HasKey("Id"); + + b.HasIndex("ApiKeyId") + .HasDatabaseName("ix_pending_changes_api_key_id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_pending_changes_created_at"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("ix_pending_changes_expires_at"); + + b.HasIndex("Status") + .HasDatabaseName("ix_pending_changes_status"); + + b.HasIndex("TenantId") + .HasDatabaseName("ix_pending_changes_tenant_id"); + + b.HasIndex("TenantId", "Status") + .HasDatabaseName("ix_pending_changes_tenant_status"); + + b.ToTable("pending_changes", "mcp"); + }); + + modelBuilder.Entity("ColaFlow.Modules.Mcp.Domain.Entities.TaskLock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AcquiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("acquired_at"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("LockHolderId") + .HasColumnType("uuid") + .HasColumnName("lock_holder_id"); + + b.Property("LockHolderName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("lock_holder_name"); + + b.Property("LockHolderType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("lock_holder_type"); + + b.Property("Purpose") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("purpose"); + + b.Property("ReleasedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("released_at"); + + b.Property("ResourceId") + .HasColumnType("uuid") + .HasColumnName("resource_id"); + + b.Property("ResourceType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("resource_type"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("ix_task_locks_expires_at"); + + b.HasIndex("LockHolderId") + .HasDatabaseName("ix_task_locks_lock_holder_id"); + + b.HasIndex("Status") + .HasDatabaseName("ix_task_locks_status"); + + b.HasIndex("TenantId", "ResourceType", "ResourceId") + .IsUnique() + .HasDatabaseName("ix_task_locks_resource"); + + b.ToTable("task_locks", "mcp"); + }); #pragma warning restore 612, 618 } } diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Persistence/Repositories/PendingChangeRepository.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Persistence/Repositories/PendingChangeRepository.cs new file mode 100644 index 0000000..ca07205 --- /dev/null +++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Persistence/Repositories/PendingChangeRepository.cs @@ -0,0 +1,115 @@ +using ColaFlow.Modules.Mcp.Domain.Entities; +using ColaFlow.Modules.Mcp.Domain.Repositories; +using ColaFlow.Modules.Mcp.Domain.ValueObjects; +using Microsoft.EntityFrameworkCore; + +namespace ColaFlow.Modules.Mcp.Infrastructure.Persistence.Repositories; + +/// +/// Repository implementation for PendingChange aggregate +/// +public sealed class PendingChangeRepository : IPendingChangeRepository +{ + private readonly McpDbContext _context; + + public PendingChangeRepository(McpDbContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _context.PendingChanges + .FirstOrDefaultAsync(p => p.Id == id, cancellationToken); + } + + public async Task> GetByTenantAsync( + Guid tenantId, + CancellationToken cancellationToken = default) + { + return await _context.PendingChanges + .Where(p => p.TenantId == tenantId) + .OrderByDescending(p => p.CreatedAt) + .ToListAsync(cancellationToken); + } + + public async Task> GetByStatusAsync( + Guid tenantId, + PendingChangeStatus status, + CancellationToken cancellationToken = default) + { + return await _context.PendingChanges + .Where(p => p.TenantId == tenantId && p.Status == status) + .OrderByDescending(p => p.CreatedAt) + .ToListAsync(cancellationToken); + } + + public async Task> GetExpiredAsync( + CancellationToken cancellationToken = default) + { + var now = DateTime.UtcNow; + return await _context.PendingChanges + .Where(p => p.Status == PendingChangeStatus.PendingApproval && p.ExpiresAt < now) + .ToListAsync(cancellationToken); + } + + public async Task> GetByApiKeyAsync( + Guid apiKeyId, + CancellationToken cancellationToken = default) + { + return await _context.PendingChanges + .Where(p => p.ApiKeyId == apiKeyId) + .OrderByDescending(p => p.CreatedAt) + .ToListAsync(cancellationToken); + } + + public async Task> GetByEntityAsync( + Guid tenantId, + string entityType, + Guid entityId, + CancellationToken cancellationToken = default) + { + return await _context.PendingChanges + .Where(p => p.TenantId == tenantId + && p.Diff.EntityType == entityType + && p.Diff.EntityId == entityId) + .OrderByDescending(p => p.CreatedAt) + .ToListAsync(cancellationToken); + } + + public async Task HasPendingChangesForEntityAsync( + Guid tenantId, + string entityType, + Guid entityId, + CancellationToken cancellationToken = default) + { + return await _context.PendingChanges + .AnyAsync(p => p.TenantId == tenantId + && p.Diff.EntityType == entityType + && p.Diff.EntityId == entityId + && p.Status == PendingChangeStatus.PendingApproval, + cancellationToken); + } + + public async Task AddAsync(PendingChange pendingChange, CancellationToken cancellationToken = default) + { + await _context.PendingChanges.AddAsync(pendingChange, cancellationToken); + } + + public Task UpdateAsync(PendingChange pendingChange, CancellationToken cancellationToken = default) + { + _context.PendingChanges.Update(pendingChange); + return Task.CompletedTask; + } + + public Task DeleteAsync(PendingChange pendingChange, CancellationToken cancellationToken = default) + { + _context.PendingChanges.Remove(pendingChange); + return Task.CompletedTask; + } + + public async Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return await _context.SaveChangesAsync(cancellationToken); + } +} diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Persistence/Repositories/TaskLockRepository.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Persistence/Repositories/TaskLockRepository.cs new file mode 100644 index 0000000..ebed495 --- /dev/null +++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Persistence/Repositories/TaskLockRepository.cs @@ -0,0 +1,138 @@ +using ColaFlow.Modules.Mcp.Domain.Entities; +using ColaFlow.Modules.Mcp.Domain.Repositories; +using ColaFlow.Modules.Mcp.Domain.ValueObjects; +using Microsoft.EntityFrameworkCore; + +namespace ColaFlow.Modules.Mcp.Infrastructure.Persistence.Repositories; + +/// +/// Repository implementation for TaskLock aggregate +/// +public sealed class TaskLockRepository : ITaskLockRepository +{ + private readonly McpDbContext _context; + + public TaskLockRepository(McpDbContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _context.TaskLocks + .FirstOrDefaultAsync(t => t.Id == id, cancellationToken); + } + + public async Task> GetByTenantAsync( + Guid tenantId, + CancellationToken cancellationToken = default) + { + return await _context.TaskLocks + .Where(t => t.TenantId == tenantId) + .OrderByDescending(t => t.AcquiredAt) + .ToListAsync(cancellationToken); + } + + public async Task GetActiveLockForResourceAsync( + Guid tenantId, + string resourceType, + Guid resourceId, + CancellationToken cancellationToken = default) + { + var now = DateTime.UtcNow; + return await _context.TaskLocks + .FirstOrDefaultAsync(t => t.TenantId == tenantId + && t.ResourceType == resourceType + && t.ResourceId == resourceId + && t.Status == TaskLockStatus.Active + && t.ExpiresAt > now, + cancellationToken); + } + + public async Task> GetByLockHolderAsync( + Guid tenantId, + Guid lockHolderId, + CancellationToken cancellationToken = default) + { + return await _context.TaskLocks + .Where(t => t.TenantId == tenantId && t.LockHolderId == lockHolderId) + .OrderByDescending(t => t.AcquiredAt) + .ToListAsync(cancellationToken); + } + + public async Task> GetByStatusAsync( + Guid tenantId, + TaskLockStatus status, + CancellationToken cancellationToken = default) + { + return await _context.TaskLocks + .Where(t => t.TenantId == tenantId && t.Status == status) + .OrderByDescending(t => t.AcquiredAt) + .ToListAsync(cancellationToken); + } + + public async Task> GetExpiredAsync( + CancellationToken cancellationToken = default) + { + var now = DateTime.UtcNow; + return await _context.TaskLocks + .Where(t => t.Status == TaskLockStatus.Active && t.ExpiresAt < now) + .ToListAsync(cancellationToken); + } + + public async Task IsResourceLockedAsync( + Guid tenantId, + string resourceType, + Guid resourceId, + CancellationToken cancellationToken = default) + { + var now = DateTime.UtcNow; + return await _context.TaskLocks + .AnyAsync(t => t.TenantId == tenantId + && t.ResourceType == resourceType + && t.ResourceId == resourceId + && t.Status == TaskLockStatus.Active + && t.ExpiresAt > now, + cancellationToken); + } + + public async Task IsResourceLockedByAsync( + Guid tenantId, + string resourceType, + Guid resourceId, + Guid lockHolderId, + CancellationToken cancellationToken = default) + { + var now = DateTime.UtcNow; + return await _context.TaskLocks + .AnyAsync(t => t.TenantId == tenantId + && t.ResourceType == resourceType + && t.ResourceId == resourceId + && t.LockHolderId == lockHolderId + && t.Status == TaskLockStatus.Active + && t.ExpiresAt > now, + cancellationToken); + } + + public async Task AddAsync(TaskLock taskLock, CancellationToken cancellationToken = default) + { + await _context.TaskLocks.AddAsync(taskLock, cancellationToken); + } + + public Task UpdateAsync(TaskLock taskLock, CancellationToken cancellationToken = default) + { + _context.TaskLocks.Update(taskLock); + return Task.CompletedTask; + } + + public Task DeleteAsync(TaskLock taskLock, CancellationToken cancellationToken = default) + { + _context.TaskLocks.Remove(taskLock); + return Task.CompletedTask; + } + + public async Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return await _context.SaveChangesAsync(cancellationToken); + } +} diff --git a/colaflow-api/tests/Modules/Mcp/ColaFlow.Modules.Mcp.Tests/Domain/DiffPreviewServiceTests.cs b/colaflow-api/tests/Modules/Mcp/ColaFlow.Modules.Mcp.Tests/Domain/DiffPreviewServiceTests.cs new file mode 100644 index 0000000..0fbe6c3 --- /dev/null +++ b/colaflow-api/tests/Modules/Mcp/ColaFlow.Modules.Mcp.Tests/Domain/DiffPreviewServiceTests.cs @@ -0,0 +1,98 @@ +using ColaFlow.Modules.Mcp.Domain.Services; +using FluentAssertions; + +namespace ColaFlow.Modules.Mcp.Tests.Domain; + +public class DiffPreviewServiceTests +{ + private readonly DiffPreviewService _service; + + public DiffPreviewServiceTests() + { + _service = new DiffPreviewService(); + } + + private class TestEntity + { + public Guid Id { get; set; } + public string Title { get; set; } = string.Empty; + public string? Description { get; set; } + public int Priority { get; set; } + public DateTime CreatedAt { get; set; } + } + + [Fact] + public void GenerateCreateDiff_WithValidEntity_ShouldCreateDiff() + { + var entity = new TestEntity + { + Id = Guid.NewGuid(), + Title = "Test Entity", + Description = "Test Description", + Priority = 1, + CreatedAt = DateTime.UtcNow + }; + + var diff = _service.GenerateCreateDiff("TestEntity", entity, "TEST-001"); + + diff.Should().NotBeNull(); + diff.Operation.Should().Be("CREATE"); + diff.EntityType.Should().Be("TestEntity"); + diff.EntityKey.Should().Be("TEST-001"); + diff.EntityId.Should().BeNull(); + diff.BeforeData.Should().BeNull(); + diff.AfterData.Should().NotBeNullOrEmpty(); + diff.AfterData.Should().Contain("Test Entity"); + } + + [Fact] + public void GenerateUpdateDiff_WithChangedFields_ShouldDetectChanges() + { + var entityId = Guid.NewGuid(); + var createdAt = DateTime.UtcNow; + + var beforeEntity = new TestEntity + { + Id = entityId, + Title = "Original Title", + Description = "Original Description", + Priority = 1, + CreatedAt = createdAt + }; + + var afterEntity = new TestEntity + { + Id = entityId, + Title = "Updated Title", + Description = "Updated Description", + Priority = 1, + CreatedAt = createdAt + }; + + var diff = _service.GenerateUpdateDiff("TestEntity", entityId, beforeEntity, afterEntity, "TEST-003"); + + diff.Should().NotBeNull(); + diff.Operation.Should().Be("UPDATE"); + diff.ChangedFields.Should().HaveCount(2); + diff.ChangedFields.Should().Contain(f => f.FieldName == "Title"); + diff.ChangedFields.Should().Contain(f => f.FieldName == "Description"); + } + + [Fact] + public void GenerateHtmlDiff_WithUpdateOperation_ShouldGenerateTable() + { + var entityId = Guid.NewGuid(); + var beforeEntity = new TestEntity { Id = entityId, Title = "Before", Priority = 1 }; + var afterEntity = new TestEntity { Id = entityId, Title = "After", Priority = 2 }; + var diff = _service.GenerateUpdateDiff("TestEntity", entityId, beforeEntity, afterEntity, "TEST-003"); + + var html = _service.GenerateHtmlDiff(diff); + + html.Should().NotBeNullOrEmpty(); + html.Should().Contain("UPDATE"); + html.Should().Contain("TestEntity"); + html.Should().Contain("