From c6760d7170117dbdfa89d4e22412d3f9d14398d2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 19:42:39 +0000 Subject: [PATCH 01/30] Initial plan From 9b2a7efd1ce613baec73c34b3e6818b8bfb981ea Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 19:47:43 +0000 Subject: [PATCH 02/30] Add domain entities for archive, chat, and ops CLI features Co-authored-by: Chris0Jeky <59696583+Chris0Jeky@users.noreply.github.com> --- .../Interfaces/IUserContext.cs | 25 +++ .../Taskdeck.Domain/Entities/ArchiveItem.cs | 106 +++++++++++ .../Entities/AutomationProposal.cs | 176 ++++++++++++++++++ .../Entities/AutomationProposalOperation.cs | 54 ++++++ .../Taskdeck.Domain/Entities/ChatMessage.cs | 71 +++++++ .../Taskdeck.Domain/Entities/ChatSession.cs | 79 ++++++++ .../Taskdeck.Domain/Entities/CommandRun.cs | 127 +++++++++++++ .../Taskdeck.Domain/Entities/CommandRunLog.cs | 45 +++++ .../Exceptions/DomainException.cs | 1 + .../Identity/UserContext.cs | 36 ++++ 10 files changed, 720 insertions(+) create mode 100644 backend/src/Taskdeck.Application/Interfaces/IUserContext.cs create mode 100644 backend/src/Taskdeck.Domain/Entities/ArchiveItem.cs create mode 100644 backend/src/Taskdeck.Domain/Entities/AutomationProposal.cs create mode 100644 backend/src/Taskdeck.Domain/Entities/AutomationProposalOperation.cs create mode 100644 backend/src/Taskdeck.Domain/Entities/ChatMessage.cs create mode 100644 backend/src/Taskdeck.Domain/Entities/ChatSession.cs create mode 100644 backend/src/Taskdeck.Domain/Entities/CommandRun.cs create mode 100644 backend/src/Taskdeck.Domain/Entities/CommandRunLog.cs create mode 100644 backend/src/Taskdeck.Infrastructure/Identity/UserContext.cs diff --git a/backend/src/Taskdeck.Application/Interfaces/IUserContext.cs b/backend/src/Taskdeck.Application/Interfaces/IUserContext.cs new file mode 100644 index 000000000..13c2def2b --- /dev/null +++ b/backend/src/Taskdeck.Application/Interfaces/IUserContext.cs @@ -0,0 +1,25 @@ +namespace Taskdeck.Application.Interfaces; + +/// +/// Provides access to the current authenticated user's identity from JWT claims. +/// This is the single source of truth for actor identity - never trust client-supplied user IDs. +/// +public interface IUserContext +{ + /// + /// Gets the current authenticated user's ID from JWT claims. + /// Returns null if no user is authenticated. + /// + string? UserId { get; } + + /// + /// Gets whether a user is currently authenticated. + /// + bool IsAuthenticated { get; } + + /// + /// Gets the current user's role from JWT claims. + /// Returns null if no role is present. + /// + string? Role { get; } +} diff --git a/backend/src/Taskdeck.Domain/Entities/ArchiveItem.cs b/backend/src/Taskdeck.Domain/Entities/ArchiveItem.cs new file mode 100644 index 000000000..2b729010c --- /dev/null +++ b/backend/src/Taskdeck.Domain/Entities/ArchiveItem.cs @@ -0,0 +1,106 @@ +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Exceptions; + +namespace Taskdeck.Domain.Entities; + +public class ArchiveItem : Entity +{ + public string EntityType { get; private set; } + public string EntityId { get; private set; } + public string BoardId { get; private set; } + public string Name { get; private set; } + public string ArchivedByUserId { get; private set; } + public DateTime ArchivedAt { get; private set; } + public string? Reason { get; private set; } + public string SnapshotJson { get; private set; } + public RestoreStatus RestoreStatus { get; private set; } + public DateTime? RestoredAt { get; private set; } + public string? RestoredByUserId { get; private set; } + + private ArchiveItem() { } // EF Core + + public ArchiveItem( + string entityType, + string entityId, + string boardId, + string name, + string archivedByUserId, + string snapshotJson, + string? reason = null) + { + if (string.IsNullOrWhiteSpace(entityType)) + throw new DomainException(ErrorCodes.ValidationError, "EntityType cannot be empty"); + if (entityType != "board" && entityType != "column" && entityType != "card") + throw new DomainException(ErrorCodes.ValidationError, "EntityType must be 'board', 'column', or 'card'"); + if (string.IsNullOrWhiteSpace(entityId)) + throw new DomainException(ErrorCodes.ValidationError, "EntityId cannot be empty"); + if (string.IsNullOrWhiteSpace(boardId)) + throw new DomainException(ErrorCodes.ValidationError, "BoardId cannot be empty"); + if (string.IsNullOrWhiteSpace(name)) + throw new DomainException(ErrorCodes.ValidationError, "Name cannot be empty"); + if (name.Length > 200) + throw new DomainException(ErrorCodes.ValidationError, "Name cannot exceed 200 characters"); + if (string.IsNullOrWhiteSpace(archivedByUserId)) + throw new DomainException(ErrorCodes.ValidationError, "ArchivedByUserId cannot be empty"); + if (string.IsNullOrWhiteSpace(snapshotJson)) + throw new DomainException(ErrorCodes.ValidationError, "SnapshotJson cannot be empty"); + + EntityType = entityType; + EntityId = entityId; + BoardId = boardId; + Name = name; + ArchivedByUserId = archivedByUserId; + ArchivedAt = DateTime.UtcNow; + Reason = reason; + SnapshotJson = snapshotJson; + RestoreStatus = RestoreStatus.Available; + } + + public void MarkAsRestored(string restoredByUserId) + { + if (string.IsNullOrWhiteSpace(restoredByUserId)) + throw new DomainException(ErrorCodes.ValidationError, "RestoredByUserId cannot be empty"); + if (RestoreStatus != RestoreStatus.Available) + throw new DomainException(ErrorCodes.InvalidOperation, $"Cannot restore archive item with status {RestoreStatus}"); + + RestoreStatus = RestoreStatus.Restored; + RestoredAt = DateTime.UtcNow; + RestoredByUserId = restoredByUserId; + Touch(); + } + + public void MarkAsExpired() + { + if (RestoreStatus != RestoreStatus.Available) + throw new DomainException(ErrorCodes.InvalidOperation, $"Cannot expire archive item with status {RestoreStatus}"); + + RestoreStatus = RestoreStatus.Expired; + Touch(); + } + + public void MarkAsConflict() + { + if (RestoreStatus != RestoreStatus.Available) + throw new DomainException(ErrorCodes.InvalidOperation, $"Cannot mark conflict for archive item with status {RestoreStatus}"); + + RestoreStatus = RestoreStatus.Conflict; + Touch(); + } + + public void ResetToAvailable() + { + if (RestoreStatus == RestoreStatus.Restored) + throw new DomainException(ErrorCodes.InvalidOperation, "Cannot reset already restored archive item"); + + RestoreStatus = RestoreStatus.Available; + Touch(); + } +} + +public enum RestoreStatus +{ + Available, + Restored, + Expired, + Conflict +} diff --git a/backend/src/Taskdeck.Domain/Entities/AutomationProposal.cs b/backend/src/Taskdeck.Domain/Entities/AutomationProposal.cs new file mode 100644 index 000000000..dff1527c5 --- /dev/null +++ b/backend/src/Taskdeck.Domain/Entities/AutomationProposal.cs @@ -0,0 +1,176 @@ +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Exceptions; + +namespace Taskdeck.Domain.Entities; + +public class AutomationProposal : Entity +{ + public ProposalSourceType SourceType { get; private set; } + public string? SourceReferenceId { get; private set; } + public string? BoardId { get; private set; } + public string RequestedByUserId { get; private set; } + public ProposalStatus Status { get; private set; } + public RiskLevel RiskLevel { get; private set; } + public string Summary { get; private set; } + public string? DiffPreview { get; private set; } + public string? ValidationIssues { get; private set; } + public DateTime ExpiresAt { get; private set; } + public DateTime? DecidedAt { get; private set; } + public string? DecidedByUserId { get; private set; } + public DateTime? AppliedAt { get; private set; } + public string? FailureReason { get; private set; } + public string CorrelationId { get; private set; } + + private readonly List _operations = new(); + public IReadOnlyList Operations => _operations.AsReadOnly(); + + private AutomationProposal() { } // EF Core + + public AutomationProposal( + ProposalSourceType sourceType, + string requestedByUserId, + string summary, + RiskLevel riskLevel, + string correlationId, + string? boardId = null, + string? sourceReferenceId = null, + int expiryMinutes = 1440) + { + if (string.IsNullOrWhiteSpace(requestedByUserId)) + throw new DomainException(ErrorCodes.ValidationError, "RequestedByUserId cannot be empty"); + if (string.IsNullOrWhiteSpace(summary)) + throw new DomainException(ErrorCodes.ValidationError, "Summary cannot be empty"); + if (summary.Length > 500) + throw new DomainException(ErrorCodes.ValidationError, "Summary cannot exceed 500 characters"); + if (string.IsNullOrWhiteSpace(correlationId)) + throw new DomainException(ErrorCodes.ValidationError, "CorrelationId cannot be empty"); + if (expiryMinutes <= 0) + throw new DomainException(ErrorCodes.ValidationError, "ExpiryMinutes must be positive"); + + SourceType = sourceType; + SourceReferenceId = sourceReferenceId; + BoardId = boardId; + RequestedByUserId = requestedByUserId; + Status = ProposalStatus.PendingReview; + RiskLevel = riskLevel; + Summary = summary; + CorrelationId = correlationId; + ExpiresAt = DateTime.UtcNow.AddMinutes(expiryMinutes); + } + + public void AddOperation(AutomationProposalOperation operation) + { + if (Status != ProposalStatus.PendingReview) + throw new DomainException(ErrorCodes.InvalidOperation, "Cannot add operations after proposal has been decided"); + + _operations.Add(operation); + Touch(); + } + + public void Approve(string decidedByUserId) + { + if (string.IsNullOrWhiteSpace(decidedByUserId)) + throw new DomainException(ErrorCodes.ValidationError, "DecidedByUserId cannot be empty"); + if (Status != ProposalStatus.PendingReview) + throw new DomainException(ErrorCodes.InvalidOperation, $"Cannot approve proposal in status {Status}"); + if (DateTime.UtcNow > ExpiresAt) + throw new DomainException(ErrorCodes.InvalidOperation, "Cannot approve expired proposal"); + + Status = ProposalStatus.Approved; + DecidedByUserId = decidedByUserId; + DecidedAt = DateTime.UtcNow; + Touch(); + } + + public void Reject(string decidedByUserId, string? reason = null) + { + if (string.IsNullOrWhiteSpace(decidedByUserId)) + throw new DomainException(ErrorCodes.ValidationError, "DecidedByUserId cannot be empty"); + if (Status != ProposalStatus.PendingReview) + throw new DomainException(ErrorCodes.InvalidOperation, $"Cannot reject proposal in status {Status}"); + + // High and Critical risk proposals require a reason for rejection + if ((RiskLevel == RiskLevel.High || RiskLevel == RiskLevel.Critical) && string.IsNullOrWhiteSpace(reason)) + throw new DomainException(ErrorCodes.ValidationError, "Rejection reason is required for High and Critical risk proposals"); + + Status = ProposalStatus.Rejected; + DecidedByUserId = decidedByUserId; + DecidedAt = DateTime.UtcNow; + FailureReason = reason; + Touch(); + } + + public void MarkAsApplied() + { + if (Status != ProposalStatus.Approved) + throw new DomainException(ErrorCodes.InvalidOperation, "Only approved proposals can be marked as applied"); + + Status = ProposalStatus.Applied; + AppliedAt = DateTime.UtcNow; + Touch(); + } + + public void MarkAsFailed(string failureReason) + { + if (string.IsNullOrWhiteSpace(failureReason)) + throw new DomainException(ErrorCodes.ValidationError, "FailureReason cannot be empty"); + if (Status != ProposalStatus.Approved) + throw new DomainException(ErrorCodes.InvalidOperation, "Only approved proposals can be marked as failed"); + + Status = ProposalStatus.Failed; + FailureReason = failureReason; + Touch(); + } + + public void Expire() + { + if (Status != ProposalStatus.PendingReview) + throw new DomainException(ErrorCodes.InvalidOperation, $"Cannot expire proposal in status {Status}"); + + Status = ProposalStatus.Expired; + Touch(); + } + + public void SetDiffPreview(string diffPreview) + { + if (Status != ProposalStatus.PendingReview) + throw new DomainException(ErrorCodes.InvalidOperation, "Cannot update diff preview after proposal has been decided"); + + DiffPreview = diffPreview; + Touch(); + } + + public void SetValidationIssues(string validationIssues) + { + if (Status != ProposalStatus.PendingReview) + throw new DomainException(ErrorCodes.InvalidOperation, "Cannot update validation issues after proposal has been decided"); + + ValidationIssues = validationIssues; + Touch(); + } +} + +public enum ProposalSourceType +{ + Queue, + Chat, + Manual +} + +public enum ProposalStatus +{ + PendingReview, + Approved, + Rejected, + Applied, + Failed, + Expired +} + +public enum RiskLevel +{ + Low, + Medium, + High, + Critical +} diff --git a/backend/src/Taskdeck.Domain/Entities/AutomationProposalOperation.cs b/backend/src/Taskdeck.Domain/Entities/AutomationProposalOperation.cs new file mode 100644 index 000000000..7c93e00cd --- /dev/null +++ b/backend/src/Taskdeck.Domain/Entities/AutomationProposalOperation.cs @@ -0,0 +1,54 @@ +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Exceptions; + +namespace Taskdeck.Domain.Entities; + +public class AutomationProposalOperation : Entity +{ + public string ProposalId { get; private set; } + public int Sequence { get; private set; } + public string ActionType { get; private set; } + public string TargetType { get; private set; } + public string? TargetId { get; private set; } + public string Parameters { get; private set; } // JSON payload + public string IdempotencyKey { get; private set; } + public string? ExpectedVersion { get; private set; } + + // Navigation + public AutomationProposal Proposal { get; private set; } = null!; + + private AutomationProposalOperation() { } // EF Core + + public AutomationProposalOperation( + string proposalId, + int sequence, + string actionType, + string targetType, + string parameters, + string idempotencyKey, + string? targetId = null, + string? expectedVersion = null) + { + if (string.IsNullOrWhiteSpace(proposalId)) + throw new DomainException(ErrorCodes.ValidationError, "ProposalId cannot be empty"); + if (sequence < 0) + throw new DomainException(ErrorCodes.ValidationError, "Sequence must be non-negative"); + if (string.IsNullOrWhiteSpace(actionType)) + throw new DomainException(ErrorCodes.ValidationError, "ActionType cannot be empty"); + if (string.IsNullOrWhiteSpace(targetType)) + throw new DomainException(ErrorCodes.ValidationError, "TargetType cannot be empty"); + if (string.IsNullOrWhiteSpace(parameters)) + throw new DomainException(ErrorCodes.ValidationError, "Parameters cannot be empty"); + if (string.IsNullOrWhiteSpace(idempotencyKey)) + throw new DomainException(ErrorCodes.ValidationError, "IdempotencyKey cannot be empty"); + + ProposalId = proposalId; + Sequence = sequence; + ActionType = actionType; + TargetType = targetType; + TargetId = targetId; + Parameters = parameters; + IdempotencyKey = idempotencyKey; + ExpectedVersion = expectedVersion; + } +} diff --git a/backend/src/Taskdeck.Domain/Entities/ChatMessage.cs b/backend/src/Taskdeck.Domain/Entities/ChatMessage.cs new file mode 100644 index 000000000..1eff2b92f --- /dev/null +++ b/backend/src/Taskdeck.Domain/Entities/ChatMessage.cs @@ -0,0 +1,71 @@ +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Exceptions; + +namespace Taskdeck.Domain.Entities; + +public class ChatMessage : Entity +{ + public string SessionId { get; private set; } + public ChatMessageRole Role { get; private set; } + public string Content { get; private set; } + public string MessageType { get; private set; } + public string? ProposalId { get; private set; } + public int? TokenUsage { get; private set; } + + // Navigation + public ChatSession Session { get; private set; } = null!; + + private ChatMessage() { } // EF Core + + public ChatMessage( + string sessionId, + ChatMessageRole role, + string content, + string messageType = "text", + string? proposalId = null, + int? tokenUsage = null) + { + if (string.IsNullOrWhiteSpace(sessionId)) + throw new DomainException(ErrorCodes.ValidationError, "SessionId cannot be empty"); + if (string.IsNullOrWhiteSpace(content)) + throw new DomainException(ErrorCodes.ValidationError, "Content cannot be empty"); + if (string.IsNullOrWhiteSpace(messageType)) + throw new DomainException(ErrorCodes.ValidationError, "MessageType cannot be empty"); + if (messageType != "text" && messageType != "proposal-reference" && messageType != "error" && messageType != "status") + throw new DomainException(ErrorCodes.ValidationError, "MessageType must be 'text', 'proposal-reference', 'error', or 'status'"); + if (tokenUsage.HasValue && tokenUsage.Value < 0) + throw new DomainException(ErrorCodes.ValidationError, "TokenUsage must be non-negative"); + + SessionId = sessionId; + Role = role; + Content = content; + MessageType = messageType; + ProposalId = proposalId; + TokenUsage = tokenUsage; + } + + public void SetTokenUsage(int tokenUsage) + { + if (tokenUsage < 0) + throw new DomainException(ErrorCodes.ValidationError, "TokenUsage must be non-negative"); + + TokenUsage = tokenUsage; + Touch(); + } + + public void SetProposalId(string proposalId) + { + if (string.IsNullOrWhiteSpace(proposalId)) + throw new DomainException(ErrorCodes.ValidationError, "ProposalId cannot be empty"); + + ProposalId = proposalId; + Touch(); + } +} + +public enum ChatMessageRole +{ + User, + Assistant, + System +} diff --git a/backend/src/Taskdeck.Domain/Entities/ChatSession.cs b/backend/src/Taskdeck.Domain/Entities/ChatSession.cs new file mode 100644 index 000000000..d5ef5b1eb --- /dev/null +++ b/backend/src/Taskdeck.Domain/Entities/ChatSession.cs @@ -0,0 +1,79 @@ +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Exceptions; + +namespace Taskdeck.Domain.Entities; + +public class ChatSession : Entity +{ + public string UserId { get; private set; } + public string? BoardId { get; private set; } + public string Title { get; private set; } + public ChatSessionStatus Status { get; private set; } + + private readonly List _messages = new(); + public IReadOnlyList Messages => _messages.AsReadOnly(); + + private ChatSession() { } // EF Core + + public ChatSession( + string userId, + string title, + string? boardId = null) + { + if (string.IsNullOrWhiteSpace(userId)) + throw new DomainException(ErrorCodes.ValidationError, "UserId cannot be empty"); + if (string.IsNullOrWhiteSpace(title)) + throw new DomainException(ErrorCodes.ValidationError, "Title cannot be empty"); + if (title.Length > 200) + throw new DomainException(ErrorCodes.ValidationError, "Title cannot exceed 200 characters"); + + UserId = userId; + BoardId = boardId; + Title = title; + Status = ChatSessionStatus.Active; + } + + public void UpdateTitle(string title) + { + if (string.IsNullOrWhiteSpace(title)) + throw new DomainException(ErrorCodes.ValidationError, "Title cannot be empty"); + if (title.Length > 200) + throw new DomainException(ErrorCodes.ValidationError, "Title cannot exceed 200 characters"); + + Title = title; + Touch(); + } + + public void Archive() + { + if (Status == ChatSessionStatus.Archived) + throw new DomainException(ErrorCodes.InvalidOperation, "Session is already archived"); + + Status = ChatSessionStatus.Archived; + Touch(); + } + + public void Reactivate() + { + if (Status == ChatSessionStatus.Active) + throw new DomainException(ErrorCodes.InvalidOperation, "Session is already active"); + + Status = ChatSessionStatus.Active; + Touch(); + } + + public void AddMessage(ChatMessage message) + { + if (Status == ChatSessionStatus.Archived) + throw new DomainException(ErrorCodes.InvalidOperation, "Cannot add messages to archived session"); + + _messages.Add(message); + Touch(); + } +} + +public enum ChatSessionStatus +{ + Active, + Archived +} diff --git a/backend/src/Taskdeck.Domain/Entities/CommandRun.cs b/backend/src/Taskdeck.Domain/Entities/CommandRun.cs new file mode 100644 index 000000000..17d4d1940 --- /dev/null +++ b/backend/src/Taskdeck.Domain/Entities/CommandRun.cs @@ -0,0 +1,127 @@ +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Exceptions; + +namespace Taskdeck.Domain.Entities; + +public class CommandRun : Entity +{ + public string TemplateName { get; private set; } + public string RequestedByUserId { get; private set; } + public CommandRunStatus Status { get; private set; } + public DateTime? StartedAt { get; private set; } + public DateTime? CompletedAt { get; private set; } + public int? ExitCode { get; private set; } + public bool Truncated { get; private set; } + public string CorrelationId { get; private set; } + public string? ErrorMessage { get; private set; } + public string? OutputPreview { get; private set; } + + private readonly List _logs = new(); + public IReadOnlyList Logs => _logs.AsReadOnly(); + + private CommandRun() { } // EF Core + + public CommandRun( + string templateName, + string requestedByUserId, + string correlationId) + { + if (string.IsNullOrWhiteSpace(templateName)) + throw new DomainException(ErrorCodes.ValidationError, "TemplateName cannot be empty"); + if (string.IsNullOrWhiteSpace(requestedByUserId)) + throw new DomainException(ErrorCodes.ValidationError, "RequestedByUserId cannot be empty"); + if (string.IsNullOrWhiteSpace(correlationId)) + throw new DomainException(ErrorCodes.ValidationError, "CorrelationId cannot be empty"); + + TemplateName = templateName; + RequestedByUserId = requestedByUserId; + Status = CommandRunStatus.Queued; + CorrelationId = correlationId; + Truncated = false; + } + + public void Start() + { + if (Status != CommandRunStatus.Queued) + throw new DomainException(ErrorCodes.InvalidOperation, $"Cannot start command run in status {Status}"); + + Status = CommandRunStatus.Running; + StartedAt = DateTime.UtcNow; + Touch(); + } + + public void Complete(int exitCode) + { + if (Status != CommandRunStatus.Running) + throw new DomainException(ErrorCodes.InvalidOperation, $"Cannot complete command run in status {Status}"); + + Status = CommandRunStatus.Completed; + CompletedAt = DateTime.UtcNow; + ExitCode = exitCode; + Touch(); + } + + public void Fail(string errorMessage) + { + if (string.IsNullOrWhiteSpace(errorMessage)) + throw new DomainException(ErrorCodes.ValidationError, "ErrorMessage cannot be empty"); + if (Status != CommandRunStatus.Queued && Status != CommandRunStatus.Running) + throw new DomainException(ErrorCodes.InvalidOperation, $"Cannot fail command run in status {Status}"); + + Status = CommandRunStatus.Failed; + CompletedAt = DateTime.UtcNow; + ErrorMessage = errorMessage; + Touch(); + } + + public void Timeout() + { + if (Status != CommandRunStatus.Running) + throw new DomainException(ErrorCodes.InvalidOperation, $"Cannot timeout command run in status {Status}"); + + Status = CommandRunStatus.TimedOut; + CompletedAt = DateTime.UtcNow; + Touch(); + } + + public void Cancel() + { + if (Status != CommandRunStatus.Queued && Status != CommandRunStatus.Running) + throw new DomainException(ErrorCodes.InvalidOperation, $"Cannot cancel command run in status {Status}"); + + Status = CommandRunStatus.Cancelled; + CompletedAt = DateTime.UtcNow; + Touch(); + } + + public void SetTruncated() + { + Truncated = true; + Touch(); + } + + public void SetOutputPreview(string outputPreview) + { + if (outputPreview != null && outputPreview.Length > 1000) + throw new DomainException(ErrorCodes.ValidationError, "OutputPreview cannot exceed 1000 characters"); + + OutputPreview = outputPreview; + Touch(); + } + + public void AddLog(CommandRunLog log) + { + _logs.Add(log); + Touch(); + } +} + +public enum CommandRunStatus +{ + Queued, + Running, + Completed, + Failed, + TimedOut, + Cancelled +} diff --git a/backend/src/Taskdeck.Domain/Entities/CommandRunLog.cs b/backend/src/Taskdeck.Domain/Entities/CommandRunLog.cs new file mode 100644 index 000000000..33263d2bd --- /dev/null +++ b/backend/src/Taskdeck.Domain/Entities/CommandRunLog.cs @@ -0,0 +1,45 @@ +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Exceptions; + +namespace Taskdeck.Domain.Entities; + +public class CommandRunLog : Entity +{ + public string CommandRunId { get; private set; } + public DateTime Timestamp { get; private set; } + public string Level { get; private set; } + public string Source { get; private set; } + public string Message { get; private set; } + public string? Metadata { get; private set; } // JSON + + // Navigation + public CommandRun CommandRun { get; private set; } = null!; + + private CommandRunLog() { } // EF Core + + public CommandRunLog( + string commandRunId, + string level, + string source, + string message, + string? metadata = null) + { + if (string.IsNullOrWhiteSpace(commandRunId)) + throw new DomainException(ErrorCodes.ValidationError, "CommandRunId cannot be empty"); + if (string.IsNullOrWhiteSpace(level)) + throw new DomainException(ErrorCodes.ValidationError, "Level cannot be empty"); + if (level != "Debug" && level != "Info" && level != "Warning" && level != "Error") + throw new DomainException(ErrorCodes.ValidationError, "Level must be 'Debug', 'Info', 'Warning', or 'Error'"); + if (string.IsNullOrWhiteSpace(source)) + throw new DomainException(ErrorCodes.ValidationError, "Source cannot be empty"); + if (string.IsNullOrWhiteSpace(message)) + throw new DomainException(ErrorCodes.ValidationError, "Message cannot be empty"); + + CommandRunId = commandRunId; + Timestamp = DateTime.UtcNow; + Level = level; + Source = source; + Message = message; + Metadata = metadata; + } +} diff --git a/backend/src/Taskdeck.Domain/Exceptions/DomainException.cs b/backend/src/Taskdeck.Domain/Exceptions/DomainException.cs index 986ec476f..51a485da0 100644 --- a/backend/src/Taskdeck.Domain/Exceptions/DomainException.cs +++ b/backend/src/Taskdeck.Domain/Exceptions/DomainException.cs @@ -26,4 +26,5 @@ public static class ErrorCodes public const string Unauthorized = "Unauthorized"; public const string Forbidden = "Forbidden"; public const string AuthenticationFailed = "AuthenticationFailed"; + public const string InvalidOperation = "InvalidOperation"; } diff --git a/backend/src/Taskdeck.Infrastructure/Identity/UserContext.cs b/backend/src/Taskdeck.Infrastructure/Identity/UserContext.cs new file mode 100644 index 000000000..3d9843e1e --- /dev/null +++ b/backend/src/Taskdeck.Infrastructure/Identity/UserContext.cs @@ -0,0 +1,36 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Taskdeck.Application.Interfaces; + +namespace Taskdeck.Infrastructure.Identity; + +/// +/// Implementation of IUserContext that extracts user identity from HTTP context claims. +/// +public class UserContext : IUserContext +{ + private readonly IHttpContextAccessor _httpContextAccessor; + + public UserContext(IHttpContextAccessor httpContextAccessor) + { + _httpContextAccessor = httpContextAccessor; + } + + public string? UserId + { + get + { + var user = _httpContextAccessor.HttpContext?.User; + if (user == null || !user.Identity?.IsAuthenticated == true) + return null; + + // Try both 'sub' (standard JWT claim) and 'nameid' (alternative) + return user.FindFirst(ClaimTypes.NameIdentifier)?.Value + ?? user.FindFirst("sub")?.Value; + } + } + + public bool IsAuthenticated => _httpContextAccessor.HttpContext?.User?.Identity?.IsAuthenticated == true; + + public string? Role => _httpContextAccessor.HttpContext?.User?.FindFirst(ClaimTypes.Role)?.Value; +} From aabd795dde6b84d2c81c356232f47e7799067cca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 19:52:31 +0000 Subject: [PATCH 03/30] Add repository interfaces for new domain entities Co-authored-by: Chris0Jeky <59696583+Chris0Jeky@users.noreply.github.com> --- .../Interfaces/IArchiveItemRepository.cs | 12 ++++++++++++ .../Interfaces/IAutomationProposalRepository.cs | 14 ++++++++++++++ .../Interfaces/IChatMessageRepository.cs | 9 +++++++++ .../Interfaces/IChatSessionRepository.cs | 11 +++++++++++ .../Interfaces/ICommandRunRepository.cs | 12 ++++++++++++ .../Interfaces/IUnitOfWork.cs | 5 +++++ .../Repositories/UnitOfWork.cs | 17 ++++++++++++++++- 7 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 backend/src/Taskdeck.Application/Interfaces/IArchiveItemRepository.cs create mode 100644 backend/src/Taskdeck.Application/Interfaces/IAutomationProposalRepository.cs create mode 100644 backend/src/Taskdeck.Application/Interfaces/IChatMessageRepository.cs create mode 100644 backend/src/Taskdeck.Application/Interfaces/IChatSessionRepository.cs create mode 100644 backend/src/Taskdeck.Application/Interfaces/ICommandRunRepository.cs diff --git a/backend/src/Taskdeck.Application/Interfaces/IArchiveItemRepository.cs b/backend/src/Taskdeck.Application/Interfaces/IArchiveItemRepository.cs new file mode 100644 index 000000000..6aad8930f --- /dev/null +++ b/backend/src/Taskdeck.Application/Interfaces/IArchiveItemRepository.cs @@ -0,0 +1,12 @@ +using Taskdeck.Domain.Entities; + +namespace Taskdeck.Application.Interfaces; + +public interface IArchiveItemRepository : IRepository +{ + Task> GetByBoardIdAsync(string boardId, int limit = 100, CancellationToken cancellationToken = default); + Task> GetByEntityTypeAsync(string entityType, int limit = 100, CancellationToken cancellationToken = default); + Task> GetByStatusAsync(RestoreStatus status, int limit = 100, CancellationToken cancellationToken = default); + Task GetByEntityAsync(string entityType, string entityId, CancellationToken cancellationToken = default); + Task> GetByUserIdAsync(string userId, int limit = 100, CancellationToken cancellationToken = default); +} diff --git a/backend/src/Taskdeck.Application/Interfaces/IAutomationProposalRepository.cs b/backend/src/Taskdeck.Application/Interfaces/IAutomationProposalRepository.cs new file mode 100644 index 000000000..ff5f12809 --- /dev/null +++ b/backend/src/Taskdeck.Application/Interfaces/IAutomationProposalRepository.cs @@ -0,0 +1,14 @@ +using Taskdeck.Domain.Entities; + +namespace Taskdeck.Application.Interfaces; + +public interface IAutomationProposalRepository : IRepository +{ + Task> GetByStatusAsync(ProposalStatus status, int limit = 100, CancellationToken cancellationToken = default); + Task> GetByBoardIdAsync(string boardId, CancellationToken cancellationToken = default); + Task> GetByUserIdAsync(string userId, int limit = 100, CancellationToken cancellationToken = default); + Task> GetByRiskLevelAsync(RiskLevel riskLevel, int limit = 100, CancellationToken cancellationToken = default); + Task GetBySourceReferenceAsync(ProposalSourceType sourceType, string referenceId, CancellationToken cancellationToken = default); + Task GetByCorrelationIdAsync(string correlationId, CancellationToken cancellationToken = default); + Task> GetExpiredAsync(CancellationToken cancellationToken = default); +} diff --git a/backend/src/Taskdeck.Application/Interfaces/IChatMessageRepository.cs b/backend/src/Taskdeck.Application/Interfaces/IChatMessageRepository.cs new file mode 100644 index 000000000..fda001431 --- /dev/null +++ b/backend/src/Taskdeck.Application/Interfaces/IChatMessageRepository.cs @@ -0,0 +1,9 @@ +using Taskdeck.Domain.Entities; + +namespace Taskdeck.Application.Interfaces; + +public interface IChatMessageRepository : IRepository +{ + Task> GetBySessionIdAsync(string sessionId, int limit = 100, CancellationToken cancellationToken = default); + Task> GetByProposalIdAsync(string proposalId, CancellationToken cancellationToken = default); +} diff --git a/backend/src/Taskdeck.Application/Interfaces/IChatSessionRepository.cs b/backend/src/Taskdeck.Application/Interfaces/IChatSessionRepository.cs new file mode 100644 index 000000000..4202dd237 --- /dev/null +++ b/backend/src/Taskdeck.Application/Interfaces/IChatSessionRepository.cs @@ -0,0 +1,11 @@ +using Taskdeck.Domain.Entities; + +namespace Taskdeck.Application.Interfaces; + +public interface IChatSessionRepository : IRepository +{ + Task> GetByUserIdAsync(string userId, int limit = 100, CancellationToken cancellationToken = default); + Task> GetByBoardIdAsync(string boardId, int limit = 100, CancellationToken cancellationToken = default); + Task> GetByStatusAsync(ChatSessionStatus status, int limit = 100, CancellationToken cancellationToken = default); + Task GetByIdWithMessagesAsync(Guid id, CancellationToken cancellationToken = default); +} diff --git a/backend/src/Taskdeck.Application/Interfaces/ICommandRunRepository.cs b/backend/src/Taskdeck.Application/Interfaces/ICommandRunRepository.cs new file mode 100644 index 000000000..4f0f8213e --- /dev/null +++ b/backend/src/Taskdeck.Application/Interfaces/ICommandRunRepository.cs @@ -0,0 +1,12 @@ +using Taskdeck.Domain.Entities; + +namespace Taskdeck.Application.Interfaces; + +public interface ICommandRunRepository : IRepository +{ + Task> GetByUserIdAsync(string userId, int limit = 100, CancellationToken cancellationToken = default); + Task> GetByStatusAsync(CommandRunStatus status, int limit = 100, CancellationToken cancellationToken = default); + Task> GetByTemplateNameAsync(string templateName, int limit = 100, CancellationToken cancellationToken = default); + Task GetByCorrelationIdAsync(string correlationId, CancellationToken cancellationToken = default); + Task GetByIdWithLogsAsync(Guid id, CancellationToken cancellationToken = default); +} diff --git a/backend/src/Taskdeck.Application/Interfaces/IUnitOfWork.cs b/backend/src/Taskdeck.Application/Interfaces/IUnitOfWork.cs index 0dcf90fe7..32ca3d45f 100644 --- a/backend/src/Taskdeck.Application/Interfaces/IUnitOfWork.cs +++ b/backend/src/Taskdeck.Application/Interfaces/IUnitOfWork.cs @@ -10,6 +10,11 @@ public interface IUnitOfWork IBoardAccessRepository BoardAccesses { get; } IAuditLogRepository AuditLogs { get; } ILlmQueueRepository LlmQueue { get; } + IAutomationProposalRepository AutomationProposals { get; } + IArchiveItemRepository ArchiveItems { get; } + IChatSessionRepository ChatSessions { get; } + IChatMessageRepository ChatMessages { get; } + ICommandRunRepository CommandRuns { get; } Task SaveChangesAsync(CancellationToken cancellationToken = default); Task BeginTransactionAsync(CancellationToken cancellationToken = default); diff --git a/backend/src/Taskdeck.Infrastructure/Repositories/UnitOfWork.cs b/backend/src/Taskdeck.Infrastructure/Repositories/UnitOfWork.cs index dfd4cecf0..cc2cfdfdf 100644 --- a/backend/src/Taskdeck.Infrastructure/Repositories/UnitOfWork.cs +++ b/backend/src/Taskdeck.Infrastructure/Repositories/UnitOfWork.cs @@ -18,7 +18,12 @@ public UnitOfWork( IUserRepository users, IBoardAccessRepository boardAccesses, IAuditLogRepository auditLogs, - ILlmQueueRepository llmQueue) + ILlmQueueRepository llmQueue, + IAutomationProposalRepository automationProposals, + IArchiveItemRepository archiveItems, + IChatSessionRepository chatSessions, + IChatMessageRepository chatMessages, + ICommandRunRepository commandRuns) { _context = context; Boards = boards; @@ -29,6 +34,11 @@ public UnitOfWork( BoardAccesses = boardAccesses; AuditLogs = auditLogs; LlmQueue = llmQueue; + AutomationProposals = automationProposals; + ArchiveItems = archiveItems; + ChatSessions = chatSessions; + ChatMessages = chatMessages; + CommandRuns = commandRuns; } public IBoardRepository Boards { get; } @@ -39,6 +49,11 @@ public UnitOfWork( public IBoardAccessRepository BoardAccesses { get; } public IAuditLogRepository AuditLogs { get; } public ILlmQueueRepository LlmQueue { get; } + public IAutomationProposalRepository AutomationProposals { get; } + public IArchiveItemRepository ArchiveItems { get; } + public IChatSessionRepository ChatSessions { get; } + public IChatMessageRepository ChatMessages { get; } + public ICommandRunRepository CommandRuns { get; } public async Task SaveChangesAsync(CancellationToken cancellationToken = default) { From db4358765c5e553498966d480b2daf7e15f5cec1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 19:56:48 +0000 Subject: [PATCH 04/30] Implement repository classes for new entities Co-authored-by: Chris0Jeky <59696583+Chris0Jeky@users.noreply.github.com> --- .../DependencyInjection.cs | 5 ++ .../Repositories/ArchiveItemRepository.cs | 55 +++++++++++++++ .../AutomationProposalRepository.cs | 68 +++++++++++++++++++ .../Repositories/ChatMessageRepository.cs | 30 ++++++++ .../Repositories/ChatSessionRepository.cs | 47 +++++++++++++ .../Repositories/CommandRunRepository.cs | 53 +++++++++++++++ 6 files changed, 258 insertions(+) create mode 100644 backend/src/Taskdeck.Infrastructure/Repositories/ArchiveItemRepository.cs create mode 100644 backend/src/Taskdeck.Infrastructure/Repositories/AutomationProposalRepository.cs create mode 100644 backend/src/Taskdeck.Infrastructure/Repositories/ChatMessageRepository.cs create mode 100644 backend/src/Taskdeck.Infrastructure/Repositories/ChatSessionRepository.cs create mode 100644 backend/src/Taskdeck.Infrastructure/Repositories/CommandRunRepository.cs diff --git a/backend/src/Taskdeck.Infrastructure/DependencyInjection.cs b/backend/src/Taskdeck.Infrastructure/DependencyInjection.cs index 249365d8f..f8c1eaf67 100644 --- a/backend/src/Taskdeck.Infrastructure/DependencyInjection.cs +++ b/backend/src/Taskdeck.Infrastructure/DependencyInjection.cs @@ -25,6 +25,11 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); return services; diff --git a/backend/src/Taskdeck.Infrastructure/Repositories/ArchiveItemRepository.cs b/backend/src/Taskdeck.Infrastructure/Repositories/ArchiveItemRepository.cs new file mode 100644 index 000000000..edb11cba2 --- /dev/null +++ b/backend/src/Taskdeck.Infrastructure/Repositories/ArchiveItemRepository.cs @@ -0,0 +1,55 @@ +using Microsoft.EntityFrameworkCore; +using Taskdeck.Application.Interfaces; +using Taskdeck.Domain.Entities; +using Taskdeck.Infrastructure.Persistence; + +namespace Taskdeck.Infrastructure.Repositories; + +public class ArchiveItemRepository : Repository, IArchiveItemRepository +{ + public ArchiveItemRepository(TaskdeckDbContext context) : base(context) + { + } + + public async Task> GetByBoardIdAsync(string boardId, int limit = 100, CancellationToken cancellationToken = default) + { + return await _dbSet + .Where(a => a.BoardId == boardId) + .OrderByDescending(a => a.ArchivedAt) + .Take(limit) + .ToListAsync(cancellationToken); + } + + public async Task> GetByEntityTypeAsync(string entityType, int limit = 100, CancellationToken cancellationToken = default) + { + return await _dbSet + .Where(a => a.EntityType == entityType) + .OrderByDescending(a => a.ArchivedAt) + .Take(limit) + .ToListAsync(cancellationToken); + } + + public async Task> GetByStatusAsync(RestoreStatus status, int limit = 100, CancellationToken cancellationToken = default) + { + return await _dbSet + .Where(a => a.Status == status) + .OrderByDescending(a => a.ArchivedAt) + .Take(limit) + .ToListAsync(cancellationToken); + } + + public async Task GetByEntityAsync(string entityType, string entityId, CancellationToken cancellationToken = default) + { + return await _dbSet + .FirstOrDefaultAsync(a => a.EntityType == entityType && a.EntityId == entityId, cancellationToken); + } + + public async Task> GetByUserIdAsync(string userId, int limit = 100, CancellationToken cancellationToken = default) + { + return await _dbSet + .Where(a => a.ArchivedByUserId == userId) + .OrderByDescending(a => a.ArchivedAt) + .Take(limit) + .ToListAsync(cancellationToken); + } +} diff --git a/backend/src/Taskdeck.Infrastructure/Repositories/AutomationProposalRepository.cs b/backend/src/Taskdeck.Infrastructure/Repositories/AutomationProposalRepository.cs new file mode 100644 index 000000000..f7f3d5e6c --- /dev/null +++ b/backend/src/Taskdeck.Infrastructure/Repositories/AutomationProposalRepository.cs @@ -0,0 +1,68 @@ +using Microsoft.EntityFrameworkCore; +using Taskdeck.Application.Interfaces; +using Taskdeck.Domain.Entities; +using Taskdeck.Infrastructure.Persistence; + +namespace Taskdeck.Infrastructure.Repositories; + +public class AutomationProposalRepository : Repository, IAutomationProposalRepository +{ + public AutomationProposalRepository(TaskdeckDbContext context) : base(context) + { + } + + public async Task> GetByStatusAsync(ProposalStatus status, int limit = 100, CancellationToken cancellationToken = default) + { + return await _dbSet + .Where(p => p.Status == status) + .OrderByDescending(p => p.CreatedAt) + .Take(limit) + .ToListAsync(cancellationToken); + } + + public async Task> GetByBoardIdAsync(string boardId, CancellationToken cancellationToken = default) + { + return await _dbSet + .Where(p => p.BoardId == boardId) + .OrderByDescending(p => p.CreatedAt) + .ToListAsync(cancellationToken); + } + + public async Task> GetByUserIdAsync(string userId, int limit = 100, CancellationToken cancellationToken = default) + { + return await _dbSet + .Where(p => p.RequestedByUserId == userId) + .OrderByDescending(p => p.CreatedAt) + .Take(limit) + .ToListAsync(cancellationToken); + } + + public async Task> GetByRiskLevelAsync(RiskLevel riskLevel, int limit = 100, CancellationToken cancellationToken = default) + { + return await _dbSet + .Where(p => p.RiskLevel == riskLevel) + .OrderByDescending(p => p.CreatedAt) + .Take(limit) + .ToListAsync(cancellationToken); + } + + public async Task GetBySourceReferenceAsync(ProposalSourceType sourceType, string referenceId, CancellationToken cancellationToken = default) + { + return await _dbSet + .FirstOrDefaultAsync(p => p.SourceType == sourceType && p.SourceReferenceId == referenceId, cancellationToken); + } + + public async Task GetByCorrelationIdAsync(string correlationId, CancellationToken cancellationToken = default) + { + return await _dbSet + .FirstOrDefaultAsync(p => p.CorrelationId == correlationId, cancellationToken); + } + + public async Task> GetExpiredAsync(CancellationToken cancellationToken = default) + { + var now = DateTime.UtcNow; + return await _dbSet + .Where(p => p.Status == ProposalStatus.PendingReview && p.ExpiresAt < now) + .ToListAsync(cancellationToken); + } +} diff --git a/backend/src/Taskdeck.Infrastructure/Repositories/ChatMessageRepository.cs b/backend/src/Taskdeck.Infrastructure/Repositories/ChatMessageRepository.cs new file mode 100644 index 000000000..35c18d829 --- /dev/null +++ b/backend/src/Taskdeck.Infrastructure/Repositories/ChatMessageRepository.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore; +using Taskdeck.Application.Interfaces; +using Taskdeck.Domain.Entities; +using Taskdeck.Infrastructure.Persistence; + +namespace Taskdeck.Infrastructure.Repositories; + +public class ChatMessageRepository : Repository, IChatMessageRepository +{ + public ChatMessageRepository(TaskdeckDbContext context) : base(context) + { + } + + public async Task> GetBySessionIdAsync(string sessionId, int limit = 100, CancellationToken cancellationToken = default) + { + return await _dbSet + .Where(m => m.SessionId == sessionId) + .OrderBy(m => m.CreatedAt) + .Take(limit) + .ToListAsync(cancellationToken); + } + + public async Task> GetByProposalIdAsync(string proposalId, CancellationToken cancellationToken = default) + { + return await _dbSet + .Where(m => m.ProposalId == proposalId) + .OrderBy(m => m.CreatedAt) + .ToListAsync(cancellationToken); + } +} diff --git a/backend/src/Taskdeck.Infrastructure/Repositories/ChatSessionRepository.cs b/backend/src/Taskdeck.Infrastructure/Repositories/ChatSessionRepository.cs new file mode 100644 index 000000000..9b5ed2fd5 --- /dev/null +++ b/backend/src/Taskdeck.Infrastructure/Repositories/ChatSessionRepository.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore; +using Taskdeck.Application.Interfaces; +using Taskdeck.Domain.Entities; +using Taskdeck.Infrastructure.Persistence; + +namespace Taskdeck.Infrastructure.Repositories; + +public class ChatSessionRepository : Repository, IChatSessionRepository +{ + public ChatSessionRepository(TaskdeckDbContext context) : base(context) + { + } + + public async Task> GetByUserIdAsync(string userId, int limit = 100, CancellationToken cancellationToken = default) + { + return await _dbSet + .Where(s => s.UserId == userId) + .OrderByDescending(s => s.UpdatedAt) + .Take(limit) + .ToListAsync(cancellationToken); + } + + public async Task> GetByBoardIdAsync(string boardId, int limit = 100, CancellationToken cancellationToken = default) + { + return await _dbSet + .Where(s => s.BoardId == boardId) + .OrderByDescending(s => s.UpdatedAt) + .Take(limit) + .ToListAsync(cancellationToken); + } + + public async Task> GetByStatusAsync(ChatSessionStatus status, int limit = 100, CancellationToken cancellationToken = default) + { + return await _dbSet + .Where(s => s.Status == status) + .OrderByDescending(s => s.UpdatedAt) + .Take(limit) + .ToListAsync(cancellationToken); + } + + public async Task GetByIdWithMessagesAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _dbSet + .Include(s => s.Messages) + .FirstOrDefaultAsync(s => s.Id == id, cancellationToken); + } +} diff --git a/backend/src/Taskdeck.Infrastructure/Repositories/CommandRunRepository.cs b/backend/src/Taskdeck.Infrastructure/Repositories/CommandRunRepository.cs new file mode 100644 index 000000000..a201b4139 --- /dev/null +++ b/backend/src/Taskdeck.Infrastructure/Repositories/CommandRunRepository.cs @@ -0,0 +1,53 @@ +using Microsoft.EntityFrameworkCore; +using Taskdeck.Application.Interfaces; +using Taskdeck.Domain.Entities; +using Taskdeck.Infrastructure.Persistence; + +namespace Taskdeck.Infrastructure.Repositories; + +public class CommandRunRepository : Repository, ICommandRunRepository +{ + public CommandRunRepository(TaskdeckDbContext context) : base(context) + { + } + + public async Task> GetByUserIdAsync(string userId, int limit = 100, CancellationToken cancellationToken = default) + { + return await _dbSet + .Where(c => c.UserId == userId) + .OrderByDescending(c => c.CreatedAt) + .Take(limit) + .ToListAsync(cancellationToken); + } + + public async Task> GetByStatusAsync(CommandRunStatus status, int limit = 100, CancellationToken cancellationToken = default) + { + return await _dbSet + .Where(c => c.Status == status) + .OrderByDescending(c => c.CreatedAt) + .Take(limit) + .ToListAsync(cancellationToken); + } + + public async Task> GetByTemplateNameAsync(string templateName, int limit = 100, CancellationToken cancellationToken = default) + { + return await _dbSet + .Where(c => c.TemplateName == templateName) + .OrderByDescending(c => c.CreatedAt) + .Take(limit) + .ToListAsync(cancellationToken); + } + + public async Task GetByCorrelationIdAsync(string correlationId, CancellationToken cancellationToken = default) + { + return await _dbSet + .FirstOrDefaultAsync(c => c.CorrelationId == correlationId, cancellationToken); + } + + public async Task GetByIdWithLogsAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _dbSet + .Include(c => c.Logs) + .FirstOrDefaultAsync(c => c.Id == id, cancellationToken); + } +} From 5514aa1ce98982a04301ebdf957e3906439256a4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 19:59:58 +0000 Subject: [PATCH 05/30] Add EF Core entity configurations for new entities and fix repository bugs Co-authored-by: Chris0Jeky <59696583+Chris0Jeky@users.noreply.github.com> --- .../ArchiveItemConfiguration.cs | 68 +++++++++++++++ .../AutomationProposalConfiguration.cs | 82 +++++++++++++++++++ ...utomationProposalOperationConfiguration.cs | 57 +++++++++++++ .../ChatMessageConfiguration.cs | 48 +++++++++++ .../ChatSessionConfiguration.cs | 49 +++++++++++ .../Configurations/CommandRunConfiguration.cs | 65 +++++++++++++++ .../CommandRunLogConfiguration.cs | 48 +++++++++++ .../Persistence/TaskdeckDbContext.cs | 7 ++ .../Repositories/ArchiveItemRepository.cs | 2 +- .../Repositories/CommandRunRepository.cs | 2 +- .../Taskdeck.Infrastructure.csproj | 1 + 11 files changed, 427 insertions(+), 2 deletions(-) create mode 100644 backend/src/Taskdeck.Infrastructure/Persistence/Configurations/ArchiveItemConfiguration.cs create mode 100644 backend/src/Taskdeck.Infrastructure/Persistence/Configurations/AutomationProposalConfiguration.cs create mode 100644 backend/src/Taskdeck.Infrastructure/Persistence/Configurations/AutomationProposalOperationConfiguration.cs create mode 100644 backend/src/Taskdeck.Infrastructure/Persistence/Configurations/ChatMessageConfiguration.cs create mode 100644 backend/src/Taskdeck.Infrastructure/Persistence/Configurations/ChatSessionConfiguration.cs create mode 100644 backend/src/Taskdeck.Infrastructure/Persistence/Configurations/CommandRunConfiguration.cs create mode 100644 backend/src/Taskdeck.Infrastructure/Persistence/Configurations/CommandRunLogConfiguration.cs diff --git a/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/ArchiveItemConfiguration.cs b/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/ArchiveItemConfiguration.cs new file mode 100644 index 000000000..6521fb17f --- /dev/null +++ b/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/ArchiveItemConfiguration.cs @@ -0,0 +1,68 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Taskdeck.Domain.Entities; + +namespace Taskdeck.Infrastructure.Persistence.Configurations; + +public class ArchiveItemConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("ArchiveItems"); + + builder.HasKey(ai => ai.Id); + + builder.Property(ai => ai.Id) + .ValueGeneratedNever(); + + builder.Property(ai => ai.EntityType) + .IsRequired() + .HasMaxLength(50); + + builder.Property(ai => ai.EntityId) + .IsRequired() + .HasMaxLength(100); + + builder.Property(ai => ai.BoardId) + .IsRequired() + .HasMaxLength(100); + + builder.Property(ai => ai.Name) + .IsRequired() + .HasMaxLength(200); + + builder.Property(ai => ai.ArchivedByUserId) + .IsRequired() + .HasMaxLength(100); + + builder.Property(ai => ai.ArchivedAt) + .IsRequired(); + + builder.Property(ai => ai.Reason) + .HasMaxLength(500); + + builder.Property(ai => ai.SnapshotJson) + .IsRequired(); + + builder.Property(ai => ai.RestoreStatus) + .IsRequired() + .HasConversion(); + + builder.Property(ai => ai.RestoredAt); + + builder.Property(ai => ai.RestoredByUserId) + .HasMaxLength(100); + + builder.Property(ai => ai.CreatedAt) + .IsRequired(); + + builder.Property(ai => ai.UpdatedAt) + .IsRequired(); + + builder.HasIndex(ai => ai.BoardId); + builder.HasIndex(ai => new { ai.EntityType, ai.EntityId }); + builder.HasIndex(ai => ai.ArchivedByUserId); + builder.HasIndex(ai => ai.RestoreStatus); + builder.HasIndex(ai => ai.ArchivedAt); + } +} diff --git a/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/AutomationProposalConfiguration.cs b/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/AutomationProposalConfiguration.cs new file mode 100644 index 000000000..59a6f452b --- /dev/null +++ b/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/AutomationProposalConfiguration.cs @@ -0,0 +1,82 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Taskdeck.Domain.Entities; + +namespace Taskdeck.Infrastructure.Persistence.Configurations; + +public class AutomationProposalConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("AutomationProposals"); + + builder.HasKey(ap => ap.Id); + + builder.Property(ap => ap.Id) + .ValueGeneratedNever(); + + builder.Property(ap => ap.SourceType) + .IsRequired() + .HasConversion(); + + builder.Property(ap => ap.SourceReferenceId) + .HasMaxLength(100); + + builder.Property(ap => ap.BoardId) + .HasMaxLength(100); + + builder.Property(ap => ap.RequestedByUserId) + .IsRequired() + .HasMaxLength(100); + + builder.Property(ap => ap.Status) + .IsRequired() + .HasConversion(); + + builder.Property(ap => ap.RiskLevel) + .IsRequired() + .HasConversion(); + + builder.Property(ap => ap.Summary) + .IsRequired() + .HasMaxLength(500); + + builder.Property(ap => ap.DiffPreview); + + builder.Property(ap => ap.ValidationIssues); + + builder.Property(ap => ap.ExpiresAt) + .IsRequired(); + + builder.Property(ap => ap.DecidedAt); + + builder.Property(ap => ap.DecidedByUserId) + .HasMaxLength(100); + + builder.Property(ap => ap.AppliedAt); + + builder.Property(ap => ap.FailureReason) + .HasMaxLength(1000); + + builder.Property(ap => ap.CorrelationId) + .IsRequired() + .HasMaxLength(100); + + builder.Property(ap => ap.CreatedAt) + .IsRequired(); + + builder.Property(ap => ap.UpdatedAt) + .IsRequired(); + + builder.HasMany(ap => ap.Operations) + .WithOne(o => o.Proposal) + .HasForeignKey(o => o.ProposalId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(ap => ap.Status); + builder.HasIndex(ap => ap.RequestedByUserId); + builder.HasIndex(ap => ap.BoardId); + builder.HasIndex(ap => ap.CorrelationId); + builder.HasIndex(ap => ap.ExpiresAt); + } +} diff --git a/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/AutomationProposalOperationConfiguration.cs b/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/AutomationProposalOperationConfiguration.cs new file mode 100644 index 000000000..e6e6e0c54 --- /dev/null +++ b/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/AutomationProposalOperationConfiguration.cs @@ -0,0 +1,57 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Taskdeck.Domain.Entities; + +namespace Taskdeck.Infrastructure.Persistence.Configurations; + +public class AutomationProposalOperationConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("AutomationProposalOperations"); + + builder.HasKey(apo => apo.Id); + + builder.Property(apo => apo.Id) + .ValueGeneratedNever(); + + builder.Property(apo => apo.ProposalId) + .IsRequired() + .HasMaxLength(100); + + builder.Property(apo => apo.Sequence) + .IsRequired(); + + builder.Property(apo => apo.ActionType) + .IsRequired() + .HasMaxLength(100); + + builder.Property(apo => apo.TargetType) + .IsRequired() + .HasMaxLength(100); + + builder.Property(apo => apo.TargetId) + .HasMaxLength(100); + + builder.Property(apo => apo.Parameters) + .IsRequired(); + + builder.Property(apo => apo.IdempotencyKey) + .IsRequired() + .HasMaxLength(100); + + builder.Property(apo => apo.ExpectedVersion) + .HasMaxLength(100); + + builder.Property(apo => apo.CreatedAt) + .IsRequired(); + + builder.Property(apo => apo.UpdatedAt) + .IsRequired(); + + builder.HasIndex(apo => apo.ProposalId); + builder.HasIndex(apo => new { apo.ProposalId, apo.Sequence }); + builder.HasIndex(apo => apo.IdempotencyKey) + .IsUnique(); + } +} diff --git a/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/ChatMessageConfiguration.cs b/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/ChatMessageConfiguration.cs new file mode 100644 index 000000000..979fd1a4d --- /dev/null +++ b/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/ChatMessageConfiguration.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Taskdeck.Domain.Entities; + +namespace Taskdeck.Infrastructure.Persistence.Configurations; + +public class ChatMessageConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("ChatMessages"); + + builder.HasKey(cm => cm.Id); + + builder.Property(cm => cm.Id) + .ValueGeneratedNever(); + + builder.Property(cm => cm.SessionId) + .IsRequired() + .HasMaxLength(100); + + builder.Property(cm => cm.Role) + .IsRequired() + .HasConversion(); + + builder.Property(cm => cm.Content) + .IsRequired(); + + builder.Property(cm => cm.MessageType) + .IsRequired() + .HasMaxLength(50); + + builder.Property(cm => cm.ProposalId) + .HasMaxLength(100); + + builder.Property(cm => cm.TokenUsage); + + builder.Property(cm => cm.CreatedAt) + .IsRequired(); + + builder.Property(cm => cm.UpdatedAt) + .IsRequired(); + + builder.HasIndex(cm => cm.SessionId); + builder.HasIndex(cm => cm.ProposalId); + builder.HasIndex(cm => cm.CreatedAt); + } +} diff --git a/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/ChatSessionConfiguration.cs b/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/ChatSessionConfiguration.cs new file mode 100644 index 000000000..1e1815bf3 --- /dev/null +++ b/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/ChatSessionConfiguration.cs @@ -0,0 +1,49 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Taskdeck.Domain.Entities; + +namespace Taskdeck.Infrastructure.Persistence.Configurations; + +public class ChatSessionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("ChatSessions"); + + builder.HasKey(cs => cs.Id); + + builder.Property(cs => cs.Id) + .ValueGeneratedNever(); + + builder.Property(cs => cs.UserId) + .IsRequired() + .HasMaxLength(100); + + builder.Property(cs => cs.BoardId) + .HasMaxLength(100); + + builder.Property(cs => cs.Title) + .IsRequired() + .HasMaxLength(200); + + builder.Property(cs => cs.Status) + .IsRequired() + .HasConversion(); + + builder.Property(cs => cs.CreatedAt) + .IsRequired(); + + builder.Property(cs => cs.UpdatedAt) + .IsRequired(); + + builder.HasMany(cs => cs.Messages) + .WithOne(m => m.Session) + .HasForeignKey(m => m.SessionId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(cs => cs.UserId); + builder.HasIndex(cs => cs.BoardId); + builder.HasIndex(cs => cs.Status); + builder.HasIndex(cs => cs.CreatedAt); + } +} diff --git a/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/CommandRunConfiguration.cs b/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/CommandRunConfiguration.cs new file mode 100644 index 000000000..70e1b3598 --- /dev/null +++ b/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/CommandRunConfiguration.cs @@ -0,0 +1,65 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Taskdeck.Domain.Entities; + +namespace Taskdeck.Infrastructure.Persistence.Configurations; + +public class CommandRunConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("CommandRuns"); + + builder.HasKey(cr => cr.Id); + + builder.Property(cr => cr.Id) + .ValueGeneratedNever(); + + builder.Property(cr => cr.TemplateName) + .IsRequired() + .HasMaxLength(100); + + builder.Property(cr => cr.RequestedByUserId) + .IsRequired() + .HasMaxLength(100); + + builder.Property(cr => cr.Status) + .IsRequired() + .HasConversion(); + + builder.Property(cr => cr.StartedAt); + + builder.Property(cr => cr.CompletedAt); + + builder.Property(cr => cr.ExitCode); + + builder.Property(cr => cr.Truncated) + .IsRequired(); + + builder.Property(cr => cr.CorrelationId) + .IsRequired() + .HasMaxLength(100); + + builder.Property(cr => cr.ErrorMessage) + .HasMaxLength(2000); + + builder.Property(cr => cr.OutputPreview) + .HasMaxLength(1000); + + builder.Property(cr => cr.CreatedAt) + .IsRequired(); + + builder.Property(cr => cr.UpdatedAt) + .IsRequired(); + + builder.HasMany(cr => cr.Logs) + .WithOne(l => l.CommandRun) + .HasForeignKey(l => l.CommandRunId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(cr => cr.Status); + builder.HasIndex(cr => cr.RequestedByUserId); + builder.HasIndex(cr => cr.CorrelationId); + builder.HasIndex(cr => cr.CreatedAt); + } +} diff --git a/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/CommandRunLogConfiguration.cs b/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/CommandRunLogConfiguration.cs new file mode 100644 index 000000000..bbe7ff7ad --- /dev/null +++ b/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/CommandRunLogConfiguration.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Taskdeck.Domain.Entities; + +namespace Taskdeck.Infrastructure.Persistence.Configurations; + +public class CommandRunLogConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("CommandRunLogs"); + + builder.HasKey(crl => crl.Id); + + builder.Property(crl => crl.Id) + .ValueGeneratedNever(); + + builder.Property(crl => crl.CommandRunId) + .IsRequired() + .HasMaxLength(100); + + builder.Property(crl => crl.Timestamp) + .IsRequired(); + + builder.Property(crl => crl.Level) + .IsRequired() + .HasMaxLength(50); + + builder.Property(crl => crl.Source) + .IsRequired() + .HasMaxLength(200); + + builder.Property(crl => crl.Message) + .IsRequired(); + + builder.Property(crl => crl.Metadata); + + builder.Property(crl => crl.CreatedAt) + .IsRequired(); + + builder.Property(crl => crl.UpdatedAt) + .IsRequired(); + + builder.HasIndex(crl => crl.CommandRunId); + builder.HasIndex(crl => crl.Timestamp); + builder.HasIndex(crl => crl.Level); + } +} diff --git a/backend/src/Taskdeck.Infrastructure/Persistence/TaskdeckDbContext.cs b/backend/src/Taskdeck.Infrastructure/Persistence/TaskdeckDbContext.cs index ee267dafd..b5dae738a 100644 --- a/backend/src/Taskdeck.Infrastructure/Persistence/TaskdeckDbContext.cs +++ b/backend/src/Taskdeck.Infrastructure/Persistence/TaskdeckDbContext.cs @@ -18,6 +18,13 @@ public TaskdeckDbContext(DbContextOptions options) : base(opt public DbSet BoardAccesses => Set(); public DbSet AuditLogs => Set(); public DbSet LlmRequests => Set(); + public DbSet AutomationProposals => Set(); + public DbSet AutomationProposalOperations => Set(); + public DbSet ArchiveItems => Set(); + public DbSet ChatSessions => Set(); + public DbSet ChatMessages => Set(); + public DbSet CommandRuns => Set(); + public DbSet CommandRunLogs => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/backend/src/Taskdeck.Infrastructure/Repositories/ArchiveItemRepository.cs b/backend/src/Taskdeck.Infrastructure/Repositories/ArchiveItemRepository.cs index edb11cba2..3d564b772 100644 --- a/backend/src/Taskdeck.Infrastructure/Repositories/ArchiveItemRepository.cs +++ b/backend/src/Taskdeck.Infrastructure/Repositories/ArchiveItemRepository.cs @@ -32,7 +32,7 @@ public async Task> GetByEntityTypeAsync(string entityTy public async Task> GetByStatusAsync(RestoreStatus status, int limit = 100, CancellationToken cancellationToken = default) { return await _dbSet - .Where(a => a.Status == status) + .Where(a => a.RestoreStatus == status) .OrderByDescending(a => a.ArchivedAt) .Take(limit) .ToListAsync(cancellationToken); diff --git a/backend/src/Taskdeck.Infrastructure/Repositories/CommandRunRepository.cs b/backend/src/Taskdeck.Infrastructure/Repositories/CommandRunRepository.cs index a201b4139..2c9a17d12 100644 --- a/backend/src/Taskdeck.Infrastructure/Repositories/CommandRunRepository.cs +++ b/backend/src/Taskdeck.Infrastructure/Repositories/CommandRunRepository.cs @@ -14,7 +14,7 @@ public CommandRunRepository(TaskdeckDbContext context) : base(context) public async Task> GetByUserIdAsync(string userId, int limit = 100, CancellationToken cancellationToken = default) { return await _dbSet - .Where(c => c.UserId == userId) + .Where(c => c.RequestedByUserId == userId) .OrderByDescending(c => c.CreatedAt) .Take(limit) .ToListAsync(cancellationToken); diff --git a/backend/src/Taskdeck.Infrastructure/Taskdeck.Infrastructure.csproj b/backend/src/Taskdeck.Infrastructure/Taskdeck.Infrastructure.csproj index 34b5350b8..39f600f4c 100644 --- a/backend/src/Taskdeck.Infrastructure/Taskdeck.Infrastructure.csproj +++ b/backend/src/Taskdeck.Infrastructure/Taskdeck.Infrastructure.csproj @@ -12,6 +12,7 @@ + From 73c12024995ca9e862d8cdc7c62d7a5fbbd186f1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 20:00:56 +0000 Subject: [PATCH 06/30] Fix operator precedence bug in UserContext authentication check Co-authored-by: Chris0Jeky <59696583+Chris0Jeky@users.noreply.github.com> --- backend/src/Taskdeck.Infrastructure/Identity/UserContext.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/Taskdeck.Infrastructure/Identity/UserContext.cs b/backend/src/Taskdeck.Infrastructure/Identity/UserContext.cs index 3d9843e1e..d3b5760ac 100644 --- a/backend/src/Taskdeck.Infrastructure/Identity/UserContext.cs +++ b/backend/src/Taskdeck.Infrastructure/Identity/UserContext.cs @@ -21,7 +21,7 @@ public string? UserId get { var user = _httpContextAccessor.HttpContext?.User; - if (user == null || !user.Identity?.IsAuthenticated == true) + if (user == null || user.Identity?.IsAuthenticated != true) return null; // Try both 'sub' (standard JWT claim) and 'nameid' (alternative) From 6db8a8803e02effc38cf904551cd5071349c63c2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 20:06:27 +0000 Subject: [PATCH 07/30] Change ID fields from string to Guid in new domain entities Updated all new domain entities to use Guid for ID fields to match Entity base class: - AutomationProposal: BoardId, RequestedByUserId, DecidedByUserId - AutomationProposalOperation: ProposalId - ArchiveItem: EntityId, BoardId, ArchivedByUserId, RestoredByUserId - ChatSession: UserId, BoardId - ChatMessage: SessionId, ProposalId - CommandRun: RequestedByUserId - CommandRunLog: CommandRunId Also updated repository interfaces and implementations to use Guid parameters. CorrelationId remains string as it's not an entity reference. Co-authored-by: Chris0Jeky <59696583+Chris0Jeky@users.noreply.github.com> --- .../Interfaces/IArchiveItemRepository.cs | 6 ++--- .../IAutomationProposalRepository.cs | 4 ++-- .../Interfaces/IChatMessageRepository.cs | 4 ++-- .../Interfaces/IChatSessionRepository.cs | 4 ++-- .../Interfaces/ICommandRunRepository.cs | 2 +- .../Taskdeck.Domain/Entities/ArchiveItem.cs | 24 +++++++++---------- .../Entities/AutomationProposal.cs | 20 ++++++++-------- .../Entities/AutomationProposalOperation.cs | 6 ++--- .../Taskdeck.Domain/Entities/ChatMessage.cs | 14 +++++------ .../Taskdeck.Domain/Entities/ChatSession.cs | 10 ++++---- .../Taskdeck.Domain/Entities/CommandRun.cs | 6 ++--- .../Taskdeck.Domain/Entities/CommandRunLog.cs | 6 ++--- .../Repositories/ArchiveItemRepository.cs | 6 ++--- .../AutomationProposalRepository.cs | 4 ++-- .../Repositories/ChatMessageRepository.cs | 4 ++-- .../Repositories/ChatSessionRepository.cs | 4 ++-- .../Repositories/CommandRunRepository.cs | 2 +- 17 files changed, 63 insertions(+), 63 deletions(-) diff --git a/backend/src/Taskdeck.Application/Interfaces/IArchiveItemRepository.cs b/backend/src/Taskdeck.Application/Interfaces/IArchiveItemRepository.cs index 6aad8930f..83109cac8 100644 --- a/backend/src/Taskdeck.Application/Interfaces/IArchiveItemRepository.cs +++ b/backend/src/Taskdeck.Application/Interfaces/IArchiveItemRepository.cs @@ -4,9 +4,9 @@ namespace Taskdeck.Application.Interfaces; public interface IArchiveItemRepository : IRepository { - Task> GetByBoardIdAsync(string boardId, int limit = 100, CancellationToken cancellationToken = default); + Task> GetByBoardIdAsync(Guid boardId, int limit = 100, CancellationToken cancellationToken = default); Task> GetByEntityTypeAsync(string entityType, int limit = 100, CancellationToken cancellationToken = default); Task> GetByStatusAsync(RestoreStatus status, int limit = 100, CancellationToken cancellationToken = default); - Task GetByEntityAsync(string entityType, string entityId, CancellationToken cancellationToken = default); - Task> GetByUserIdAsync(string userId, int limit = 100, CancellationToken cancellationToken = default); + Task GetByEntityAsync(string entityType, Guid entityId, CancellationToken cancellationToken = default); + Task> GetByUserIdAsync(Guid userId, int limit = 100, CancellationToken cancellationToken = default); } diff --git a/backend/src/Taskdeck.Application/Interfaces/IAutomationProposalRepository.cs b/backend/src/Taskdeck.Application/Interfaces/IAutomationProposalRepository.cs index ff5f12809..2acc3b480 100644 --- a/backend/src/Taskdeck.Application/Interfaces/IAutomationProposalRepository.cs +++ b/backend/src/Taskdeck.Application/Interfaces/IAutomationProposalRepository.cs @@ -5,8 +5,8 @@ namespace Taskdeck.Application.Interfaces; public interface IAutomationProposalRepository : IRepository { Task> GetByStatusAsync(ProposalStatus status, int limit = 100, CancellationToken cancellationToken = default); - Task> GetByBoardIdAsync(string boardId, CancellationToken cancellationToken = default); - Task> GetByUserIdAsync(string userId, int limit = 100, CancellationToken cancellationToken = default); + Task> GetByBoardIdAsync(Guid boardId, CancellationToken cancellationToken = default); + Task> GetByUserIdAsync(Guid userId, int limit = 100, CancellationToken cancellationToken = default); Task> GetByRiskLevelAsync(RiskLevel riskLevel, int limit = 100, CancellationToken cancellationToken = default); Task GetBySourceReferenceAsync(ProposalSourceType sourceType, string referenceId, CancellationToken cancellationToken = default); Task GetByCorrelationIdAsync(string correlationId, CancellationToken cancellationToken = default); diff --git a/backend/src/Taskdeck.Application/Interfaces/IChatMessageRepository.cs b/backend/src/Taskdeck.Application/Interfaces/IChatMessageRepository.cs index fda001431..66154c103 100644 --- a/backend/src/Taskdeck.Application/Interfaces/IChatMessageRepository.cs +++ b/backend/src/Taskdeck.Application/Interfaces/IChatMessageRepository.cs @@ -4,6 +4,6 @@ namespace Taskdeck.Application.Interfaces; public interface IChatMessageRepository : IRepository { - Task> GetBySessionIdAsync(string sessionId, int limit = 100, CancellationToken cancellationToken = default); - Task> GetByProposalIdAsync(string proposalId, CancellationToken cancellationToken = default); + Task> GetBySessionIdAsync(Guid sessionId, int limit = 100, CancellationToken cancellationToken = default); + Task> GetByProposalIdAsync(Guid proposalId, CancellationToken cancellationToken = default); } diff --git a/backend/src/Taskdeck.Application/Interfaces/IChatSessionRepository.cs b/backend/src/Taskdeck.Application/Interfaces/IChatSessionRepository.cs index 4202dd237..e490cb499 100644 --- a/backend/src/Taskdeck.Application/Interfaces/IChatSessionRepository.cs +++ b/backend/src/Taskdeck.Application/Interfaces/IChatSessionRepository.cs @@ -4,8 +4,8 @@ namespace Taskdeck.Application.Interfaces; public interface IChatSessionRepository : IRepository { - Task> GetByUserIdAsync(string userId, int limit = 100, CancellationToken cancellationToken = default); - Task> GetByBoardIdAsync(string boardId, int limit = 100, CancellationToken cancellationToken = default); + Task> GetByUserIdAsync(Guid userId, int limit = 100, CancellationToken cancellationToken = default); + Task> GetByBoardIdAsync(Guid boardId, int limit = 100, CancellationToken cancellationToken = default); Task> GetByStatusAsync(ChatSessionStatus status, int limit = 100, CancellationToken cancellationToken = default); Task GetByIdWithMessagesAsync(Guid id, CancellationToken cancellationToken = default); } diff --git a/backend/src/Taskdeck.Application/Interfaces/ICommandRunRepository.cs b/backend/src/Taskdeck.Application/Interfaces/ICommandRunRepository.cs index 4f0f8213e..a6785ced4 100644 --- a/backend/src/Taskdeck.Application/Interfaces/ICommandRunRepository.cs +++ b/backend/src/Taskdeck.Application/Interfaces/ICommandRunRepository.cs @@ -4,7 +4,7 @@ namespace Taskdeck.Application.Interfaces; public interface ICommandRunRepository : IRepository { - Task> GetByUserIdAsync(string userId, int limit = 100, CancellationToken cancellationToken = default); + Task> GetByUserIdAsync(Guid userId, int limit = 100, CancellationToken cancellationToken = default); Task> GetByStatusAsync(CommandRunStatus status, int limit = 100, CancellationToken cancellationToken = default); Task> GetByTemplateNameAsync(string templateName, int limit = 100, CancellationToken cancellationToken = default); Task GetByCorrelationIdAsync(string correlationId, CancellationToken cancellationToken = default); diff --git a/backend/src/Taskdeck.Domain/Entities/ArchiveItem.cs b/backend/src/Taskdeck.Domain/Entities/ArchiveItem.cs index 2b729010c..85dfa947c 100644 --- a/backend/src/Taskdeck.Domain/Entities/ArchiveItem.cs +++ b/backend/src/Taskdeck.Domain/Entities/ArchiveItem.cs @@ -6,25 +6,25 @@ namespace Taskdeck.Domain.Entities; public class ArchiveItem : Entity { public string EntityType { get; private set; } - public string EntityId { get; private set; } - public string BoardId { get; private set; } + public Guid EntityId { get; private set; } + public Guid BoardId { get; private set; } public string Name { get; private set; } - public string ArchivedByUserId { get; private set; } + public Guid ArchivedByUserId { get; private set; } public DateTime ArchivedAt { get; private set; } public string? Reason { get; private set; } public string SnapshotJson { get; private set; } public RestoreStatus RestoreStatus { get; private set; } public DateTime? RestoredAt { get; private set; } - public string? RestoredByUserId { get; private set; } + public Guid? RestoredByUserId { get; private set; } private ArchiveItem() { } // EF Core public ArchiveItem( string entityType, - string entityId, - string boardId, + Guid entityId, + Guid boardId, string name, - string archivedByUserId, + Guid archivedByUserId, string snapshotJson, string? reason = null) { @@ -32,15 +32,15 @@ public ArchiveItem( throw new DomainException(ErrorCodes.ValidationError, "EntityType cannot be empty"); if (entityType != "board" && entityType != "column" && entityType != "card") throw new DomainException(ErrorCodes.ValidationError, "EntityType must be 'board', 'column', or 'card'"); - if (string.IsNullOrWhiteSpace(entityId)) + if (entityId == Guid.Empty) throw new DomainException(ErrorCodes.ValidationError, "EntityId cannot be empty"); - if (string.IsNullOrWhiteSpace(boardId)) + if (boardId == Guid.Empty) throw new DomainException(ErrorCodes.ValidationError, "BoardId cannot be empty"); if (string.IsNullOrWhiteSpace(name)) throw new DomainException(ErrorCodes.ValidationError, "Name cannot be empty"); if (name.Length > 200) throw new DomainException(ErrorCodes.ValidationError, "Name cannot exceed 200 characters"); - if (string.IsNullOrWhiteSpace(archivedByUserId)) + if (archivedByUserId == Guid.Empty) throw new DomainException(ErrorCodes.ValidationError, "ArchivedByUserId cannot be empty"); if (string.IsNullOrWhiteSpace(snapshotJson)) throw new DomainException(ErrorCodes.ValidationError, "SnapshotJson cannot be empty"); @@ -56,9 +56,9 @@ public ArchiveItem( RestoreStatus = RestoreStatus.Available; } - public void MarkAsRestored(string restoredByUserId) + public void MarkAsRestored(Guid restoredByUserId) { - if (string.IsNullOrWhiteSpace(restoredByUserId)) + if (restoredByUserId == Guid.Empty) throw new DomainException(ErrorCodes.ValidationError, "RestoredByUserId cannot be empty"); if (RestoreStatus != RestoreStatus.Available) throw new DomainException(ErrorCodes.InvalidOperation, $"Cannot restore archive item with status {RestoreStatus}"); diff --git a/backend/src/Taskdeck.Domain/Entities/AutomationProposal.cs b/backend/src/Taskdeck.Domain/Entities/AutomationProposal.cs index dff1527c5..301a6e58d 100644 --- a/backend/src/Taskdeck.Domain/Entities/AutomationProposal.cs +++ b/backend/src/Taskdeck.Domain/Entities/AutomationProposal.cs @@ -7,8 +7,8 @@ public class AutomationProposal : Entity { public ProposalSourceType SourceType { get; private set; } public string? SourceReferenceId { get; private set; } - public string? BoardId { get; private set; } - public string RequestedByUserId { get; private set; } + public Guid? BoardId { get; private set; } + public Guid RequestedByUserId { get; private set; } public ProposalStatus Status { get; private set; } public RiskLevel RiskLevel { get; private set; } public string Summary { get; private set; } @@ -16,7 +16,7 @@ public class AutomationProposal : Entity public string? ValidationIssues { get; private set; } public DateTime ExpiresAt { get; private set; } public DateTime? DecidedAt { get; private set; } - public string? DecidedByUserId { get; private set; } + public Guid? DecidedByUserId { get; private set; } public DateTime? AppliedAt { get; private set; } public string? FailureReason { get; private set; } public string CorrelationId { get; private set; } @@ -28,15 +28,15 @@ private AutomationProposal() { } // EF Core public AutomationProposal( ProposalSourceType sourceType, - string requestedByUserId, + Guid requestedByUserId, string summary, RiskLevel riskLevel, string correlationId, - string? boardId = null, + Guid? boardId = null, string? sourceReferenceId = null, int expiryMinutes = 1440) { - if (string.IsNullOrWhiteSpace(requestedByUserId)) + if (requestedByUserId == Guid.Empty) throw new DomainException(ErrorCodes.ValidationError, "RequestedByUserId cannot be empty"); if (string.IsNullOrWhiteSpace(summary)) throw new DomainException(ErrorCodes.ValidationError, "Summary cannot be empty"); @@ -67,9 +67,9 @@ public void AddOperation(AutomationProposalOperation operation) Touch(); } - public void Approve(string decidedByUserId) + public void Approve(Guid decidedByUserId) { - if (string.IsNullOrWhiteSpace(decidedByUserId)) + if (decidedByUserId == Guid.Empty) throw new DomainException(ErrorCodes.ValidationError, "DecidedByUserId cannot be empty"); if (Status != ProposalStatus.PendingReview) throw new DomainException(ErrorCodes.InvalidOperation, $"Cannot approve proposal in status {Status}"); @@ -82,9 +82,9 @@ public void Approve(string decidedByUserId) Touch(); } - public void Reject(string decidedByUserId, string? reason = null) + public void Reject(Guid decidedByUserId, string? reason = null) { - if (string.IsNullOrWhiteSpace(decidedByUserId)) + if (decidedByUserId == Guid.Empty) throw new DomainException(ErrorCodes.ValidationError, "DecidedByUserId cannot be empty"); if (Status != ProposalStatus.PendingReview) throw new DomainException(ErrorCodes.InvalidOperation, $"Cannot reject proposal in status {Status}"); diff --git a/backend/src/Taskdeck.Domain/Entities/AutomationProposalOperation.cs b/backend/src/Taskdeck.Domain/Entities/AutomationProposalOperation.cs index 7c93e00cd..871b01f66 100644 --- a/backend/src/Taskdeck.Domain/Entities/AutomationProposalOperation.cs +++ b/backend/src/Taskdeck.Domain/Entities/AutomationProposalOperation.cs @@ -5,7 +5,7 @@ namespace Taskdeck.Domain.Entities; public class AutomationProposalOperation : Entity { - public string ProposalId { get; private set; } + public Guid ProposalId { get; private set; } public int Sequence { get; private set; } public string ActionType { get; private set; } public string TargetType { get; private set; } @@ -20,7 +20,7 @@ public class AutomationProposalOperation : Entity private AutomationProposalOperation() { } // EF Core public AutomationProposalOperation( - string proposalId, + Guid proposalId, int sequence, string actionType, string targetType, @@ -29,7 +29,7 @@ public AutomationProposalOperation( string? targetId = null, string? expectedVersion = null) { - if (string.IsNullOrWhiteSpace(proposalId)) + if (proposalId == Guid.Empty) throw new DomainException(ErrorCodes.ValidationError, "ProposalId cannot be empty"); if (sequence < 0) throw new DomainException(ErrorCodes.ValidationError, "Sequence must be non-negative"); diff --git a/backend/src/Taskdeck.Domain/Entities/ChatMessage.cs b/backend/src/Taskdeck.Domain/Entities/ChatMessage.cs index 1eff2b92f..bac4f0079 100644 --- a/backend/src/Taskdeck.Domain/Entities/ChatMessage.cs +++ b/backend/src/Taskdeck.Domain/Entities/ChatMessage.cs @@ -5,11 +5,11 @@ namespace Taskdeck.Domain.Entities; public class ChatMessage : Entity { - public string SessionId { get; private set; } + public Guid SessionId { get; private set; } public ChatMessageRole Role { get; private set; } public string Content { get; private set; } public string MessageType { get; private set; } - public string? ProposalId { get; private set; } + public Guid? ProposalId { get; private set; } public int? TokenUsage { get; private set; } // Navigation @@ -18,14 +18,14 @@ public class ChatMessage : Entity private ChatMessage() { } // EF Core public ChatMessage( - string sessionId, + Guid sessionId, ChatMessageRole role, string content, string messageType = "text", - string? proposalId = null, + Guid? proposalId = null, int? tokenUsage = null) { - if (string.IsNullOrWhiteSpace(sessionId)) + if (sessionId == Guid.Empty) throw new DomainException(ErrorCodes.ValidationError, "SessionId cannot be empty"); if (string.IsNullOrWhiteSpace(content)) throw new DomainException(ErrorCodes.ValidationError, "Content cannot be empty"); @@ -53,9 +53,9 @@ public void SetTokenUsage(int tokenUsage) Touch(); } - public void SetProposalId(string proposalId) + public void SetProposalId(Guid proposalId) { - if (string.IsNullOrWhiteSpace(proposalId)) + if (proposalId == Guid.Empty) throw new DomainException(ErrorCodes.ValidationError, "ProposalId cannot be empty"); ProposalId = proposalId; diff --git a/backend/src/Taskdeck.Domain/Entities/ChatSession.cs b/backend/src/Taskdeck.Domain/Entities/ChatSession.cs index d5ef5b1eb..b3737343f 100644 --- a/backend/src/Taskdeck.Domain/Entities/ChatSession.cs +++ b/backend/src/Taskdeck.Domain/Entities/ChatSession.cs @@ -5,8 +5,8 @@ namespace Taskdeck.Domain.Entities; public class ChatSession : Entity { - public string UserId { get; private set; } - public string? BoardId { get; private set; } + public Guid UserId { get; private set; } + public Guid? BoardId { get; private set; } public string Title { get; private set; } public ChatSessionStatus Status { get; private set; } @@ -16,11 +16,11 @@ public class ChatSession : Entity private ChatSession() { } // EF Core public ChatSession( - string userId, + Guid userId, string title, - string? boardId = null) + Guid? boardId = null) { - if (string.IsNullOrWhiteSpace(userId)) + if (userId == Guid.Empty) throw new DomainException(ErrorCodes.ValidationError, "UserId cannot be empty"); if (string.IsNullOrWhiteSpace(title)) throw new DomainException(ErrorCodes.ValidationError, "Title cannot be empty"); diff --git a/backend/src/Taskdeck.Domain/Entities/CommandRun.cs b/backend/src/Taskdeck.Domain/Entities/CommandRun.cs index 17d4d1940..a0420da74 100644 --- a/backend/src/Taskdeck.Domain/Entities/CommandRun.cs +++ b/backend/src/Taskdeck.Domain/Entities/CommandRun.cs @@ -6,7 +6,7 @@ namespace Taskdeck.Domain.Entities; public class CommandRun : Entity { public string TemplateName { get; private set; } - public string RequestedByUserId { get; private set; } + public Guid RequestedByUserId { get; private set; } public CommandRunStatus Status { get; private set; } public DateTime? StartedAt { get; private set; } public DateTime? CompletedAt { get; private set; } @@ -23,12 +23,12 @@ private CommandRun() { } // EF Core public CommandRun( string templateName, - string requestedByUserId, + Guid requestedByUserId, string correlationId) { if (string.IsNullOrWhiteSpace(templateName)) throw new DomainException(ErrorCodes.ValidationError, "TemplateName cannot be empty"); - if (string.IsNullOrWhiteSpace(requestedByUserId)) + if (requestedByUserId == Guid.Empty) throw new DomainException(ErrorCodes.ValidationError, "RequestedByUserId cannot be empty"); if (string.IsNullOrWhiteSpace(correlationId)) throw new DomainException(ErrorCodes.ValidationError, "CorrelationId cannot be empty"); diff --git a/backend/src/Taskdeck.Domain/Entities/CommandRunLog.cs b/backend/src/Taskdeck.Domain/Entities/CommandRunLog.cs index 33263d2bd..664cbe2f2 100644 --- a/backend/src/Taskdeck.Domain/Entities/CommandRunLog.cs +++ b/backend/src/Taskdeck.Domain/Entities/CommandRunLog.cs @@ -5,7 +5,7 @@ namespace Taskdeck.Domain.Entities; public class CommandRunLog : Entity { - public string CommandRunId { get; private set; } + public Guid CommandRunId { get; private set; } public DateTime Timestamp { get; private set; } public string Level { get; private set; } public string Source { get; private set; } @@ -18,13 +18,13 @@ public class CommandRunLog : Entity private CommandRunLog() { } // EF Core public CommandRunLog( - string commandRunId, + Guid commandRunId, string level, string source, string message, string? metadata = null) { - if (string.IsNullOrWhiteSpace(commandRunId)) + if (commandRunId == Guid.Empty) throw new DomainException(ErrorCodes.ValidationError, "CommandRunId cannot be empty"); if (string.IsNullOrWhiteSpace(level)) throw new DomainException(ErrorCodes.ValidationError, "Level cannot be empty"); diff --git a/backend/src/Taskdeck.Infrastructure/Repositories/ArchiveItemRepository.cs b/backend/src/Taskdeck.Infrastructure/Repositories/ArchiveItemRepository.cs index 3d564b772..49d5f5545 100644 --- a/backend/src/Taskdeck.Infrastructure/Repositories/ArchiveItemRepository.cs +++ b/backend/src/Taskdeck.Infrastructure/Repositories/ArchiveItemRepository.cs @@ -11,7 +11,7 @@ public ArchiveItemRepository(TaskdeckDbContext context) : base(context) { } - public async Task> GetByBoardIdAsync(string boardId, int limit = 100, CancellationToken cancellationToken = default) + public async Task> GetByBoardIdAsync(Guid boardId, int limit = 100, CancellationToken cancellationToken = default) { return await _dbSet .Where(a => a.BoardId == boardId) @@ -38,13 +38,13 @@ public async Task> GetByStatusAsync(RestoreStatus statu .ToListAsync(cancellationToken); } - public async Task GetByEntityAsync(string entityType, string entityId, CancellationToken cancellationToken = default) + public async Task GetByEntityAsync(string entityType, Guid entityId, CancellationToken cancellationToken = default) { return await _dbSet .FirstOrDefaultAsync(a => a.EntityType == entityType && a.EntityId == entityId, cancellationToken); } - public async Task> GetByUserIdAsync(string userId, int limit = 100, CancellationToken cancellationToken = default) + public async Task> GetByUserIdAsync(Guid userId, int limit = 100, CancellationToken cancellationToken = default) { return await _dbSet .Where(a => a.ArchivedByUserId == userId) diff --git a/backend/src/Taskdeck.Infrastructure/Repositories/AutomationProposalRepository.cs b/backend/src/Taskdeck.Infrastructure/Repositories/AutomationProposalRepository.cs index f7f3d5e6c..86a7af600 100644 --- a/backend/src/Taskdeck.Infrastructure/Repositories/AutomationProposalRepository.cs +++ b/backend/src/Taskdeck.Infrastructure/Repositories/AutomationProposalRepository.cs @@ -20,7 +20,7 @@ public async Task> GetByStatusAsync(ProposalStat .ToListAsync(cancellationToken); } - public async Task> GetByBoardIdAsync(string boardId, CancellationToken cancellationToken = default) + public async Task> GetByBoardIdAsync(Guid boardId, CancellationToken cancellationToken = default) { return await _dbSet .Where(p => p.BoardId == boardId) @@ -28,7 +28,7 @@ public async Task> GetByBoardIdAsync(string boar .ToListAsync(cancellationToken); } - public async Task> GetByUserIdAsync(string userId, int limit = 100, CancellationToken cancellationToken = default) + public async Task> GetByUserIdAsync(Guid userId, int limit = 100, CancellationToken cancellationToken = default) { return await _dbSet .Where(p => p.RequestedByUserId == userId) diff --git a/backend/src/Taskdeck.Infrastructure/Repositories/ChatMessageRepository.cs b/backend/src/Taskdeck.Infrastructure/Repositories/ChatMessageRepository.cs index 35c18d829..d8545318f 100644 --- a/backend/src/Taskdeck.Infrastructure/Repositories/ChatMessageRepository.cs +++ b/backend/src/Taskdeck.Infrastructure/Repositories/ChatMessageRepository.cs @@ -11,7 +11,7 @@ public ChatMessageRepository(TaskdeckDbContext context) : base(context) { } - public async Task> GetBySessionIdAsync(string sessionId, int limit = 100, CancellationToken cancellationToken = default) + public async Task> GetBySessionIdAsync(Guid sessionId, int limit = 100, CancellationToken cancellationToken = default) { return await _dbSet .Where(m => m.SessionId == sessionId) @@ -20,7 +20,7 @@ public async Task> GetBySessionIdAsync(string sessionId .ToListAsync(cancellationToken); } - public async Task> GetByProposalIdAsync(string proposalId, CancellationToken cancellationToken = default) + public async Task> GetByProposalIdAsync(Guid proposalId, CancellationToken cancellationToken = default) { return await _dbSet .Where(m => m.ProposalId == proposalId) diff --git a/backend/src/Taskdeck.Infrastructure/Repositories/ChatSessionRepository.cs b/backend/src/Taskdeck.Infrastructure/Repositories/ChatSessionRepository.cs index 9b5ed2fd5..daa586393 100644 --- a/backend/src/Taskdeck.Infrastructure/Repositories/ChatSessionRepository.cs +++ b/backend/src/Taskdeck.Infrastructure/Repositories/ChatSessionRepository.cs @@ -11,7 +11,7 @@ public ChatSessionRepository(TaskdeckDbContext context) : base(context) { } - public async Task> GetByUserIdAsync(string userId, int limit = 100, CancellationToken cancellationToken = default) + public async Task> GetByUserIdAsync(Guid userId, int limit = 100, CancellationToken cancellationToken = default) { return await _dbSet .Where(s => s.UserId == userId) @@ -20,7 +20,7 @@ public async Task> GetByUserIdAsync(string userId, int .ToListAsync(cancellationToken); } - public async Task> GetByBoardIdAsync(string boardId, int limit = 100, CancellationToken cancellationToken = default) + public async Task> GetByBoardIdAsync(Guid boardId, int limit = 100, CancellationToken cancellationToken = default) { return await _dbSet .Where(s => s.BoardId == boardId) diff --git a/backend/src/Taskdeck.Infrastructure/Repositories/CommandRunRepository.cs b/backend/src/Taskdeck.Infrastructure/Repositories/CommandRunRepository.cs index 2c9a17d12..2cf8c8b9d 100644 --- a/backend/src/Taskdeck.Infrastructure/Repositories/CommandRunRepository.cs +++ b/backend/src/Taskdeck.Infrastructure/Repositories/CommandRunRepository.cs @@ -11,7 +11,7 @@ public CommandRunRepository(TaskdeckDbContext context) : base(context) { } - public async Task> GetByUserIdAsync(string userId, int limit = 100, CancellationToken cancellationToken = default) + public async Task> GetByUserIdAsync(Guid userId, int limit = 100, CancellationToken cancellationToken = default) { return await _dbSet .Where(c => c.RequestedByUserId == userId) From afdcd3e346adfefeaba270e44afd8cbc8425ce8f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 20:07:17 +0000 Subject: [PATCH 08/30] Add database migration for automation, archive, chat, and ops entities Co-authored-by: Chris0Jeky <59696583+Chris0Jeky@users.noreply.github.com> --- ...tomationArchiveChatOpsEntities.Designer.cs | 951 ++++++++++++++++++ ...649_AddAutomationArchiveChatOpsEntities.cs | 345 +++++++ .../TaskdeckDbContextModelSnapshot.cs | 446 ++++++++ 3 files changed, 1742 insertions(+) create mode 100644 backend/src/Taskdeck.Infrastructure/Migrations/20260212200649_AddAutomationArchiveChatOpsEntities.Designer.cs create mode 100644 backend/src/Taskdeck.Infrastructure/Migrations/20260212200649_AddAutomationArchiveChatOpsEntities.cs diff --git a/backend/src/Taskdeck.Infrastructure/Migrations/20260212200649_AddAutomationArchiveChatOpsEntities.Designer.cs b/backend/src/Taskdeck.Infrastructure/Migrations/20260212200649_AddAutomationArchiveChatOpsEntities.Designer.cs new file mode 100644 index 000000000..60499a545 --- /dev/null +++ b/backend/src/Taskdeck.Infrastructure/Migrations/20260212200649_AddAutomationArchiveChatOpsEntities.Designer.cs @@ -0,0 +1,951 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Taskdeck.Infrastructure.Persistence; + +#nullable disable + +namespace Taskdeck.Infrastructure.Migrations +{ + [DbContext(typeof(TaskdeckDbContext))] + [Migration("20260212200649_AddAutomationArchiveChatOpsEntities")] + partial class AddAutomationArchiveChatOpsEntities + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.0"); + + modelBuilder.Entity("Taskdeck.Domain.Entities.ArchiveItem", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ArchivedAt") + .HasColumnType("TEXT"); + + b.Property("ArchivedByUserId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("BoardId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Reason") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("RestoreStatus") + .HasColumnType("INTEGER"); + + b.Property("RestoredAt") + .HasColumnType("TEXT"); + + b.Property("RestoredByUserId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SnapshotJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ArchivedAt"); + + b.HasIndex("ArchivedByUserId"); + + b.HasIndex("BoardId"); + + b.HasIndex("RestoreStatus"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("ArchiveItems", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Action") + .HasColumnType("INTEGER"); + + b.Property("Changes") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasColumnType("TEXT"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Timestamp"); + + b.HasIndex("UserId"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("AuditLogs", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.AutomationProposal", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AppliedAt") + .HasColumnType("TEXT"); + + b.Property("BoardId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DecidedAt") + .HasColumnType("TEXT"); + + b.Property("DecidedByUserId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DiffPreview") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("FailureReason") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("RequestedByUserId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("RiskLevel") + .HasColumnType("INTEGER"); + + b.Property("SourceReferenceId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("ValidationIssues") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BoardId"); + + b.HasIndex("CorrelationId"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("RequestedByUserId"); + + b.HasIndex("Status"); + + b.ToTable("AutomationProposals", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.AutomationProposalOperation", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActionType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpectedVersion") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProposalId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Sequence") + .HasColumnType("INTEGER"); + + b.Property("TargetId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IdempotencyKey") + .IsUnique(); + + b.HasIndex("ProposalId"); + + b.HasIndex("ProposalId", "Sequence"); + + b.ToTable("AutomationProposalOperations", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Board", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("IsArchived") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("OwnerId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.ToTable("Boards", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.BoardAccess", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BoardId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GrantedAt") + .HasColumnType("TEXT"); + + b.Property("GrantedBy") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("BoardId", "UserId") + .IsUnique(); + + b.ToTable("BoardAccesses", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Card", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("BlockReason") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("BoardId") + .HasColumnType("TEXT"); + + b.Property("ColumnId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("DueDate") + .HasColumnType("TEXT"); + + b.Property("IsBlocked") + .HasColumnType("INTEGER"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BoardId"); + + b.HasIndex("ColumnId"); + + b.ToTable("Cards", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.CardLabel", b => + { + b.Property("CardId") + .HasColumnType("TEXT"); + + b.Property("LabelId") + .HasColumnType("TEXT"); + + b.HasKey("CardId", "LabelId"); + + b.HasIndex("LabelId"); + + b.ToTable("CardLabels", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.ChatMessage", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("MessageType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ProposalId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TokenUsage") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("ProposalId"); + + b.HasIndex("SessionId"); + + b.ToTable("ChatMessages", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.ChatSession", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("BoardId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BoardId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.HasIndex("UserId"); + + b.ToTable("ChatSessions", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Column", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("BoardId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WipLimit") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BoardId", "Position") + .IsUnique(); + + b.ToTable("Columns", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.CommandRun", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ExitCode") + .HasColumnType("INTEGER"); + + b.Property("OutputPreview") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("RequestedByUserId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TemplateName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Truncated") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CorrelationId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("RequestedByUserId"); + + b.HasIndex("Status"); + + b.ToTable("CommandRuns", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.CommandRunLog", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CommandRunId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Level") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Message") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Metadata") + .HasColumnType("TEXT"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CommandRunId"); + + b.HasIndex("Level"); + + b.HasIndex("Timestamp"); + + b.ToTable("CommandRunLogs", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Label", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("BoardId") + .HasColumnType("TEXT"); + + b.Property("ColorHex") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BoardId"); + + b.ToTable("Labels", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.LlmRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BoardId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProcessedAt") + .HasColumnType("TEXT"); + + b.Property("RequestType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("RetryCount") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BoardId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("LlmRequests", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DefaultRole") + .HasColumnType("INTEGER"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.AuditLog", b => + { + b.HasOne("Taskdeck.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.AutomationProposalOperation", b => + { + b.HasOne("Taskdeck.Domain.Entities.AutomationProposal", "Proposal") + .WithMany("Operations") + .HasForeignKey("ProposalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Proposal"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Board", b => + { + b.HasOne("Taskdeck.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.BoardAccess", b => + { + b.HasOne("Taskdeck.Domain.Entities.Board", "Board") + .WithMany("BoardAccesses") + .HasForeignKey("BoardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Taskdeck.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Board"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Card", b => + { + b.HasOne("Taskdeck.Domain.Entities.Board", "Board") + .WithMany("Cards") + .HasForeignKey("BoardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Taskdeck.Domain.Entities.Column", "Column") + .WithMany("Cards") + .HasForeignKey("ColumnId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Board"); + + b.Navigation("Column"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.CardLabel", b => + { + b.HasOne("Taskdeck.Domain.Entities.Card", "Card") + .WithMany("CardLabels") + .HasForeignKey("CardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Taskdeck.Domain.Entities.Label", "Label") + .WithMany("CardLabels") + .HasForeignKey("LabelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Card"); + + b.Navigation("Label"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.ChatMessage", b => + { + b.HasOne("Taskdeck.Domain.Entities.ChatSession", "Session") + .WithMany("Messages") + .HasForeignKey("SessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Session"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Column", b => + { + b.HasOne("Taskdeck.Domain.Entities.Board", "Board") + .WithMany("Columns") + .HasForeignKey("BoardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Board"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.CommandRunLog", b => + { + b.HasOne("Taskdeck.Domain.Entities.CommandRun", "CommandRun") + .WithMany("Logs") + .HasForeignKey("CommandRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CommandRun"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Label", b => + { + b.HasOne("Taskdeck.Domain.Entities.Board", "Board") + .WithMany("Labels") + .HasForeignKey("BoardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Board"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.LlmRequest", b => + { + b.HasOne("Taskdeck.Domain.Entities.Board", "Board") + .WithMany() + .HasForeignKey("BoardId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Taskdeck.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Board"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.AutomationProposal", b => + { + b.Navigation("Operations"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Board", b => + { + b.Navigation("BoardAccesses"); + + b.Navigation("Cards"); + + b.Navigation("Columns"); + + b.Navigation("Labels"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Card", b => + { + b.Navigation("CardLabels"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.ChatSession", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Column", b => + { + b.Navigation("Cards"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.CommandRun", b => + { + b.Navigation("Logs"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Label", b => + { + b.Navigation("CardLabels"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Taskdeck.Infrastructure/Migrations/20260212200649_AddAutomationArchiveChatOpsEntities.cs b/backend/src/Taskdeck.Infrastructure/Migrations/20260212200649_AddAutomationArchiveChatOpsEntities.cs new file mode 100644 index 000000000..fb9c99671 --- /dev/null +++ b/backend/src/Taskdeck.Infrastructure/Migrations/20260212200649_AddAutomationArchiveChatOpsEntities.cs @@ -0,0 +1,345 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Taskdeck.Infrastructure.Migrations +{ + /// + public partial class AddAutomationArchiveChatOpsEntities : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ArchiveItems", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + EntityType = table.Column(type: "TEXT", maxLength: 50, nullable: false), + EntityId = table.Column(type: "TEXT", maxLength: 100, nullable: false), + BoardId = table.Column(type: "TEXT", maxLength: 100, nullable: false), + Name = table.Column(type: "TEXT", maxLength: 200, nullable: false), + ArchivedByUserId = table.Column(type: "TEXT", maxLength: 100, nullable: false), + ArchivedAt = table.Column(type: "TEXT", nullable: false), + Reason = table.Column(type: "TEXT", maxLength: 500, nullable: true), + SnapshotJson = table.Column(type: "TEXT", nullable: false), + RestoreStatus = table.Column(type: "INTEGER", nullable: false), + RestoredAt = table.Column(type: "TEXT", nullable: true), + RestoredByUserId = table.Column(type: "TEXT", maxLength: 100, nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ArchiveItems", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AutomationProposals", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + SourceType = table.Column(type: "INTEGER", nullable: false), + SourceReferenceId = table.Column(type: "TEXT", maxLength: 100, nullable: true), + BoardId = table.Column(type: "TEXT", maxLength: 100, nullable: true), + RequestedByUserId = table.Column(type: "TEXT", maxLength: 100, nullable: false), + Status = table.Column(type: "INTEGER", nullable: false), + RiskLevel = table.Column(type: "INTEGER", nullable: false), + Summary = table.Column(type: "TEXT", maxLength: 500, nullable: false), + DiffPreview = table.Column(type: "TEXT", nullable: true), + ValidationIssues = table.Column(type: "TEXT", nullable: true), + ExpiresAt = table.Column(type: "TEXT", nullable: false), + DecidedAt = table.Column(type: "TEXT", nullable: true), + DecidedByUserId = table.Column(type: "TEXT", maxLength: 100, nullable: true), + AppliedAt = table.Column(type: "TEXT", nullable: true), + FailureReason = table.Column(type: "TEXT", maxLength: 1000, nullable: true), + CorrelationId = table.Column(type: "TEXT", maxLength: 100, nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AutomationProposals", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "ChatSessions", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + UserId = table.Column(type: "TEXT", maxLength: 100, nullable: false), + BoardId = table.Column(type: "TEXT", maxLength: 100, nullable: true), + Title = table.Column(type: "TEXT", maxLength: 200, nullable: false), + Status = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ChatSessions", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "CommandRuns", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + TemplateName = table.Column(type: "TEXT", maxLength: 100, nullable: false), + RequestedByUserId = table.Column(type: "TEXT", maxLength: 100, nullable: false), + Status = table.Column(type: "INTEGER", nullable: false), + StartedAt = table.Column(type: "TEXT", nullable: true), + CompletedAt = table.Column(type: "TEXT", nullable: true), + ExitCode = table.Column(type: "INTEGER", nullable: true), + Truncated = table.Column(type: "INTEGER", nullable: false), + CorrelationId = table.Column(type: "TEXT", maxLength: 100, nullable: false), + ErrorMessage = table.Column(type: "TEXT", maxLength: 2000, nullable: true), + OutputPreview = table.Column(type: "TEXT", maxLength: 1000, nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CommandRuns", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AutomationProposalOperations", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + ProposalId = table.Column(type: "TEXT", maxLength: 100, nullable: false), + Sequence = table.Column(type: "INTEGER", nullable: false), + ActionType = table.Column(type: "TEXT", maxLength: 100, nullable: false), + TargetType = table.Column(type: "TEXT", maxLength: 100, nullable: false), + TargetId = table.Column(type: "TEXT", maxLength: 100, nullable: true), + Parameters = table.Column(type: "TEXT", nullable: false), + IdempotencyKey = table.Column(type: "TEXT", maxLength: 100, nullable: false), + ExpectedVersion = table.Column(type: "TEXT", maxLength: 100, nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AutomationProposalOperations", x => x.Id); + table.ForeignKey( + name: "FK_AutomationProposalOperations_AutomationProposals_ProposalId", + column: x => x.ProposalId, + principalTable: "AutomationProposals", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ChatMessages", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + SessionId = table.Column(type: "TEXT", maxLength: 100, nullable: false), + Role = table.Column(type: "INTEGER", nullable: false), + Content = table.Column(type: "TEXT", nullable: false), + MessageType = table.Column(type: "TEXT", maxLength: 50, nullable: false), + ProposalId = table.Column(type: "TEXT", maxLength: 100, nullable: true), + TokenUsage = table.Column(type: "INTEGER", nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ChatMessages", x => x.Id); + table.ForeignKey( + name: "FK_ChatMessages_ChatSessions_SessionId", + column: x => x.SessionId, + principalTable: "ChatSessions", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "CommandRunLogs", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + CommandRunId = table.Column(type: "TEXT", maxLength: 100, nullable: false), + Timestamp = table.Column(type: "TEXT", nullable: false), + Level = table.Column(type: "TEXT", maxLength: 50, nullable: false), + Source = table.Column(type: "TEXT", maxLength: 200, nullable: false), + Message = table.Column(type: "TEXT", nullable: false), + Metadata = table.Column(type: "TEXT", nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CommandRunLogs", x => x.Id); + table.ForeignKey( + name: "FK_CommandRunLogs_CommandRuns_CommandRunId", + column: x => x.CommandRunId, + principalTable: "CommandRuns", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ArchiveItems_ArchivedAt", + table: "ArchiveItems", + column: "ArchivedAt"); + + migrationBuilder.CreateIndex( + name: "IX_ArchiveItems_ArchivedByUserId", + table: "ArchiveItems", + column: "ArchivedByUserId"); + + migrationBuilder.CreateIndex( + name: "IX_ArchiveItems_BoardId", + table: "ArchiveItems", + column: "BoardId"); + + migrationBuilder.CreateIndex( + name: "IX_ArchiveItems_EntityType_EntityId", + table: "ArchiveItems", + columns: new[] { "EntityType", "EntityId" }); + + migrationBuilder.CreateIndex( + name: "IX_ArchiveItems_RestoreStatus", + table: "ArchiveItems", + column: "RestoreStatus"); + + migrationBuilder.CreateIndex( + name: "IX_AutomationProposalOperations_IdempotencyKey", + table: "AutomationProposalOperations", + column: "IdempotencyKey", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_AutomationProposalOperations_ProposalId", + table: "AutomationProposalOperations", + column: "ProposalId"); + + migrationBuilder.CreateIndex( + name: "IX_AutomationProposalOperations_ProposalId_Sequence", + table: "AutomationProposalOperations", + columns: new[] { "ProposalId", "Sequence" }); + + migrationBuilder.CreateIndex( + name: "IX_AutomationProposals_BoardId", + table: "AutomationProposals", + column: "BoardId"); + + migrationBuilder.CreateIndex( + name: "IX_AutomationProposals_CorrelationId", + table: "AutomationProposals", + column: "CorrelationId"); + + migrationBuilder.CreateIndex( + name: "IX_AutomationProposals_ExpiresAt", + table: "AutomationProposals", + column: "ExpiresAt"); + + migrationBuilder.CreateIndex( + name: "IX_AutomationProposals_RequestedByUserId", + table: "AutomationProposals", + column: "RequestedByUserId"); + + migrationBuilder.CreateIndex( + name: "IX_AutomationProposals_Status", + table: "AutomationProposals", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_ChatMessages_CreatedAt", + table: "ChatMessages", + column: "CreatedAt"); + + migrationBuilder.CreateIndex( + name: "IX_ChatMessages_ProposalId", + table: "ChatMessages", + column: "ProposalId"); + + migrationBuilder.CreateIndex( + name: "IX_ChatMessages_SessionId", + table: "ChatMessages", + column: "SessionId"); + + migrationBuilder.CreateIndex( + name: "IX_ChatSessions_BoardId", + table: "ChatSessions", + column: "BoardId"); + + migrationBuilder.CreateIndex( + name: "IX_ChatSessions_CreatedAt", + table: "ChatSessions", + column: "CreatedAt"); + + migrationBuilder.CreateIndex( + name: "IX_ChatSessions_Status", + table: "ChatSessions", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_ChatSessions_UserId", + table: "ChatSessions", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_CommandRunLogs_CommandRunId", + table: "CommandRunLogs", + column: "CommandRunId"); + + migrationBuilder.CreateIndex( + name: "IX_CommandRunLogs_Level", + table: "CommandRunLogs", + column: "Level"); + + migrationBuilder.CreateIndex( + name: "IX_CommandRunLogs_Timestamp", + table: "CommandRunLogs", + column: "Timestamp"); + + migrationBuilder.CreateIndex( + name: "IX_CommandRuns_CorrelationId", + table: "CommandRuns", + column: "CorrelationId"); + + migrationBuilder.CreateIndex( + name: "IX_CommandRuns_CreatedAt", + table: "CommandRuns", + column: "CreatedAt"); + + migrationBuilder.CreateIndex( + name: "IX_CommandRuns_RequestedByUserId", + table: "CommandRuns", + column: "RequestedByUserId"); + + migrationBuilder.CreateIndex( + name: "IX_CommandRuns_Status", + table: "CommandRuns", + column: "Status"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ArchiveItems"); + + migrationBuilder.DropTable( + name: "AutomationProposalOperations"); + + migrationBuilder.DropTable( + name: "ChatMessages"); + + migrationBuilder.DropTable( + name: "CommandRunLogs"); + + migrationBuilder.DropTable( + name: "AutomationProposals"); + + migrationBuilder.DropTable( + name: "ChatSessions"); + + migrationBuilder.DropTable( + name: "CommandRuns"); + } + } +} diff --git a/backend/src/Taskdeck.Infrastructure/Migrations/TaskdeckDbContextModelSnapshot.cs b/backend/src/Taskdeck.Infrastructure/Migrations/TaskdeckDbContextModelSnapshot.cs index 6d1ef9269..57e5cfa7f 100644 --- a/backend/src/Taskdeck.Infrastructure/Migrations/TaskdeckDbContextModelSnapshot.cs +++ b/backend/src/Taskdeck.Infrastructure/Migrations/TaskdeckDbContextModelSnapshot.cs @@ -17,6 +17,75 @@ protected override void BuildModel(ModelBuilder modelBuilder) #pragma warning disable 612, 618 modelBuilder.HasAnnotation("ProductVersion", "8.0.0"); + modelBuilder.Entity("Taskdeck.Domain.Entities.ArchiveItem", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ArchivedAt") + .HasColumnType("TEXT"); + + b.Property("ArchivedByUserId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("BoardId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Reason") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("RestoreStatus") + .HasColumnType("INTEGER"); + + b.Property("RestoredAt") + .HasColumnType("TEXT"); + + b.Property("RestoredByUserId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SnapshotJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ArchivedAt"); + + b.HasIndex("ArchivedByUserId"); + + b.HasIndex("BoardId"); + + b.HasIndex("RestoreStatus"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("ArchiveItems", (string)null); + }); + modelBuilder.Entity("Taskdeck.Domain.Entities.AuditLog", b => { b.Property("Id") @@ -61,6 +130,143 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("AuditLogs", (string)null); }); + modelBuilder.Entity("Taskdeck.Domain.Entities.AutomationProposal", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AppliedAt") + .HasColumnType("TEXT"); + + b.Property("BoardId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DecidedAt") + .HasColumnType("TEXT"); + + b.Property("DecidedByUserId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DiffPreview") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("FailureReason") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("RequestedByUserId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("RiskLevel") + .HasColumnType("INTEGER"); + + b.Property("SourceReferenceId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("ValidationIssues") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BoardId"); + + b.HasIndex("CorrelationId"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("RequestedByUserId"); + + b.HasIndex("Status"); + + b.ToTable("AutomationProposals", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.AutomationProposalOperation", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActionType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpectedVersion") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProposalId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Sequence") + .HasColumnType("INTEGER"); + + b.Property("TargetId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IdempotencyKey") + .IsUnique(); + + b.HasIndex("ProposalId"); + + b.HasIndex("ProposalId", "Sequence"); + + b.ToTable("AutomationProposalOperations", (string)null); + }); + modelBuilder.Entity("Taskdeck.Domain.Entities.Board", b => { b.Property("Id") @@ -195,6 +401,91 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("CardLabels", (string)null); }); + modelBuilder.Entity("Taskdeck.Domain.Entities.ChatMessage", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("MessageType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ProposalId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TokenUsage") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("ProposalId"); + + b.HasIndex("SessionId"); + + b.ToTable("ChatMessages", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.ChatSession", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("BoardId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BoardId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.HasIndex("UserId"); + + b.ToTable("ChatSessions", (string)null); + }); + modelBuilder.Entity("Taskdeck.Domain.Entities.Column", b => { b.Property("Id") @@ -228,6 +519,113 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Columns", (string)null); }); + modelBuilder.Entity("Taskdeck.Domain.Entities.CommandRun", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ExitCode") + .HasColumnType("INTEGER"); + + b.Property("OutputPreview") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("RequestedByUserId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TemplateName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Truncated") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CorrelationId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("RequestedByUserId"); + + b.HasIndex("Status"); + + b.ToTable("CommandRuns", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.CommandRunLog", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CommandRunId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Level") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Message") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Metadata") + .HasColumnType("TEXT"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CommandRunId"); + + b.HasIndex("Level"); + + b.HasIndex("Timestamp"); + + b.ToTable("CommandRunLogs", (string)null); + }); + modelBuilder.Entity("Taskdeck.Domain.Entities.Label", b => { b.Property("Id") @@ -366,6 +764,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("Taskdeck.Domain.Entities.AutomationProposalOperation", b => + { + b.HasOne("Taskdeck.Domain.Entities.AutomationProposal", "Proposal") + .WithMany("Operations") + .HasForeignKey("ProposalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Proposal"); + }); + modelBuilder.Entity("Taskdeck.Domain.Entities.Board", b => { b.HasOne("Taskdeck.Domain.Entities.User", null) @@ -431,6 +840,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Label"); }); + modelBuilder.Entity("Taskdeck.Domain.Entities.ChatMessage", b => + { + b.HasOne("Taskdeck.Domain.Entities.ChatSession", "Session") + .WithMany("Messages") + .HasForeignKey("SessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Session"); + }); + modelBuilder.Entity("Taskdeck.Domain.Entities.Column", b => { b.HasOne("Taskdeck.Domain.Entities.Board", "Board") @@ -442,6 +862,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Board"); }); + modelBuilder.Entity("Taskdeck.Domain.Entities.CommandRunLog", b => + { + b.HasOne("Taskdeck.Domain.Entities.CommandRun", "CommandRun") + .WithMany("Logs") + .HasForeignKey("CommandRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CommandRun"); + }); + modelBuilder.Entity("Taskdeck.Domain.Entities.Label", b => { b.HasOne("Taskdeck.Domain.Entities.Board", "Board") @@ -471,6 +902,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("Taskdeck.Domain.Entities.AutomationProposal", b => + { + b.Navigation("Operations"); + }); + modelBuilder.Entity("Taskdeck.Domain.Entities.Board", b => { b.Navigation("BoardAccesses"); @@ -487,11 +923,21 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("CardLabels"); }); + modelBuilder.Entity("Taskdeck.Domain.Entities.ChatSession", b => + { + b.Navigation("Messages"); + }); + modelBuilder.Entity("Taskdeck.Domain.Entities.Column", b => { b.Navigation("Cards"); }); + modelBuilder.Entity("Taskdeck.Domain.Entities.CommandRun", b => + { + b.Navigation("Logs"); + }); + modelBuilder.Entity("Taskdeck.Domain.Entities.Label", b => { b.Navigation("CardLabels"); From 8239f1e460d8ba6c2e5f2620509abf677cfba397 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 20:11:56 +0000 Subject: [PATCH 09/30] Implement AutomationProposalService with DTOs and comprehensive tests - Created IAutomationProposalService interface with all required operations - Implemented AutomationProposalService using Result pattern and IUnitOfWork - Created AutomationProposalDtos with proposal and operation DTOs - Added comprehensive test suite with 18 passing tests - All 338 tests in solution pass Co-authored-by: Chris0Jeky <59696583+Chris0Jeky@users.noreply.github.com> --- .../DTOs/AutomationProposalDtos.cs | 71 +++ .../Services/AutomationProposalService.cs | 265 +++++++++ .../Services/IAutomationProposalService.cs | 53 ++ .../AutomationProposalServiceTests.cs | 508 ++++++++++++++++++ 4 files changed, 897 insertions(+) create mode 100644 backend/src/Taskdeck.Application/DTOs/AutomationProposalDtos.cs create mode 100644 backend/src/Taskdeck.Application/Services/AutomationProposalService.cs create mode 100644 backend/src/Taskdeck.Application/Services/IAutomationProposalService.cs create mode 100644 backend/tests/Taskdeck.Application.Tests/Services/AutomationProposalServiceTests.cs diff --git a/backend/src/Taskdeck.Application/DTOs/AutomationProposalDtos.cs b/backend/src/Taskdeck.Application/DTOs/AutomationProposalDtos.cs new file mode 100644 index 000000000..947cb42b6 --- /dev/null +++ b/backend/src/Taskdeck.Application/DTOs/AutomationProposalDtos.cs @@ -0,0 +1,71 @@ +using Taskdeck.Domain.Entities; + +namespace Taskdeck.Application.DTOs; + +public record ProposalDto( + Guid Id, + ProposalSourceType SourceType, + string? SourceReferenceId, + Guid? BoardId, + Guid RequestedByUserId, + ProposalStatus Status, + RiskLevel RiskLevel, + string Summary, + string? DiffPreview, + string? ValidationIssues, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt, + DateTime ExpiresAt, + DateTime? DecidedAt, + Guid? DecidedByUserId, + DateTime? AppliedAt, + string? FailureReason, + string CorrelationId, + List Operations +); + +public record ProposalOperationDto( + Guid Id, + Guid ProposalId, + int Sequence, + string ActionType, + string TargetType, + string? TargetId, + string Parameters, + string IdempotencyKey, + string? ExpectedVersion +); + +public record CreateProposalDto( + ProposalSourceType SourceType, + Guid RequestedByUserId, + string Summary, + RiskLevel RiskLevel, + string CorrelationId, + Guid? BoardId = null, + string? SourceReferenceId = null, + int ExpiryMinutes = 1440, + List? Operations = null +); + +public record CreateProposalOperationDto( + int Sequence, + string ActionType, + string TargetType, + string Parameters, + string IdempotencyKey, + string? TargetId = null, + string? ExpectedVersion = null +); + +public record UpdateProposalStatusDto( + string? Reason = null +); + +public record ProposalFilterDto( + ProposalStatus? Status = null, + Guid? BoardId = null, + Guid? UserId = null, + RiskLevel? RiskLevel = null, + int Limit = 100 +); diff --git a/backend/src/Taskdeck.Application/Services/AutomationProposalService.cs b/backend/src/Taskdeck.Application/Services/AutomationProposalService.cs new file mode 100644 index 000000000..1642ddda6 --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/AutomationProposalService.cs @@ -0,0 +1,265 @@ +using Taskdeck.Application.DTOs; +using Taskdeck.Application.Interfaces; +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Exceptions; + +namespace Taskdeck.Application.Services; + +public class AutomationProposalService : IAutomationProposalService +{ + private readonly IUnitOfWork _unitOfWork; + + public AutomationProposalService(IUnitOfWork unitOfWork) + { + _unitOfWork = unitOfWork; + } + + public async Task> CreateProposalAsync(CreateProposalDto dto, CancellationToken cancellationToken = default) + { + try + { + var proposal = new AutomationProposal( + dto.SourceType, + dto.RequestedByUserId, + dto.Summary, + dto.RiskLevel, + dto.CorrelationId, + dto.BoardId, + dto.SourceReferenceId, + dto.ExpiryMinutes); + + await _unitOfWork.AutomationProposals.AddAsync(proposal, cancellationToken); + + // Add operations if provided + if (dto.Operations != null) + { + foreach (var opDto in dto.Operations) + { + var operation = new AutomationProposalOperation( + proposal.Id, + opDto.Sequence, + opDto.ActionType, + opDto.TargetType, + opDto.Parameters, + opDto.IdempotencyKey, + opDto.TargetId, + opDto.ExpectedVersion); + + proposal.AddOperation(operation); + } + } + + await _unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Success(MapToDto(proposal)); + } + catch (DomainException ex) + { + return Result.Failure(ex.ErrorCode, ex.Message); + } + } + + public async Task> GetProposalByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + var proposal = await _unitOfWork.AutomationProposals.GetByIdAsync(id, cancellationToken); + if (proposal == null) + return Result.Failure(ErrorCodes.NotFound, $"Proposal with ID {id} not found"); + + return Result.Success(MapToDto(proposal)); + } + + public async Task>> GetProposalsAsync(ProposalFilterDto? filter = null, CancellationToken cancellationToken = default) + { + filter ??= new ProposalFilterDto(); + + IEnumerable proposals; + + // Apply filters in order of specificity + if (filter.Status.HasValue) + { + proposals = await _unitOfWork.AutomationProposals.GetByStatusAsync(filter.Status.Value, filter.Limit, cancellationToken); + } + else if (filter.BoardId.HasValue) + { + proposals = await _unitOfWork.AutomationProposals.GetByBoardIdAsync(filter.BoardId.Value, cancellationToken); + } + else if (filter.UserId.HasValue) + { + proposals = await _unitOfWork.AutomationProposals.GetByUserIdAsync(filter.UserId.Value, filter.Limit, cancellationToken); + } + else if (filter.RiskLevel.HasValue) + { + proposals = await _unitOfWork.AutomationProposals.GetByRiskLevelAsync(filter.RiskLevel.Value, filter.Limit, cancellationToken); + } + else + { + // Get all by status Pending if no filters provided + proposals = await _unitOfWork.AutomationProposals.GetByStatusAsync(ProposalStatus.PendingReview, filter.Limit, cancellationToken); + } + + // Apply additional filters in memory if multiple filters are specified + if (filter.Status.HasValue && filter.BoardId.HasValue) + proposals = proposals.Where(p => p.BoardId == filter.BoardId.Value); + + if (filter.Status.HasValue && filter.UserId.HasValue) + proposals = proposals.Where(p => p.RequestedByUserId == filter.UserId.Value); + + if (filter.Status.HasValue && filter.RiskLevel.HasValue) + proposals = proposals.Where(p => p.RiskLevel == filter.RiskLevel.Value); + + return Result.Success(proposals.Select(MapToDto)); + } + + public async Task> ApproveProposalAsync(Guid id, Guid decidedByUserId, CancellationToken cancellationToken = default) + { + try + { + var proposal = await _unitOfWork.AutomationProposals.GetByIdAsync(id, cancellationToken); + if (proposal == null) + return Result.Failure(ErrorCodes.NotFound, $"Proposal with ID {id} not found"); + + proposal.Approve(decidedByUserId); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Success(MapToDto(proposal)); + } + catch (DomainException ex) + { + return Result.Failure(ex.ErrorCode, ex.Message); + } + } + + public async Task> RejectProposalAsync(Guid id, Guid decidedByUserId, UpdateProposalStatusDto dto, CancellationToken cancellationToken = default) + { + try + { + var proposal = await _unitOfWork.AutomationProposals.GetByIdAsync(id, cancellationToken); + if (proposal == null) + return Result.Failure(ErrorCodes.NotFound, $"Proposal with ID {id} not found"); + + proposal.Reject(decidedByUserId, dto.Reason); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Success(MapToDto(proposal)); + } + catch (DomainException ex) + { + return Result.Failure(ex.ErrorCode, ex.Message); + } + } + + public async Task> MarkAsAppliedAsync(Guid id, CancellationToken cancellationToken = default) + { + try + { + var proposal = await _unitOfWork.AutomationProposals.GetByIdAsync(id, cancellationToken); + if (proposal == null) + return Result.Failure(ErrorCodes.NotFound, $"Proposal with ID {id} not found"); + + proposal.MarkAsApplied(); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Success(MapToDto(proposal)); + } + catch (DomainException ex) + { + return Result.Failure(ex.ErrorCode, ex.Message); + } + } + + public async Task> MarkAsFailedAsync(Guid id, string failureReason, CancellationToken cancellationToken = default) + { + try + { + var proposal = await _unitOfWork.AutomationProposals.GetByIdAsync(id, cancellationToken); + if (proposal == null) + return Result.Failure(ErrorCodes.NotFound, $"Proposal with ID {id} not found"); + + proposal.MarkAsFailed(failureReason); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Success(MapToDto(proposal)); + } + catch (DomainException ex) + { + return Result.Failure(ex.ErrorCode, ex.Message); + } + } + + public async Task> ExpireProposalsAsync(CancellationToken cancellationToken = default) + { + try + { + var expiredProposals = await _unitOfWork.AutomationProposals.GetExpiredAsync(cancellationToken); + int count = 0; + + foreach (var proposal in expiredProposals) + { + proposal.Expire(); + count++; + } + + if (count > 0) + await _unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Success(count); + } + catch (DomainException ex) + { + return Result.Failure(ex.ErrorCode, ex.Message); + } + } + + public async Task> GetProposalDiffAsync(Guid id, CancellationToken cancellationToken = default) + { + var proposal = await _unitOfWork.AutomationProposals.GetByIdAsync(id, cancellationToken); + if (proposal == null) + return Result.Failure(ErrorCodes.NotFound, $"Proposal with ID {id} not found"); + + if (string.IsNullOrEmpty(proposal.DiffPreview)) + return Result.Failure(ErrorCodes.NotFound, "Diff preview not available for this proposal"); + + return Result.Success(proposal.DiffPreview); + } + + private static ProposalDto MapToDto(AutomationProposal proposal) + { + return new ProposalDto( + proposal.Id, + proposal.SourceType, + proposal.SourceReferenceId, + proposal.BoardId, + proposal.RequestedByUserId, + proposal.Status, + proposal.RiskLevel, + proposal.Summary, + proposal.DiffPreview, + proposal.ValidationIssues, + proposal.CreatedAt, + proposal.UpdatedAt, + proposal.ExpiresAt, + proposal.DecidedAt, + proposal.DecidedByUserId, + proposal.AppliedAt, + proposal.FailureReason, + proposal.CorrelationId, + proposal.Operations.Select(MapOperationToDto).ToList() + ); + } + + private static ProposalOperationDto MapOperationToDto(AutomationProposalOperation operation) + { + return new ProposalOperationDto( + operation.Id, + operation.ProposalId, + operation.Sequence, + operation.ActionType, + operation.TargetType, + operation.TargetId, + operation.Parameters, + operation.IdempotencyKey, + operation.ExpectedVersion + ); + } +} diff --git a/backend/src/Taskdeck.Application/Services/IAutomationProposalService.cs b/backend/src/Taskdeck.Application/Services/IAutomationProposalService.cs new file mode 100644 index 000000000..b3691d489 --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/IAutomationProposalService.cs @@ -0,0 +1,53 @@ +using Taskdeck.Application.DTOs; +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Entities; + +namespace Taskdeck.Application.Services; + +public interface IAutomationProposalService +{ + /// + /// Creates a new automation proposal with operations. + /// + Task> CreateProposalAsync(CreateProposalDto dto, CancellationToken cancellationToken = default); + + /// + /// Gets a proposal by ID with all operations. + /// + Task> GetProposalByIdAsync(Guid id, CancellationToken cancellationToken = default); + + /// + /// Gets proposals with optional filters. + /// + Task>> GetProposalsAsync(ProposalFilterDto? filter = null, CancellationToken cancellationToken = default); + + /// + /// Approves a pending proposal. + /// + Task> ApproveProposalAsync(Guid id, Guid decidedByUserId, CancellationToken cancellationToken = default); + + /// + /// Rejects a pending proposal with optional reason (required for High/Critical risk). + /// + Task> RejectProposalAsync(Guid id, Guid decidedByUserId, UpdateProposalStatusDto dto, CancellationToken cancellationToken = default); + + /// + /// Marks an approved proposal as applied. + /// + Task> MarkAsAppliedAsync(Guid id, CancellationToken cancellationToken = default); + + /// + /// Marks an approved proposal as failed with reason. + /// + Task> MarkAsFailedAsync(Guid id, string failureReason, CancellationToken cancellationToken = default); + + /// + /// Expires all stale pending proposals. + /// + Task> ExpireProposalsAsync(CancellationToken cancellationToken = default); + + /// + /// Gets the diff preview for a proposal. + /// + Task> GetProposalDiffAsync(Guid id, CancellationToken cancellationToken = default); +} diff --git a/backend/tests/Taskdeck.Application.Tests/Services/AutomationProposalServiceTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/AutomationProposalServiceTests.cs new file mode 100644 index 000000000..5a360aa2c --- /dev/null +++ b/backend/tests/Taskdeck.Application.Tests/Services/AutomationProposalServiceTests.cs @@ -0,0 +1,508 @@ +using FluentAssertions; +using Moq; +using Taskdeck.Application.DTOs; +using Taskdeck.Application.Interfaces; +using Taskdeck.Application.Services; +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Exceptions; +using Xunit; + +namespace Taskdeck.Application.Tests.Services; + +public class AutomationProposalServiceTests +{ + private readonly Mock _unitOfWorkMock; + private readonly Mock _proposalRepoMock; + private readonly AutomationProposalService _service; + + public AutomationProposalServiceTests() + { + _unitOfWorkMock = new Mock(); + _proposalRepoMock = new Mock(); + + _unitOfWorkMock.Setup(u => u.AutomationProposals).Returns(_proposalRepoMock.Object); + + _service = new AutomationProposalService(_unitOfWorkMock.Object); + } + + #region CreateProposalAsync Tests + + [Fact] + public async Task CreateProposalAsync_ShouldReturnSuccess_WithValidData() + { + // Arrange + var dto = new CreateProposalDto( + ProposalSourceType.Chat, + Guid.NewGuid(), + "Create new card", + RiskLevel.Low, + Guid.NewGuid().ToString()); + + _proposalRepoMock.Setup(r => r.AddAsync(It.IsAny(), default)) + .ReturnsAsync((AutomationProposal p, CancellationToken ct) => p); + + // Act + var result = await _service.CreateProposalAsync(dto); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Summary.Should().Be("Create new card"); + result.Value.Status.Should().Be(ProposalStatus.PendingReview); + result.Value.RiskLevel.Should().Be(RiskLevel.Low); + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(default), Times.Once); + } + + [Fact] + public async Task CreateProposalAsync_ShouldAddOperations_WhenProvided() + { + // Arrange + var operations = new List + { + new(0, "card.create", "Card", "{\"name\":\"Test\"}", "key1"), + new(1, "card.move", "Card", "{\"position\":5}", "key2", "card-123") + }; + + var dto = new CreateProposalDto( + ProposalSourceType.Manual, + Guid.NewGuid(), + "Multi-step operation", + RiskLevel.Medium, + Guid.NewGuid().ToString(), + Operations: operations); + + _proposalRepoMock.Setup(r => r.AddAsync(It.IsAny(), default)) + .ReturnsAsync((AutomationProposal p, CancellationToken ct) => p); + + // Act + var result = await _service.CreateProposalAsync(dto); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Operations.Should().HaveCount(2); + result.Value.Operations[0].Sequence.Should().Be(0); + result.Value.Operations[1].Sequence.Should().Be(1); + } + + [Fact] + public async Task CreateProposalAsync_ShouldReturnValidationError_WhenSummaryIsEmpty() + { + // Arrange + var dto = new CreateProposalDto( + ProposalSourceType.Queue, + Guid.NewGuid(), + "", + RiskLevel.Low, + Guid.NewGuid().ToString()); + + // Act + var result = await _service.CreateProposalAsync(dto); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(default), Times.Never); + } + + #endregion + + #region GetProposalByIdAsync Tests + + [Fact] + public async Task GetProposalByIdAsync_ShouldReturnProposal_WhenExists() + { + // Arrange + var proposalId = Guid.NewGuid(); + var proposal = new AutomationProposal( + ProposalSourceType.Chat, + Guid.NewGuid(), + "Test proposal", + RiskLevel.Low, + Guid.NewGuid().ToString()); + + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposalId, default)) + .ReturnsAsync(proposal); + + // Act + var result = await _service.GetProposalByIdAsync(proposalId); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Id.Should().Be(proposal.Id); + result.Value.Summary.Should().Be("Test proposal"); + } + + [Fact] + public async Task GetProposalByIdAsync_ShouldReturnNotFound_WhenDoesNotExist() + { + // Arrange + var proposalId = Guid.NewGuid(); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposalId, default)) + .ReturnsAsync((AutomationProposal?)null); + + // Act + var result = await _service.GetProposalByIdAsync(proposalId); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.NotFound); + } + + #endregion + + #region ApproveProposalAsync Tests + + [Fact] + public async Task ApproveProposalAsync_ShouldReturnSuccess_WhenPending() + { + // Arrange + var proposalId = Guid.NewGuid(); + var deciderId = Guid.NewGuid(); + var proposal = new AutomationProposal( + ProposalSourceType.Chat, + Guid.NewGuid(), + "Test proposal", + RiskLevel.Low, + Guid.NewGuid().ToString()); + + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposalId, default)) + .ReturnsAsync(proposal); + + // Act + var result = await _service.ApproveProposalAsync(proposalId, deciderId); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Status.Should().Be(ProposalStatus.Approved); + result.Value.DecidedByUserId.Should().Be(deciderId); + result.Value.DecidedAt.Should().NotBeNull(); + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(default), Times.Once); + } + + [Fact] + public async Task ApproveProposalAsync_ShouldReturnInvalidOperation_WhenAlreadyApproved() + { + // Arrange + var proposalId = Guid.NewGuid(); + var deciderId = Guid.NewGuid(); + var proposal = new AutomationProposal( + ProposalSourceType.Chat, + Guid.NewGuid(), + "Test proposal", + RiskLevel.Low, + Guid.NewGuid().ToString()); + + proposal.Approve(deciderId); + + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposalId, default)) + .ReturnsAsync(proposal); + + // Act + var result = await _service.ApproveProposalAsync(proposalId, Guid.NewGuid()); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.InvalidOperation); + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(default), Times.Never); + } + + #endregion + + #region RejectProposalAsync Tests + + [Fact] + public async Task RejectProposalAsync_ShouldReturnSuccess_WhenPending() + { + // Arrange + var proposalId = Guid.NewGuid(); + var deciderId = Guid.NewGuid(); + var proposal = new AutomationProposal( + ProposalSourceType.Chat, + Guid.NewGuid(), + "Test proposal", + RiskLevel.Low, + Guid.NewGuid().ToString()); + + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposalId, default)) + .ReturnsAsync(proposal); + + // Act + var result = await _service.RejectProposalAsync( + proposalId, + deciderId, + new UpdateProposalStatusDto("Not needed")); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Status.Should().Be(ProposalStatus.Rejected); + result.Value.DecidedByUserId.Should().Be(deciderId); + result.Value.FailureReason.Should().Be("Not needed"); + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(default), Times.Once); + } + + [Fact] + public async Task RejectProposalAsync_ShouldRequireReason_ForHighRisk() + { + // Arrange + var proposalId = Guid.NewGuid(); + var deciderId = Guid.NewGuid(); + var proposal = new AutomationProposal( + ProposalSourceType.Chat, + Guid.NewGuid(), + "Test proposal", + RiskLevel.High, + Guid.NewGuid().ToString()); + + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposalId, default)) + .ReturnsAsync(proposal); + + // Act + var result = await _service.RejectProposalAsync( + proposalId, + deciderId, + new UpdateProposalStatusDto()); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(default), Times.Never); + } + + #endregion + + #region MarkAsAppliedAsync Tests + + [Fact] + public async Task MarkAsAppliedAsync_ShouldReturnSuccess_WhenApproved() + { + // Arrange + var proposalId = Guid.NewGuid(); + var proposal = new AutomationProposal( + ProposalSourceType.Chat, + Guid.NewGuid(), + "Test proposal", + RiskLevel.Low, + Guid.NewGuid().ToString()); + + proposal.Approve(Guid.NewGuid()); + + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposalId, default)) + .ReturnsAsync(proposal); + + // Act + var result = await _service.MarkAsAppliedAsync(proposalId); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Status.Should().Be(ProposalStatus.Applied); + result.Value.AppliedAt.Should().NotBeNull(); + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(default), Times.Once); + } + + [Fact] + public async Task MarkAsAppliedAsync_ShouldReturnInvalidOperation_WhenNotApproved() + { + // Arrange + var proposalId = Guid.NewGuid(); + var proposal = new AutomationProposal( + ProposalSourceType.Chat, + Guid.NewGuid(), + "Test proposal", + RiskLevel.Low, + Guid.NewGuid().ToString()); + + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposalId, default)) + .ReturnsAsync(proposal); + + // Act + var result = await _service.MarkAsAppliedAsync(proposalId); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.InvalidOperation); + } + + #endregion + + #region MarkAsFailedAsync Tests + + [Fact] + public async Task MarkAsFailedAsync_ShouldReturnSuccess_WhenApproved() + { + // Arrange + var proposalId = Guid.NewGuid(); + var proposal = new AutomationProposal( + ProposalSourceType.Chat, + Guid.NewGuid(), + "Test proposal", + RiskLevel.Low, + Guid.NewGuid().ToString()); + + proposal.Approve(Guid.NewGuid()); + + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposalId, default)) + .ReturnsAsync(proposal); + + // Act + var result = await _service.MarkAsFailedAsync(proposalId, "Database error"); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Status.Should().Be(ProposalStatus.Failed); + result.Value.FailureReason.Should().Be("Database error"); + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(default), Times.Once); + } + + #endregion + + #region ExpireProposalsAsync Tests + + [Fact] + public async Task ExpireProposalsAsync_ShouldExpireAllStaleProposals() + { + // Arrange + var proposal1 = new AutomationProposal( + ProposalSourceType.Chat, + Guid.NewGuid(), + "Test 1", + RiskLevel.Low, + Guid.NewGuid().ToString(), + expiryMinutes: 1); + + var proposal2 = new AutomationProposal( + ProposalSourceType.Chat, + Guid.NewGuid(), + "Test 2", + RiskLevel.Low, + Guid.NewGuid().ToString(), + expiryMinutes: 1); + + // Simulate that these are expired (repository would return expired ones) + _proposalRepoMock.Setup(r => r.GetExpiredAsync(default)) + .ReturnsAsync(new[] { proposal1, proposal2 }); + + // Act + var result = await _service.ExpireProposalsAsync(); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(2); + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(default), Times.Once); + } + + [Fact] + public async Task ExpireProposalsAsync_ShouldReturnZero_WhenNoExpiredProposals() + { + // Arrange + _proposalRepoMock.Setup(r => r.GetExpiredAsync(default)) + .ReturnsAsync(Array.Empty()); + + // Act + var result = await _service.ExpireProposalsAsync(); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(0); + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(default), Times.Never); + } + + #endregion + + #region GetProposalDiffAsync Tests + + [Fact] + public async Task GetProposalDiffAsync_ShouldReturnDiff_WhenAvailable() + { + // Arrange + var proposalId = Guid.NewGuid(); + var proposal = new AutomationProposal( + ProposalSourceType.Chat, + Guid.NewGuid(), + "Test proposal", + RiskLevel.Low, + Guid.NewGuid().ToString()); + + proposal.SetDiffPreview("+ New card created\n- Old card removed"); + + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposalId, default)) + .ReturnsAsync(proposal); + + // Act + var result = await _service.GetProposalDiffAsync(proposalId); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Contain("New card created"); + } + + [Fact] + public async Task GetProposalDiffAsync_ShouldReturnNotFound_WhenDiffNotAvailable() + { + // Arrange + var proposalId = Guid.NewGuid(); + var proposal = new AutomationProposal( + ProposalSourceType.Chat, + Guid.NewGuid(), + "Test proposal", + RiskLevel.Low, + Guid.NewGuid().ToString()); + + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposalId, default)) + .ReturnsAsync(proposal); + + // Act + var result = await _service.GetProposalDiffAsync(proposalId); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.NotFound); + } + + #endregion + + #region GetProposalsAsync Tests + + [Fact] + public async Task GetProposalsAsync_ShouldFilterByStatus_WhenStatusProvided() + { + // Arrange + var proposals = new[] + { + new AutomationProposal(ProposalSourceType.Chat, Guid.NewGuid(), "Test 1", RiskLevel.Low, Guid.NewGuid().ToString()), + new AutomationProposal(ProposalSourceType.Chat, Guid.NewGuid(), "Test 2", RiskLevel.Low, Guid.NewGuid().ToString()) + }; + + _proposalRepoMock.Setup(r => r.GetByStatusAsync(ProposalStatus.PendingReview, 100, default)) + .ReturnsAsync(proposals); + + // Act + var result = await _service.GetProposalsAsync(new ProposalFilterDto(Status: ProposalStatus.PendingReview)); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().HaveCount(2); + } + + [Fact] + public async Task GetProposalsAsync_ShouldFilterByBoardId_WhenProvided() + { + // Arrange + var boardId = Guid.NewGuid(); + var proposals = new[] + { + new AutomationProposal(ProposalSourceType.Chat, Guid.NewGuid(), "Test", RiskLevel.Low, Guid.NewGuid().ToString(), boardId) + }; + + _proposalRepoMock.Setup(r => r.GetByBoardIdAsync(boardId, default)) + .ReturnsAsync(proposals); + + // Act + var result = await _service.GetProposalsAsync(new ProposalFilterDto(BoardId: boardId)); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().HaveCount(1); + } + + #endregion +} From 8887c02351dda4bd478c33be40f042971988f629 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 20:17:53 +0000 Subject: [PATCH 10/30] Implement ArchiveRecoveryService with comprehensive tests - Created IArchiveRecoveryService interface with archive CRUD operations - Implemented ArchiveRecoveryService with restore logic for boards, columns, and cards - Added DTOs: CreateArchiveItemDto, ArchiveItemDto, RestoreArchiveItemDto, RestoreResult - Implemented conflict strategies: Fail, Rename, AppendSuffix - Added permission checks via IAuthorizationService - Created audit log entries for archive operations - Implemented snapshot serialization/deserialization for entity restoration - Added 26 comprehensive unit tests covering all scenarios - All tests pass (364 total tests across all projects) Co-authored-by: Chris0Jeky <59696583+Chris0Jeky@users.noreply.github.com> --- .../DTOs/ArchiveRecoveryDtos.cs | 55 ++ .../Services/ArchiveRecoveryService.cs | 501 ++++++++++ .../Services/IArchiveRecoveryService.cs | 29 + .../Services/ArchiveRecoveryServiceTests.cs | 904 ++++++++++++++++++ 4 files changed, 1489 insertions(+) create mode 100644 backend/src/Taskdeck.Application/DTOs/ArchiveRecoveryDtos.cs create mode 100644 backend/src/Taskdeck.Application/Services/ArchiveRecoveryService.cs create mode 100644 backend/src/Taskdeck.Application/Services/IArchiveRecoveryService.cs create mode 100644 backend/tests/Taskdeck.Application.Tests/Services/ArchiveRecoveryServiceTests.cs diff --git a/backend/src/Taskdeck.Application/DTOs/ArchiveRecoveryDtos.cs b/backend/src/Taskdeck.Application/DTOs/ArchiveRecoveryDtos.cs new file mode 100644 index 000000000..7982dd28c --- /dev/null +++ b/backend/src/Taskdeck.Application/DTOs/ArchiveRecoveryDtos.cs @@ -0,0 +1,55 @@ +using Taskdeck.Domain.Entities; + +namespace Taskdeck.Application.DTOs; + +public record CreateArchiveItemDto( + string EntityType, + Guid EntityId, + Guid BoardId, + string Name, + Guid ArchivedByUserId, + string SnapshotJson, + string? Reason +); + +public record ArchiveItemDto( + Guid Id, + string EntityType, + Guid EntityId, + Guid BoardId, + string Name, + Guid ArchivedByUserId, + DateTime ArchivedAt, + string? Reason, + RestoreStatus RestoreStatus, + DateTime? RestoredAt, + Guid? RestoredByUserId, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt +); + +public record RestoreArchiveItemDto( + Guid? TargetBoardId, + RestoreMode RestoreMode, + ConflictStrategy ConflictStrategy +); + +public record RestoreResult( + bool Success, + Guid? RestoredEntityId, + string? ErrorMessage, + string? ResolvedName +); + +public enum RestoreMode +{ + InPlace, + Copy +} + +public enum ConflictStrategy +{ + Fail, + Rename, + AppendSuffix +} diff --git a/backend/src/Taskdeck.Application/Services/ArchiveRecoveryService.cs b/backend/src/Taskdeck.Application/Services/ArchiveRecoveryService.cs new file mode 100644 index 000000000..932b15fdf --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/ArchiveRecoveryService.cs @@ -0,0 +1,501 @@ +using System.Text.Json; +using Taskdeck.Application.DTOs; +using Taskdeck.Application.Interfaces; +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Enums; +using Taskdeck.Domain.Exceptions; + +namespace Taskdeck.Application.Services; + +public class ArchiveRecoveryService : IArchiveRecoveryService +{ + private readonly IUnitOfWork _unitOfWork; + private readonly IAuthorizationService? _authorizationService; + + public ArchiveRecoveryService( + IUnitOfWork unitOfWork, + IAuthorizationService? authorizationService = null) + { + _unitOfWork = unitOfWork; + _authorizationService = authorizationService; + } + + public async Task> CreateArchiveItemAsync( + CreateArchiveItemDto dto, + CancellationToken cancellationToken = default) + { + try + { + var archiveItem = new ArchiveItem( + dto.EntityType, + dto.EntityId, + dto.BoardId, + dto.Name, + dto.ArchivedByUserId, + dto.SnapshotJson, + dto.Reason); + + await _unitOfWork.ArchiveItems.AddAsync(archiveItem, cancellationToken); + + // Create audit log + var auditLog = new AuditLog( + "ArchiveItem", + archiveItem.Id, + AuditAction.Created, + dto.ArchivedByUserId, + $"Archived {dto.EntityType} '{dto.Name}' (ID: {dto.EntityId})"); + await _unitOfWork.AuditLogs.AddAsync(auditLog, cancellationToken); + + await _unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Success(MapToDto(archiveItem)); + } + catch (DomainException ex) + { + return Result.Failure(ex.ErrorCode, ex.Message); + } + } + + public async Task>> GetArchiveItemsAsync( + string? entityType = null, + Guid? boardId = null, + RestoreStatus? status = null, + int limit = 100, + CancellationToken cancellationToken = default) + { + try + { + IEnumerable items; + + if (entityType != null && boardId != null && status != null) + { + // Combined filter - need to implement custom query + var allItems = await _unitOfWork.ArchiveItems.GetAllAsync(cancellationToken); + items = allItems + .Where(i => i.EntityType == entityType + && i.BoardId == boardId + && i.RestoreStatus == status.Value) + .Take(limit); + } + else if (entityType != null) + { + items = await _unitOfWork.ArchiveItems.GetByEntityTypeAsync(entityType, limit, cancellationToken); + } + else if (boardId != null) + { + items = await _unitOfWork.ArchiveItems.GetByBoardIdAsync(boardId.Value, limit, cancellationToken); + } + else if (status != null) + { + items = await _unitOfWork.ArchiveItems.GetByStatusAsync(status.Value, limit, cancellationToken); + } + else + { + var allItems = await _unitOfWork.ArchiveItems.GetAllAsync(cancellationToken); + items = allItems.Take(limit); + } + + // Apply additional filters if needed + if (entityType != null && boardId == null && status != null) + { + items = items.Where(i => i.RestoreStatus == status.Value); + } + else if (entityType == null && boardId != null && status != null) + { + items = items.Where(i => i.RestoreStatus == status.Value); + } + else if (entityType != null && boardId != null && status == null) + { + items = items.Where(i => i.BoardId == boardId.Value); + } + + return Result.Success(items.Select(MapToDto)); + } + catch (Exception ex) + { + return Result.Failure>( + ErrorCodes.UnexpectedError, + $"Failed to retrieve archive items: {ex.Message}"); + } + } + + public async Task> GetArchiveItemByIdAsync( + Guid id, + CancellationToken cancellationToken = default) + { + var archiveItem = await _unitOfWork.ArchiveItems.GetByIdAsync(id, cancellationToken); + if (archiveItem == null) + return Result.Failure(ErrorCodes.NotFound, $"Archive item with ID {id} not found"); + + return Result.Success(MapToDto(archiveItem)); + } + + public async Task> RestoreArchiveItemAsync( + Guid id, + RestoreArchiveItemDto dto, + Guid restoredByUserId, + CancellationToken cancellationToken = default) + { + try + { + // 1. Get archive item + var archiveItem = await _unitOfWork.ArchiveItems.GetByIdAsync(id, cancellationToken); + if (archiveItem == null) + return Result.Failure(ErrorCodes.NotFound, $"Archive item with ID {id} not found"); + + if (archiveItem.RestoreStatus != RestoreStatus.Available) + return Result.Failure( + ErrorCodes.InvalidOperation, + $"Cannot restore archive item with status {archiveItem.RestoreStatus}"); + + // 2. Determine target board + var targetBoardId = dto.TargetBoardId ?? archiveItem.BoardId; + + // 3. Check permissions + if (_authorizationService != null) + { + var canWriteResult = await _authorizationService.CanWriteBoardAsync(restoredByUserId, targetBoardId); + if (canWriteResult.IsSuccess && !canWriteResult.Value) + { + return Result.Failure( + ErrorCodes.Forbidden, + "User does not have permission to restore to target board"); + } + } + + // 4. Validate target board exists + var targetBoard = await _unitOfWork.Boards.GetByIdAsync(targetBoardId, cancellationToken); + if (targetBoard == null) + return Result.Failure(ErrorCodes.NotFound, $"Target board with ID {targetBoardId} not found"); + + if (targetBoard.IsArchived) + return Result.Failure( + ErrorCodes.InvalidOperation, + "Cannot restore to an archived board"); + + // 5. Validate and restore based on entity type + Result restoreResult; + switch (archiveItem.EntityType) + { + case "board": + restoreResult = await RestoreBoardAsync(archiveItem, dto, restoredByUserId, cancellationToken); + break; + case "column": + restoreResult = await RestoreColumnAsync(archiveItem, targetBoardId, dto, restoredByUserId, cancellationToken); + break; + case "card": + restoreResult = await RestoreCardAsync(archiveItem, targetBoardId, dto, restoredByUserId, cancellationToken); + break; + default: + return Result.Failure( + ErrorCodes.ValidationError, + $"Unknown entity type: {archiveItem.EntityType}"); + } + + if (!restoreResult.IsSuccess) + return restoreResult; + + // 6. Mark archive item as restored + archiveItem.MarkAsRestored(restoredByUserId); + + // 7. Create audit log + var auditLog = new AuditLog( + "ArchiveItem", + archiveItem.Id, + AuditAction.Updated, + restoredByUserId, + $"Restored {archiveItem.EntityType} '{restoreResult.Value.ResolvedName ?? archiveItem.Name}' " + + $"(Original ID: {archiveItem.EntityId}, Restored ID: {restoreResult.Value.RestoredEntityId})"); + await _unitOfWork.AuditLogs.AddAsync(auditLog, cancellationToken); + + await _unitOfWork.SaveChangesAsync(cancellationToken); + + return restoreResult; + } + catch (DomainException ex) + { + return Result.Failure(ex.ErrorCode, ex.Message); + } + catch (Exception ex) + { + return Result.Failure( + ErrorCodes.UnexpectedError, + $"Failed to restore archive item: {ex.Message}"); + } + } + + private async Task> RestoreBoardAsync( + ArchiveItem archiveItem, + RestoreArchiveItemDto dto, + Guid restoredByUserId, + CancellationToken cancellationToken) + { + try + { + // Deserialize snapshot + var snapshot = JsonSerializer.Deserialize(archiveItem.SnapshotJson); + if (snapshot == null) + return Result.Failure( + ErrorCodes.ValidationError, + "Failed to deserialize board snapshot"); + + // Check for naming conflicts + var existingBoards = await _unitOfWork.Boards.SearchAsync(snapshot.Name, includeArchived: false, cancellationToken); + var conflictExists = existingBoards.Any(b => b.Name == snapshot.Name); + + string resolvedName = snapshot.Name; + if (conflictExists) + { + if (dto.ConflictStrategy == ConflictStrategy.Fail) + { + return Result.Failure( + ErrorCodes.Conflict, + $"A board with name '{snapshot.Name}' already exists"); + } + else if (dto.ConflictStrategy == ConflictStrategy.Rename) + { + resolvedName = $"{snapshot.Name} (Restored)"; + } + else if (dto.ConflictStrategy == ConflictStrategy.AppendSuffix) + { + resolvedName = $"{snapshot.Name} - {DateTime.UtcNow:yyyyMMdd-HHmmss}"; + } + } + + // For InPlace mode, unarchive existing board if it's archived + if (dto.RestoreMode == RestoreMode.InPlace) + { + var existingBoard = await _unitOfWork.Boards.GetByIdAsync(archiveItem.EntityId, cancellationToken); + if (existingBoard != null && existingBoard.IsArchived) + { + existingBoard.Unarchive(); + existingBoard.Update(resolvedName, snapshot.Description); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Success(new RestoreResult( + true, + existingBoard.Id, + null, + resolvedName)); + } + } + + // Create new board (Copy mode or InPlace when original doesn't exist) + var newBoard = new Board(resolvedName, snapshot.Description, restoredByUserId); + await _unitOfWork.Boards.AddAsync(newBoard, cancellationToken); + + return Result.Success(new RestoreResult( + true, + newBoard.Id, + null, + resolvedName)); + } + catch (JsonException ex) + { + return Result.Failure( + ErrorCodes.ValidationError, + $"Invalid snapshot format: {ex.Message}"); + } + } + + private async Task> RestoreColumnAsync( + ArchiveItem archiveItem, + Guid targetBoardId, + RestoreArchiveItemDto dto, + Guid restoredByUserId, + CancellationToken cancellationToken) + { + try + { + // Deserialize snapshot + var snapshot = JsonSerializer.Deserialize(archiveItem.SnapshotJson); + if (snapshot == null) + return Result.Failure( + ErrorCodes.ValidationError, + "Failed to deserialize column snapshot"); + + // Get board and existing columns + var board = await _unitOfWork.Boards.GetByIdWithDetailsAsync(targetBoardId, cancellationToken); + if (board == null) + return Result.Failure(ErrorCodes.NotFound, $"Board with ID {targetBoardId} not found"); + + // Check for naming conflicts + var conflictExists = board.Columns.Any(c => c.Name == snapshot.Name); + + string resolvedName = snapshot.Name; + if (conflictExists) + { + if (dto.ConflictStrategy == ConflictStrategy.Fail) + { + return Result.Failure( + ErrorCodes.Conflict, + $"A column with name '{snapshot.Name}' already exists"); + } + else if (dto.ConflictStrategy == ConflictStrategy.Rename) + { + resolvedName = $"{snapshot.Name} (Restored)"; + } + else if (dto.ConflictStrategy == ConflictStrategy.AppendSuffix) + { + resolvedName = $"{snapshot.Name} - {DateTime.UtcNow:yyyyMMdd-HHmmss}"; + } + } + + // Determine position (add to end) + var maxPosition = board.Columns.Any() ? board.Columns.Max(c => c.Position) : -1; + var newPosition = maxPosition + 1; + + // Create new column + var newColumn = new Column(targetBoardId, resolvedName, newPosition, snapshot.WipLimit); + await _unitOfWork.Columns.AddAsync(newColumn, cancellationToken); + + return Result.Success(new RestoreResult( + true, + newColumn.Id, + null, + resolvedName)); + } + catch (JsonException ex) + { + return Result.Failure( + ErrorCodes.ValidationError, + $"Invalid snapshot format: {ex.Message}"); + } + } + + private async Task> RestoreCardAsync( + ArchiveItem archiveItem, + Guid targetBoardId, + RestoreArchiveItemDto dto, + Guid restoredByUserId, + CancellationToken cancellationToken) + { + try + { + // Deserialize snapshot + var snapshot = JsonSerializer.Deserialize(archiveItem.SnapshotJson); + if (snapshot == null) + return Result.Failure( + ErrorCodes.ValidationError, + "Failed to deserialize card snapshot"); + + // Get board with details + var board = await _unitOfWork.Boards.GetByIdWithDetailsAsync(targetBoardId, cancellationToken); + if (board == null) + return Result.Failure(ErrorCodes.NotFound, $"Board with ID {targetBoardId} not found"); + + // Find target column + Column? targetColumn = null; + if (snapshot.ColumnId != Guid.Empty) + { + targetColumn = board.Columns.FirstOrDefault(c => c.Id == snapshot.ColumnId); + } + + // If original column doesn't exist, use first available column + if (targetColumn == null) + { + targetColumn = board.Columns.OrderBy(c => c.Position).FirstOrDefault(); + if (targetColumn == null) + return Result.Failure( + ErrorCodes.InvalidOperation, + "Target board has no columns to restore card to"); + } + + // Get column with cards to check WIP limit and position + var columnWithCards = await _unitOfWork.Columns.GetByIdWithCardsAsync(targetColumn.Id, cancellationToken); + if (columnWithCards == null) + return Result.Failure(ErrorCodes.NotFound, $"Column with ID {targetColumn.Id} not found"); + + // Check WIP limit + if (columnWithCards.WouldExceedWipLimitIfAdded()) + return Result.Failure( + ErrorCodes.WipLimitExceeded, + $"Cannot restore card, column '{columnWithCards.Name}' has reached its WIP limit"); + + // Check for title conflicts + var existingCards = columnWithCards.Cards.ToList(); + var conflictExists = existingCards.Any(c => c.Title == snapshot.Title); + + string resolvedTitle = snapshot.Title; + if (conflictExists) + { + if (dto.ConflictStrategy == ConflictStrategy.Fail) + { + return Result.Failure( + ErrorCodes.Conflict, + $"A card with title '{snapshot.Title}' already exists in the target column"); + } + else if (dto.ConflictStrategy == ConflictStrategy.Rename) + { + resolvedTitle = $"{snapshot.Title} (Restored)"; + } + else if (dto.ConflictStrategy == ConflictStrategy.AppendSuffix) + { + resolvedTitle = $"{snapshot.Title} - {DateTime.UtcNow:yyyyMMdd-HHmmss}"; + } + } + + // Determine position (add to bottom) + var maxPosition = existingCards.Any() ? existingCards.Max(c => c.Position) : -1; + var newPosition = maxPosition + 1; + + // Create new card + var newCard = new Card( + targetBoardId, + columnWithCards.Id, + resolvedTitle, + snapshot.Description, + snapshot.DueDate, + newPosition); + + if (snapshot.IsBlocked && !string.IsNullOrEmpty(snapshot.BlockReason)) + { + newCard.Block(snapshot.BlockReason); + } + + await _unitOfWork.Cards.AddAsync(newCard, cancellationToken); + + return Result.Success(new RestoreResult( + true, + newCard.Id, + null, + resolvedTitle)); + } + catch (JsonException ex) + { + return Result.Failure( + ErrorCodes.ValidationError, + $"Invalid snapshot format: {ex.Message}"); + } + } + + private static ArchiveItemDto MapToDto(ArchiveItem item) + { + return new ArchiveItemDto( + item.Id, + item.EntityType, + item.EntityId, + item.BoardId, + item.Name, + item.ArchivedByUserId, + item.ArchivedAt, + item.Reason, + item.RestoreStatus, + item.RestoredAt, + item.RestoredByUserId, + item.CreatedAt, + item.UpdatedAt); + } +} + +// Snapshot DTOs for deserialization +internal record BoardSnapshot(string Name, string? Description); +internal record ColumnSnapshot(string Name, int Position, int? WipLimit); +internal record CardSnapshot( + string Title, + string? Description, + DateTimeOffset? DueDate, + bool IsBlocked, + string? BlockReason, + Guid ColumnId); diff --git a/backend/src/Taskdeck.Application/Services/IArchiveRecoveryService.cs b/backend/src/Taskdeck.Application/Services/IArchiveRecoveryService.cs new file mode 100644 index 000000000..5e895f2be --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/IArchiveRecoveryService.cs @@ -0,0 +1,29 @@ +using Taskdeck.Application.DTOs; +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Entities; + +namespace Taskdeck.Application.Services; + +public interface IArchiveRecoveryService +{ + Task> CreateArchiveItemAsync( + CreateArchiveItemDto dto, + CancellationToken cancellationToken = default); + + Task>> GetArchiveItemsAsync( + string? entityType = null, + Guid? boardId = null, + RestoreStatus? status = null, + int limit = 100, + CancellationToken cancellationToken = default); + + Task> GetArchiveItemByIdAsync( + Guid id, + CancellationToken cancellationToken = default); + + Task> RestoreArchiveItemAsync( + Guid id, + RestoreArchiveItemDto dto, + Guid restoredByUserId, + CancellationToken cancellationToken = default); +} diff --git a/backend/tests/Taskdeck.Application.Tests/Services/ArchiveRecoveryServiceTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/ArchiveRecoveryServiceTests.cs new file mode 100644 index 000000000..fe4aa78bc --- /dev/null +++ b/backend/tests/Taskdeck.Application.Tests/Services/ArchiveRecoveryServiceTests.cs @@ -0,0 +1,904 @@ +using System.Text.Json; +using FluentAssertions; +using Moq; +using Taskdeck.Application.DTOs; +using Taskdeck.Application.Interfaces; +using Taskdeck.Application.Services; +using Taskdeck.Application.Tests.TestUtilities; +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Enums; +using Taskdeck.Domain.Exceptions; +using Xunit; + +namespace Taskdeck.Application.Tests.Services; + +public class ArchiveRecoveryServiceTests +{ + private readonly Mock _unitOfWorkMock; + private readonly Mock _archiveItemRepoMock; + private readonly Mock _auditLogRepoMock; + private readonly Mock _boardRepoMock; + private readonly Mock _columnRepoMock; + private readonly Mock _cardRepoMock; + private readonly Mock _authorizationServiceMock; + private readonly ArchiveRecoveryService _service; + + public ArchiveRecoveryServiceTests() + { + _unitOfWorkMock = new Mock(); + _archiveItemRepoMock = new Mock(); + _auditLogRepoMock = new Mock(); + _boardRepoMock = new Mock(); + _columnRepoMock = new Mock(); + _cardRepoMock = new Mock(); + _authorizationServiceMock = new Mock(); + + _unitOfWorkMock.Setup(u => u.ArchiveItems).Returns(_archiveItemRepoMock.Object); + _unitOfWorkMock.Setup(u => u.AuditLogs).Returns(_auditLogRepoMock.Object); + _unitOfWorkMock.Setup(u => u.Boards).Returns(_boardRepoMock.Object); + _unitOfWorkMock.Setup(u => u.Columns).Returns(_columnRepoMock.Object); + _unitOfWorkMock.Setup(u => u.Cards).Returns(_cardRepoMock.Object); + + _service = new ArchiveRecoveryService(_unitOfWorkMock.Object, _authorizationServiceMock.Object); + } + + #region CreateArchiveItemAsync Tests + + [Fact] + public async Task CreateArchiveItemAsync_ShouldReturnSuccess_WithValidData() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var entityId = Guid.NewGuid(); + var snapshotJson = JsonSerializer.Serialize(new { Name = "Test Board", Description = "Test" }); + var dto = new CreateArchiveItemDto( + "board", + entityId, + boardId, + "Test Board", + userId, + snapshotJson, + "Archived by user"); + + _archiveItemRepoMock.Setup(r => r.AddAsync(It.IsAny(), default)) + .ReturnsAsync((ArchiveItem a, CancellationToken ct) => a); + _auditLogRepoMock.Setup(r => r.AddAsync(It.IsAny(), default)) + .ReturnsAsync((AuditLog a, CancellationToken ct) => a); + + // Act + var result = await _service.CreateArchiveItemAsync(dto); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.EntityType.Should().Be("board"); + result.Value.EntityId.Should().Be(entityId); + result.Value.BoardId.Should().Be(boardId); + result.Value.Name.Should().Be("Test Board"); + result.Value.ArchivedByUserId.Should().Be(userId); + result.Value.RestoreStatus.Should().Be(RestoreStatus.Available); + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(default), Times.Once); + } + + [Fact] + public async Task CreateArchiveItemAsync_ShouldReturnFailure_WithInvalidEntityType() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var entityId = Guid.NewGuid(); + var dto = new CreateArchiveItemDto( + "invalid", + entityId, + boardId, + "Test", + userId, + "{}", + null); + + // Act + var result = await _service.CreateArchiveItemAsync(dto); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + result.ErrorMessage.Should().Contain("EntityType"); + } + + [Fact] + public async Task CreateArchiveItemAsync_ShouldCreateAuditLog() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var entityId = Guid.NewGuid(); + var snapshotJson = JsonSerializer.Serialize(new { Name = "Test Card" }); + var dto = new CreateArchiveItemDto( + "card", + entityId, + boardId, + "Test Card", + userId, + snapshotJson, + null); + + _archiveItemRepoMock.Setup(r => r.AddAsync(It.IsAny(), default)) + .ReturnsAsync((ArchiveItem a, CancellationToken ct) => a); + _auditLogRepoMock.Setup(r => r.AddAsync(It.IsAny(), default)) + .ReturnsAsync((AuditLog a, CancellationToken ct) => a); + + // Act + var result = await _service.CreateArchiveItemAsync(dto); + + // Assert + result.IsSuccess.Should().BeTrue(); + _auditLogRepoMock.Verify(r => r.AddAsync( + It.Is(a => + a.EntityType == "ArchiveItem" + && a.Action == AuditAction.Created + && a.UserId == userId), + default), Times.Once); + } + + #endregion + + #region GetArchiveItemsAsync Tests + + [Fact] + public async Task GetArchiveItemsAsync_ShouldReturnAll_WhenNoFiltersProvided() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var items = new List + { + CreateArchiveItem("board", Guid.NewGuid(), boardId, "Board 1", userId), + CreateArchiveItem("card", Guid.NewGuid(), boardId, "Card 1", userId), + CreateArchiveItem("column", Guid.NewGuid(), boardId, "Column 1", userId) + }; + + _archiveItemRepoMock.Setup(r => r.GetAllAsync(default)) + .ReturnsAsync(items); + + // Act + var result = await _service.GetArchiveItemsAsync(); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().HaveCount(3); + } + + [Fact] + public async Task GetArchiveItemsAsync_ShouldFilterByEntityType() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var cardItems = new List + { + CreateArchiveItem("card", Guid.NewGuid(), boardId, "Card 1", userId), + CreateArchiveItem("card", Guid.NewGuid(), boardId, "Card 2", userId) + }; + + _archiveItemRepoMock.Setup(r => r.GetByEntityTypeAsync("card", 100, default)) + .ReturnsAsync(cardItems); + + // Act + var result = await _service.GetArchiveItemsAsync(entityType: "card"); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().HaveCount(2); + result.Value.Should().OnlyContain(i => i.EntityType == "card"); + } + + [Fact] + public async Task GetArchiveItemsAsync_ShouldFilterByBoardId() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var items = new List + { + CreateArchiveItem("card", Guid.NewGuid(), boardId, "Card 1", userId), + CreateArchiveItem("column", Guid.NewGuid(), boardId, "Column 1", userId) + }; + + _archiveItemRepoMock.Setup(r => r.GetByBoardIdAsync(boardId, 100, default)) + .ReturnsAsync(items); + + // Act + var result = await _service.GetArchiveItemsAsync(boardId: boardId); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().HaveCount(2); + result.Value.Should().OnlyContain(i => i.BoardId == boardId); + } + + [Fact] + public async Task GetArchiveItemsAsync_ShouldFilterByStatus() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var items = new List + { + CreateArchiveItem("card", Guid.NewGuid(), boardId, "Card 1", userId, RestoreStatus.Available) + }; + + _archiveItemRepoMock.Setup(r => r.GetByStatusAsync(RestoreStatus.Available, 100, default)) + .ReturnsAsync(items); + + // Act + var result = await _service.GetArchiveItemsAsync(status: RestoreStatus.Available); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().HaveCount(1); + result.Value.Should().OnlyContain(i => i.RestoreStatus == RestoreStatus.Available); + } + + [Fact] + public async Task GetArchiveItemsAsync_ShouldRespectLimit() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var items = Enumerable.Range(0, 150) + .Select(i => CreateArchiveItem("card", Guid.NewGuid(), boardId, $"Card {i}", userId)) + .ToList(); + + _archiveItemRepoMock.Setup(r => r.GetAllAsync(default)) + .ReturnsAsync(items); + + // Act + var result = await _service.GetArchiveItemsAsync(limit: 50); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().HaveCount(50); + } + + #endregion + + #region GetArchiveItemByIdAsync Tests + + [Fact] + public async Task GetArchiveItemByIdAsync_ShouldReturnItem_WhenExists() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var item = CreateArchiveItem("board", Guid.NewGuid(), boardId, "Test Board", userId); + + _archiveItemRepoMock.Setup(r => r.GetByIdAsync(item.Id, default)) + .ReturnsAsync(item); + + // Act + var result = await _service.GetArchiveItemByIdAsync(item.Id); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Id.Should().Be(item.Id); + result.Value.Name.Should().Be("Test Board"); + } + + [Fact] + public async Task GetArchiveItemByIdAsync_ShouldReturnNotFound_WhenDoesNotExist() + { + // Arrange + var id = Guid.NewGuid(); + _archiveItemRepoMock.Setup(r => r.GetByIdAsync(id, default)) + .ReturnsAsync((ArchiveItem?)null); + + // Act + var result = await _service.GetArchiveItemByIdAsync(id); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.NotFound); + } + + #endregion + + #region RestoreArchiveItemAsync - General Tests + + [Fact] + public async Task RestoreArchiveItemAsync_ShouldReturnNotFound_WhenArchiveItemDoesNotExist() + { + // Arrange + var id = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var dto = new RestoreArchiveItemDto(null, RestoreMode.InPlace, ConflictStrategy.Fail); + + _archiveItemRepoMock.Setup(r => r.GetByIdAsync(id, default)) + .ReturnsAsync((ArchiveItem?)null); + + // Act + var result = await _service.RestoreArchiveItemAsync(id, dto, userId); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.NotFound); + } + + [Fact] + public async Task RestoreArchiveItemAsync_ShouldReturnFailure_WhenAlreadyRestored() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var item = CreateArchiveItem("board", Guid.NewGuid(), boardId, "Test", userId); + item.MarkAsRestored(userId); + + var dto = new RestoreArchiveItemDto(null, RestoreMode.InPlace, ConflictStrategy.Fail); + + _archiveItemRepoMock.Setup(r => r.GetByIdAsync(item.Id, default)) + .ReturnsAsync(item); + + // Act + var result = await _service.RestoreArchiveItemAsync(item.Id, dto, userId); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.InvalidOperation); + result.ErrorMessage.Should().Contain("Restored"); + } + + [Fact] + public async Task RestoreArchiveItemAsync_ShouldReturnForbidden_WhenUserLacksPermission() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var snapshotJson = JsonSerializer.Serialize(new { Name = "Test", Description = (string?)null }); + var item = CreateArchiveItem("board", Guid.NewGuid(), boardId, "Test", userId, RestoreStatus.Available, snapshotJson); + var dto = new RestoreArchiveItemDto(null, RestoreMode.InPlace, ConflictStrategy.Fail); + + _archiveItemRepoMock.Setup(r => r.GetByIdAsync(item.Id, default)) + .ReturnsAsync(item); + _authorizationServiceMock.Setup(s => s.CanWriteBoardAsync(userId, boardId)) + .ReturnsAsync(Result.Success(false)); + _boardRepoMock.Setup(r => r.GetByIdAsync(boardId, default)) + .ReturnsAsync(TestDataBuilder.CreateBoard()); + + // Act + var result = await _service.RestoreArchiveItemAsync(item.Id, dto, userId); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.Forbidden); + } + + [Fact] + public async Task RestoreArchiveItemAsync_ShouldReturnNotFound_WhenTargetBoardDoesNotExist() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var snapshotJson = JsonSerializer.Serialize(new { Name = "Test" }); + var item = CreateArchiveItem("column", Guid.NewGuid(), boardId, "Test", userId, RestoreStatus.Available, snapshotJson); + var dto = new RestoreArchiveItemDto(boardId, RestoreMode.Copy, ConflictStrategy.Fail); + + _archiveItemRepoMock.Setup(r => r.GetByIdAsync(item.Id, default)) + .ReturnsAsync(item); + _authorizationServiceMock.Setup(s => s.CanWriteBoardAsync(userId, boardId)) + .ReturnsAsync(Result.Success(true)); + _boardRepoMock.Setup(r => r.GetByIdAsync(boardId, default)) + .ReturnsAsync((Board?)null); + + // Act + var result = await _service.RestoreArchiveItemAsync(item.Id, dto, userId); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.NotFound); + result.ErrorMessage.Should().Contain("board"); + } + + [Fact] + public async Task RestoreArchiveItemAsync_ShouldReturnFailure_WhenTargetBoardIsArchived() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var archivedBoard = TestDataBuilder.CreateBoard(isArchived: true); + var snapshotJson = JsonSerializer.Serialize(new { Name = "Test" }); + var item = CreateArchiveItem("card", Guid.NewGuid(), boardId, "Test", userId, RestoreStatus.Available, snapshotJson); + var dto = new RestoreArchiveItemDto(archivedBoard.Id, RestoreMode.Copy, ConflictStrategy.Fail); + + _archiveItemRepoMock.Setup(r => r.GetByIdAsync(item.Id, default)) + .ReturnsAsync(item); + _authorizationServiceMock.Setup(s => s.CanWriteBoardAsync(userId, archivedBoard.Id)) + .ReturnsAsync(Result.Success(true)); + _boardRepoMock.Setup(r => r.GetByIdAsync(archivedBoard.Id, default)) + .ReturnsAsync(archivedBoard); + + // Act + var result = await _service.RestoreArchiveItemAsync(item.Id, dto, userId); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.InvalidOperation); + result.ErrorMessage.Should().Contain("archived board"); + } + + #endregion + + #region RestoreArchiveItemAsync - Board Tests + + [Fact] + public async Task RestoreArchiveItemAsync_ShouldRestoreBoard_WithoutConflict() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var snapshotJson = JsonSerializer.Serialize(new { Name = "Unique Board", Description = "Test board" }); + var item = CreateArchiveItem("board", boardId, boardId, "Unique Board", userId, RestoreStatus.Available, snapshotJson); + var dto = new RestoreArchiveItemDto(null, RestoreMode.Copy, ConflictStrategy.Fail); + + _archiveItemRepoMock.Setup(r => r.GetByIdAsync(item.Id, default)) + .ReturnsAsync(item); + _authorizationServiceMock.Setup(s => s.CanWriteBoardAsync(userId, boardId)) + .ReturnsAsync(Result.Success(true)); + _boardRepoMock.Setup(r => r.GetByIdAsync(boardId, default)) + .ReturnsAsync(TestDataBuilder.CreateBoard()); + _boardRepoMock.Setup(r => r.SearchAsync("Unique Board", false, default)) + .ReturnsAsync(new List()); + _boardRepoMock.Setup(r => r.AddAsync(It.IsAny(), default)) + .ReturnsAsync((Board b, CancellationToken ct) => b); + _auditLogRepoMock.Setup(r => r.AddAsync(It.IsAny(), default)) + .ReturnsAsync((AuditLog a, CancellationToken ct) => a); + + // Act + var result = await _service.RestoreArchiveItemAsync(item.Id, dto, userId); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Success.Should().BeTrue(); + result.Value.RestoredEntityId.Should().NotBeEmpty(); + result.Value.ResolvedName.Should().Be("Unique Board"); + _boardRepoMock.Verify(r => r.AddAsync(It.Is(b => b.Name == "Unique Board"), default), Times.Once); + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(default), Times.Once); + } + + [Fact] + public async Task RestoreArchiveItemAsync_ShouldFailOnConflict_WithFailStrategy() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var existingBoard = TestDataBuilder.CreateBoard("Existing Board"); + var snapshotJson = JsonSerializer.Serialize(new { Name = "Existing Board", Description = (string?)null }); + var item = CreateArchiveItem("board", boardId, boardId, "Existing Board", userId, RestoreStatus.Available, snapshotJson); + var dto = new RestoreArchiveItemDto(null, RestoreMode.Copy, ConflictStrategy.Fail); + + _archiveItemRepoMock.Setup(r => r.GetByIdAsync(item.Id, default)) + .ReturnsAsync(item); + _authorizationServiceMock.Setup(s => s.CanWriteBoardAsync(userId, boardId)) + .ReturnsAsync(Result.Success(true)); + _boardRepoMock.Setup(r => r.GetByIdAsync(boardId, default)) + .ReturnsAsync(TestDataBuilder.CreateBoard()); + _boardRepoMock.Setup(r => r.SearchAsync("Existing Board", false, default)) + .ReturnsAsync(new List { existingBoard }); + + // Act + var result = await _service.RestoreArchiveItemAsync(item.Id, dto, userId); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.Conflict); + result.ErrorMessage.Should().Contain("already exists"); + } + + [Fact] + public async Task RestoreArchiveItemAsync_ShouldRenameOnConflict_WithRenameStrategy() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var existingBoard = TestDataBuilder.CreateBoard("Existing Board"); + var snapshotJson = JsonSerializer.Serialize(new { Name = "Existing Board", Description = (string?)null }); + var item = CreateArchiveItem("board", boardId, boardId, "Existing Board", userId, RestoreStatus.Available, snapshotJson); + var dto = new RestoreArchiveItemDto(null, RestoreMode.Copy, ConflictStrategy.Rename); + + _archiveItemRepoMock.Setup(r => r.GetByIdAsync(item.Id, default)) + .ReturnsAsync(item); + _authorizationServiceMock.Setup(s => s.CanWriteBoardAsync(userId, boardId)) + .ReturnsAsync(Result.Success(true)); + _boardRepoMock.Setup(r => r.GetByIdAsync(boardId, default)) + .ReturnsAsync(TestDataBuilder.CreateBoard()); + _boardRepoMock.Setup(r => r.SearchAsync("Existing Board", false, default)) + .ReturnsAsync(new List { existingBoard }); + _boardRepoMock.Setup(r => r.AddAsync(It.IsAny(), default)) + .ReturnsAsync((Board b, CancellationToken ct) => b); + _auditLogRepoMock.Setup(r => r.AddAsync(It.IsAny(), default)) + .ReturnsAsync((AuditLog a, CancellationToken ct) => a); + + // Act + var result = await _service.RestoreArchiveItemAsync(item.Id, dto, userId); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.ResolvedName.Should().Be("Existing Board (Restored)"); + _boardRepoMock.Verify(r => r.AddAsync(It.Is(b => b.Name == "Existing Board (Restored)"), default), Times.Once); + } + + [Fact] + public async Task RestoreArchiveItemAsync_ShouldAppendSuffixOnConflict_WithAppendSuffixStrategy() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var existingBoard = TestDataBuilder.CreateBoard("Existing Board"); + var snapshotJson = JsonSerializer.Serialize(new { Name = "Existing Board", Description = (string?)null }); + var item = CreateArchiveItem("board", boardId, boardId, "Existing Board", userId, RestoreStatus.Available, snapshotJson); + var dto = new RestoreArchiveItemDto(null, RestoreMode.Copy, ConflictStrategy.AppendSuffix); + + _archiveItemRepoMock.Setup(r => r.GetByIdAsync(item.Id, default)) + .ReturnsAsync(item); + _authorizationServiceMock.Setup(s => s.CanWriteBoardAsync(userId, boardId)) + .ReturnsAsync(Result.Success(true)); + _boardRepoMock.Setup(r => r.GetByIdAsync(boardId, default)) + .ReturnsAsync(TestDataBuilder.CreateBoard()); + _boardRepoMock.Setup(r => r.SearchAsync("Existing Board", false, default)) + .ReturnsAsync(new List { existingBoard }); + _boardRepoMock.Setup(r => r.AddAsync(It.IsAny(), default)) + .ReturnsAsync((Board b, CancellationToken ct) => b); + _auditLogRepoMock.Setup(r => r.AddAsync(It.IsAny(), default)) + .ReturnsAsync((AuditLog a, CancellationToken ct) => a); + + // Act + var result = await _service.RestoreArchiveItemAsync(item.Id, dto, userId); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.ResolvedName.Should().StartWith("Existing Board - "); + result.Value.ResolvedName.Should().MatchRegex(@"Existing Board - \d{8}-\d{6}"); + } + + #endregion + + #region RestoreArchiveItemAsync - Column Tests + + [Fact] + public async Task RestoreArchiveItemAsync_ShouldRestoreColumn_WithoutConflict() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var board = TestDataBuilder.CreateBoard(); + var column1 = TestDataBuilder.CreateColumn(board.Id, "Existing Column", position: 0); + var boardWithColumns = TestDataBuilder.CreateBoardWithColumns(board.Name, new[] { column1 }); + + var snapshotJson = JsonSerializer.Serialize(new { Name = "New Column", Position = 0, WipLimit = (int?)5 }); + var item = CreateArchiveItem("column", Guid.NewGuid(), boardId, "New Column", userId, RestoreStatus.Available, snapshotJson); + var dto = new RestoreArchiveItemDto(boardWithColumns.Id, RestoreMode.Copy, ConflictStrategy.Fail); + + _archiveItemRepoMock.Setup(r => r.GetByIdAsync(item.Id, default)) + .ReturnsAsync(item); + _authorizationServiceMock.Setup(s => s.CanWriteBoardAsync(userId, boardWithColumns.Id)) + .ReturnsAsync(Result.Success(true)); + _boardRepoMock.Setup(r => r.GetByIdAsync(boardWithColumns.Id, default)) + .ReturnsAsync(boardWithColumns); + _boardRepoMock.Setup(r => r.GetByIdWithDetailsAsync(boardWithColumns.Id, default)) + .ReturnsAsync(boardWithColumns); + _columnRepoMock.Setup(r => r.AddAsync(It.IsAny(), default)) + .ReturnsAsync((Column c, CancellationToken ct) => c); + _auditLogRepoMock.Setup(r => r.AddAsync(It.IsAny(), default)) + .ReturnsAsync((AuditLog a, CancellationToken ct) => a); + + // Act + var result = await _service.RestoreArchiveItemAsync(item.Id, dto, userId); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Success.Should().BeTrue(); + result.Value.ResolvedName.Should().Be("New Column"); + _columnRepoMock.Verify(r => r.AddAsync( + It.Is(c => c.Name == "New Column" && c.Position == 1 && c.WipLimit == 5), + default), Times.Once); + } + + [Fact] + public async Task RestoreArchiveItemAsync_ShouldRenameColumn_OnConflict() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var board = TestDataBuilder.CreateBoard(); + var column1 = TestDataBuilder.CreateColumn(board.Id, "Existing Column", position: 0); + var boardWithColumns = TestDataBuilder.CreateBoardWithColumns(board.Name, new[] { column1 }); + + var snapshotJson = JsonSerializer.Serialize(new { Name = "Existing Column", Position = 0, WipLimit = (int?)null }); + var item = CreateArchiveItem("column", Guid.NewGuid(), boardId, "Existing Column", userId, RestoreStatus.Available, snapshotJson); + var dto = new RestoreArchiveItemDto(boardWithColumns.Id, RestoreMode.Copy, ConflictStrategy.Rename); + + _archiveItemRepoMock.Setup(r => r.GetByIdAsync(item.Id, default)) + .ReturnsAsync(item); + _authorizationServiceMock.Setup(s => s.CanWriteBoardAsync(userId, boardWithColumns.Id)) + .ReturnsAsync(Result.Success(true)); + _boardRepoMock.Setup(r => r.GetByIdAsync(boardWithColumns.Id, default)) + .ReturnsAsync(boardWithColumns); + _boardRepoMock.Setup(r => r.GetByIdWithDetailsAsync(boardWithColumns.Id, default)) + .ReturnsAsync(boardWithColumns); + _columnRepoMock.Setup(r => r.AddAsync(It.IsAny(), default)) + .ReturnsAsync((Column c, CancellationToken ct) => c); + _auditLogRepoMock.Setup(r => r.AddAsync(It.IsAny(), default)) + .ReturnsAsync((AuditLog a, CancellationToken ct) => a); + + // Act + var result = await _service.RestoreArchiveItemAsync(item.Id, dto, userId); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.ResolvedName.Should().Be("Existing Column (Restored)"); + _columnRepoMock.Verify(r => r.AddAsync( + It.Is(c => c.Name == "Existing Column (Restored)"), + default), Times.Once); + } + + #endregion + + #region RestoreArchiveItemAsync - Card Tests + + [Fact] + public async Task RestoreArchiveItemAsync_ShouldRestoreCard_WithoutConflict() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var board = TestDataBuilder.CreateBoard(); + var column = TestDataBuilder.CreateColumn(board.Id, "To Do", position: 0); + var boardWithColumns = TestDataBuilder.CreateBoardWithColumns(board.Name, new[] { column }); + + var snapshotJson = JsonSerializer.Serialize(new + { + Title = "New Card", + Description = "Test card", + DueDate = (DateTimeOffset?)null, + IsBlocked = false, + BlockReason = (string?)null, + ColumnId = column.Id + }); + var item = CreateArchiveItem("card", Guid.NewGuid(), boardId, "New Card", userId, RestoreStatus.Available, snapshotJson); + var dto = new RestoreArchiveItemDto(boardWithColumns.Id, RestoreMode.Copy, ConflictStrategy.Fail); + + _archiveItemRepoMock.Setup(r => r.GetByIdAsync(item.Id, default)) + .ReturnsAsync(item); + _authorizationServiceMock.Setup(s => s.CanWriteBoardAsync(userId, boardWithColumns.Id)) + .ReturnsAsync(Result.Success(true)); + _boardRepoMock.Setup(r => r.GetByIdAsync(boardWithColumns.Id, default)) + .ReturnsAsync(boardWithColumns); + _boardRepoMock.Setup(r => r.GetByIdWithDetailsAsync(boardWithColumns.Id, default)) + .ReturnsAsync(boardWithColumns); + _columnRepoMock.Setup(r => r.GetByIdWithCardsAsync(column.Id, default)) + .ReturnsAsync(column); + _cardRepoMock.Setup(r => r.AddAsync(It.IsAny(), default)) + .ReturnsAsync((Card c, CancellationToken ct) => c); + _auditLogRepoMock.Setup(r => r.AddAsync(It.IsAny(), default)) + .ReturnsAsync((AuditLog a, CancellationToken ct) => a); + + // Act + var result = await _service.RestoreArchiveItemAsync(item.Id, dto, userId); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Success.Should().BeTrue(); + result.Value.ResolvedName.Should().Be("New Card"); + _cardRepoMock.Verify(r => r.AddAsync( + It.Is(c => c.Title == "New Card" && c.ColumnId == column.Id), + default), Times.Once); + } + + [Fact] + public async Task RestoreArchiveItemAsync_ShouldRestoreCardToFirstColumn_WhenOriginalColumnMissing() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var board = TestDataBuilder.CreateBoard(); + var column = TestDataBuilder.CreateColumn(board.Id, "To Do", position: 0); + var boardWithColumns = TestDataBuilder.CreateBoardWithColumns(board.Name, new[] { column }); + + var snapshotJson = JsonSerializer.Serialize(new + { + Title = "Card", + Description = (string?)null, + DueDate = (DateTimeOffset?)null, + IsBlocked = false, + BlockReason = (string?)null, + ColumnId = Guid.NewGuid() // Non-existent column + }); + var item = CreateArchiveItem("card", Guid.NewGuid(), boardId, "Card", userId, RestoreStatus.Available, snapshotJson); + var dto = new RestoreArchiveItemDto(boardWithColumns.Id, RestoreMode.Copy, ConflictStrategy.Fail); + + _archiveItemRepoMock.Setup(r => r.GetByIdAsync(item.Id, default)) + .ReturnsAsync(item); + _authorizationServiceMock.Setup(s => s.CanWriteBoardAsync(userId, boardWithColumns.Id)) + .ReturnsAsync(Result.Success(true)); + _boardRepoMock.Setup(r => r.GetByIdAsync(boardWithColumns.Id, default)) + .ReturnsAsync(boardWithColumns); + _boardRepoMock.Setup(r => r.GetByIdWithDetailsAsync(boardWithColumns.Id, default)) + .ReturnsAsync(boardWithColumns); + _columnRepoMock.Setup(r => r.GetByIdWithCardsAsync(column.Id, default)) + .ReturnsAsync(column); + _cardRepoMock.Setup(r => r.AddAsync(It.IsAny(), default)) + .ReturnsAsync((Card c, CancellationToken ct) => c); + _auditLogRepoMock.Setup(r => r.AddAsync(It.IsAny(), default)) + .ReturnsAsync((AuditLog a, CancellationToken ct) => a); + + // Act + var result = await _service.RestoreArchiveItemAsync(item.Id, dto, userId); + + // Assert + result.IsSuccess.Should().BeTrue(); + _cardRepoMock.Verify(r => r.AddAsync( + It.Is(c => c.ColumnId == column.Id), + default), Times.Once); + } + + [Fact] + public async Task RestoreArchiveItemAsync_ShouldFail_WhenTargetBoardHasNoColumns() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var board = TestDataBuilder.CreateBoard(); + + var snapshotJson = JsonSerializer.Serialize(new + { + Title = "Card", + Description = (string?)null, + DueDate = (DateTimeOffset?)null, + IsBlocked = false, + BlockReason = (string?)null, + ColumnId = Guid.NewGuid() + }); + var item = CreateArchiveItem("card", Guid.NewGuid(), boardId, "Card", userId, RestoreStatus.Available, snapshotJson); + var dto = new RestoreArchiveItemDto(board.Id, RestoreMode.Copy, ConflictStrategy.Fail); + + _archiveItemRepoMock.Setup(r => r.GetByIdAsync(item.Id, default)) + .ReturnsAsync(item); + _authorizationServiceMock.Setup(s => s.CanWriteBoardAsync(userId, board.Id)) + .ReturnsAsync(Result.Success(true)); + _boardRepoMock.Setup(r => r.GetByIdAsync(board.Id, default)) + .ReturnsAsync(board); + _boardRepoMock.Setup(r => r.GetByIdWithDetailsAsync(board.Id, default)) + .ReturnsAsync(board); + + // Act + var result = await _service.RestoreArchiveItemAsync(item.Id, dto, userId); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.InvalidOperation); + result.ErrorMessage.Should().Contain("no columns"); + } + + [Fact] + public async Task RestoreArchiveItemAsync_ShouldFail_WhenWipLimitExceeded() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var board = TestDataBuilder.CreateBoard(); + var card1 = TestDataBuilder.CreateCard(board.Id, Guid.NewGuid(), "Card 1", position: 0); + var card2 = TestDataBuilder.CreateCard(board.Id, Guid.NewGuid(), "Card 2", position: 1); + var column = TestDataBuilder.CreateColumnWithCards(board.Id, "To Do", new[] { card1, card2 }, position: 0, wipLimit: 2); + var boardWithColumns = TestDataBuilder.CreateBoardWithColumns(board.Name, new[] { column }); + + var snapshotJson = JsonSerializer.Serialize(new + { + Title = "New Card", + Description = (string?)null, + DueDate = (DateTimeOffset?)null, + IsBlocked = false, + BlockReason = (string?)null, + ColumnId = column.Id + }); + var item = CreateArchiveItem("card", Guid.NewGuid(), boardId, "New Card", userId, RestoreStatus.Available, snapshotJson); + var dto = new RestoreArchiveItemDto(boardWithColumns.Id, RestoreMode.Copy, ConflictStrategy.Fail); + + _archiveItemRepoMock.Setup(r => r.GetByIdAsync(item.Id, default)) + .ReturnsAsync(item); + _authorizationServiceMock.Setup(s => s.CanWriteBoardAsync(userId, boardWithColumns.Id)) + .ReturnsAsync(Result.Success(true)); + _boardRepoMock.Setup(r => r.GetByIdAsync(boardWithColumns.Id, default)) + .ReturnsAsync(boardWithColumns); + _boardRepoMock.Setup(r => r.GetByIdWithDetailsAsync(boardWithColumns.Id, default)) + .ReturnsAsync(boardWithColumns); + _columnRepoMock.Setup(r => r.GetByIdWithCardsAsync(column.Id, default)) + .ReturnsAsync(column); + + // Act + var result = await _service.RestoreArchiveItemAsync(item.Id, dto, userId); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.WipLimitExceeded); + } + + [Fact] + public async Task RestoreArchiveItemAsync_ShouldRestoreBlockedCard() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var board = TestDataBuilder.CreateBoard(); + var column = TestDataBuilder.CreateColumn(board.Id, "To Do", position: 0); + var boardWithColumns = TestDataBuilder.CreateBoardWithColumns(board.Name, new[] { column }); + + var snapshotJson = JsonSerializer.Serialize(new + { + Title = "Blocked Card", + Description = "Test", + DueDate = (DateTimeOffset?)null, + IsBlocked = true, + BlockReason = "Waiting on dependency", + ColumnId = column.Id + }); + var item = CreateArchiveItem("card", Guid.NewGuid(), boardId, "Blocked Card", userId, RestoreStatus.Available, snapshotJson); + var dto = new RestoreArchiveItemDto(boardWithColumns.Id, RestoreMode.Copy, ConflictStrategy.Fail); + + _archiveItemRepoMock.Setup(r => r.GetByIdAsync(item.Id, default)) + .ReturnsAsync(item); + _authorizationServiceMock.Setup(s => s.CanWriteBoardAsync(userId, boardWithColumns.Id)) + .ReturnsAsync(Result.Success(true)); + _boardRepoMock.Setup(r => r.GetByIdAsync(boardWithColumns.Id, default)) + .ReturnsAsync(boardWithColumns); + _boardRepoMock.Setup(r => r.GetByIdWithDetailsAsync(boardWithColumns.Id, default)) + .ReturnsAsync(boardWithColumns); + _columnRepoMock.Setup(r => r.GetByIdWithCardsAsync(column.Id, default)) + .ReturnsAsync(column); + _cardRepoMock.Setup(r => r.AddAsync(It.IsAny(), default)) + .ReturnsAsync((Card c, CancellationToken ct) => c); + _auditLogRepoMock.Setup(r => r.AddAsync(It.IsAny(), default)) + .ReturnsAsync((AuditLog a, CancellationToken ct) => a); + + // Act + var result = await _service.RestoreArchiveItemAsync(item.Id, dto, userId); + + // Assert + result.IsSuccess.Should().BeTrue(); + _cardRepoMock.Verify(r => r.AddAsync( + It.Is(c => c.IsBlocked && c.BlockReason == "Waiting on dependency"), + default), Times.Once); + } + + #endregion + + #region Helper Methods + + private static ArchiveItem CreateArchiveItem( + string entityType, + Guid entityId, + Guid boardId, + string name, + Guid userId, + RestoreStatus status = RestoreStatus.Available, + string? snapshotJson = null) + { + snapshotJson ??= JsonSerializer.Serialize(new { Name = name }); + var item = new ArchiveItem(entityType, entityId, boardId, name, userId, snapshotJson, null); + + if (status == RestoreStatus.Restored) + { + item.MarkAsRestored(userId); + } + else if (status == RestoreStatus.Expired) + { + item.MarkAsExpired(); + } + else if (status == RestoreStatus.Conflict) + { + item.MarkAsConflict(); + } + + return item; + } + + #endregion +} From ad071d6a31f077078d5c59d9ac1d44c8b767c713 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 20:28:25 +0000 Subject: [PATCH 11/30] Implement core automation services: PolicyEngine, Planner, and Executor Co-authored-by: Chris0Jeky <59696583+Chris0Jeky@users.noreply.github.com> --- .../Services/AutomationExecutorService.cs | 370 ++++++++++++++ .../Services/AutomationPlannerService.cs | 252 +++++++++ .../Services/AutomationPolicyEngine.cs | 133 +++++ .../Services/IAutomationExecutorService.cs | 8 + .../Services/IAutomationPlannerService.cs | 9 + .../Services/IAutomationPolicyEngine.cs | 12 + .../AutomationExecutorServiceTests.cs | 232 +++++++++ .../Services/AutomationPlannerServiceTests.cs | 461 +++++++++++++++++ .../Services/AutomationPolicyEngineTests.cs | 483 ++++++++++++++++++ 9 files changed, 1960 insertions(+) create mode 100644 backend/src/Taskdeck.Application/Services/AutomationExecutorService.cs create mode 100644 backend/src/Taskdeck.Application/Services/AutomationPlannerService.cs create mode 100644 backend/src/Taskdeck.Application/Services/AutomationPolicyEngine.cs create mode 100644 backend/src/Taskdeck.Application/Services/IAutomationExecutorService.cs create mode 100644 backend/src/Taskdeck.Application/Services/IAutomationPlannerService.cs create mode 100644 backend/src/Taskdeck.Application/Services/IAutomationPolicyEngine.cs create mode 100644 backend/tests/Taskdeck.Application.Tests/Services/AutomationExecutorServiceTests.cs create mode 100644 backend/tests/Taskdeck.Application.Tests/Services/AutomationPlannerServiceTests.cs create mode 100644 backend/tests/Taskdeck.Application.Tests/Services/AutomationPolicyEngineTests.cs diff --git a/backend/src/Taskdeck.Application/Services/AutomationExecutorService.cs b/backend/src/Taskdeck.Application/Services/AutomationExecutorService.cs new file mode 100644 index 000000000..6087d6fdf --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/AutomationExecutorService.cs @@ -0,0 +1,370 @@ +using System.Text.Json; +using Taskdeck.Application.DTOs; +using Taskdeck.Application.Interfaces; +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Enums; +using Taskdeck.Domain.Exceptions; + +namespace Taskdeck.Application.Services; + +public class AutomationExecutorService : IAutomationExecutorService +{ + private readonly IUnitOfWork _unitOfWork; + private readonly IAutomationProposalService _proposalService; + private readonly IAutomationPolicyEngine _policyEngine; + private readonly CardService _cardService; + private readonly BoardService _boardService; + private readonly ColumnService _columnService; + private readonly Dictionary> _executedOperations = new(); + + public AutomationExecutorService( + IUnitOfWork unitOfWork, + IAutomationProposalService proposalService, + IAutomationPolicyEngine policyEngine, + CardService cardService, + BoardService boardService, + ColumnService columnService) + { + _unitOfWork = unitOfWork; + _proposalService = proposalService; + _policyEngine = policyEngine; + _cardService = cardService; + _boardService = boardService; + _columnService = columnService; + } + + public async Task ExecuteProposalAsync(Guid proposalId, string idempotencyKey, CancellationToken cancellationToken = default) + { + if (proposalId == Guid.Empty) + return Result.Failure(ErrorCodes.ValidationError, "ProposalId cannot be empty"); + + if (string.IsNullOrWhiteSpace(idempotencyKey)) + return Result.Failure(ErrorCodes.ValidationError, "IdempotencyKey cannot be empty"); + + // Check idempotency - has this proposal been executed with this key? + if (_executedOperations.ContainsKey(idempotencyKey) && + _executedOperations[idempotencyKey].Contains(proposalId.ToString())) + { + return Result.Success(); // Already executed, return success + } + + // Get proposal + var proposalResult = await _proposalService.GetProposalByIdAsync(proposalId, cancellationToken); + if (!proposalResult.IsSuccess) + return Result.Failure(proposalResult.ErrorCode, proposalResult.ErrorMessage); + + var proposal = proposalResult.Value; + + // Verify proposal is approved + if (proposal.Status != ProposalStatus.Approved) + return Result.Failure(ErrorCodes.InvalidOperation, $"Cannot execute proposal in status {proposal.Status}"); + + // Revalidate policy before execution + var policyResult = _policyEngine.ValidatePolicy(proposal); + if (!policyResult.IsSuccess) + return Result.Failure(policyResult.ErrorCode, policyResult.ErrorMessage); + + // Revalidate permissions + var permissionResult = await _policyEngine.ValidatePermissionsAsync( + proposal.RequestedByUserId, + proposal.BoardId, + proposal.Operations, + cancellationToken); + if (!permissionResult.IsSuccess) + return Result.Failure(permissionResult.ErrorCode, permissionResult.ErrorMessage); + + try + { + await _unitOfWork.BeginTransactionAsync(cancellationToken); + + // Execute operations in sequence order + var orderedOperations = proposal.Operations.OrderBy(o => o.Sequence).ToList(); + var failedOperation = -1; + var failureReason = ""; + + foreach (var operation in orderedOperations) + { + var executionResult = await ExecuteOperationAsync(operation, proposal.RequestedByUserId, cancellationToken); + if (!executionResult.IsSuccess) + { + failedOperation = operation.Sequence; + failureReason = $"Operation {operation.Sequence} failed: {executionResult.ErrorMessage}"; + break; + } + + // Create audit log for the operation + await CreateAuditLogAsync(operation, proposal, cancellationToken); + } + + if (failedOperation >= 0) + { + // Mark proposal as failed and rollback transaction + await _unitOfWork.RollbackTransactionAsync(cancellationToken); + + // Update proposal status + var updateResult = await UpdateProposalStatusAsync(proposalId, ProposalStatus.Failed, failureReason, cancellationToken); + if (!updateResult.IsSuccess) + return Result.Failure(updateResult.ErrorCode, updateResult.ErrorMessage); + + return Result.Failure(ErrorCodes.UnexpectedError, failureReason); + } + + // Mark proposal as applied + await _unitOfWork.CommitTransactionAsync(cancellationToken); + + var markResult = await UpdateProposalStatusAsync(proposalId, ProposalStatus.Applied, null, cancellationToken); + if (!markResult.IsSuccess) + return Result.Failure(markResult.ErrorCode, markResult.ErrorMessage); + + // Record idempotency + if (!_executedOperations.ContainsKey(idempotencyKey)) + _executedOperations[idempotencyKey] = new HashSet(); + _executedOperations[idempotencyKey].Add(proposalId.ToString()); + + return Result.Success(); + } + catch (Exception ex) + { + await _unitOfWork.RollbackTransactionAsync(cancellationToken); + await UpdateProposalStatusAsync(proposalId, ProposalStatus.Failed, ex.Message, cancellationToken); + return Result.Failure(ErrorCodes.UnexpectedError, $"Failed to execute proposal: {ex.Message}"); + } + } + + private async Task ExecuteOperationAsync(ProposalOperationDto operation, Guid userId, CancellationToken cancellationToken) + { + var actionType = operation.ActionType.ToLowerInvariant(); + var targetType = operation.TargetType.ToLowerInvariant(); + + try + { + if (targetType == "card") + { + return await ExecuteCardOperationAsync(actionType, operation, cancellationToken); + } + else if (targetType == "board") + { + return await ExecuteBoardOperationAsync(actionType, operation, cancellationToken); + } + else if (targetType == "column") + { + return await ExecuteColumnOperationAsync(actionType, operation, cancellationToken); + } + else + { + return Result.Failure(ErrorCodes.ValidationError, $"Unsupported target type: {targetType}"); + } + } + catch (Exception ex) + { + return Result.Failure(ErrorCodes.UnexpectedError, $"Operation execution failed: {ex.Message}"); + } + } + + private async Task ExecuteCardOperationAsync(string actionType, ProposalOperationDto operation, CancellationToken cancellationToken) + { + var parameters = JsonSerializer.Deserialize(operation.Parameters); + + switch (actionType) + { + case "create": + return await CreateCardAsync(parameters, cancellationToken); + + case "update": + return await UpdateCardAsync(parameters, cancellationToken); + + case "move": + return await MoveCardAsync(parameters, cancellationToken); + + case "archive": + return await ArchiveCardAsync(parameters, cancellationToken); + + default: + return Result.Failure(ErrorCodes.ValidationError, $"Unsupported card action: {actionType}"); + } + } + + private async Task CreateCardAsync(JsonElement parameters, CancellationToken cancellationToken) + { + var title = parameters.GetProperty("title").GetString(); + var description = parameters.TryGetProperty("description", out var descProp) ? descProp.GetString() : null; + var columnIdStr = parameters.GetProperty("columnId").GetString(); + var boardIdStr = parameters.GetProperty("boardId").GetString(); + + if (!Guid.TryParse(columnIdStr, out var columnId)) + return Result.Failure(ErrorCodes.ValidationError, "Invalid columnId"); + + if (!Guid.TryParse(boardIdStr, out var boardId)) + return Result.Failure(ErrorCodes.ValidationError, "Invalid boardId"); + + var dto = new CreateCardDto(boardId, columnId, title!, description, null, null); + var result = await _cardService.CreateCardAsync(dto, cancellationToken); + + return result.IsSuccess ? Result.Success() : Result.Failure(result.ErrorCode, result.ErrorMessage); + } + + private async Task UpdateCardAsync(JsonElement parameters, CancellationToken cancellationToken) + { + var cardIdStr = parameters.GetProperty("cardId").GetString(); + if (!Guid.TryParse(cardIdStr, out var cardId)) + return Result.Failure(ErrorCodes.ValidationError, "Invalid cardId"); + + var title = parameters.TryGetProperty("title", out var titleProp) ? titleProp.GetString() : null; + var description = parameters.TryGetProperty("description", out var descProp) ? descProp.GetString() : null; + + var dto = new UpdateCardDto(title, description, null, null, null, null); + var result = await _cardService.UpdateCardAsync(cardId, dto, cancellationToken); + + return result.IsSuccess ? Result.Success() : Result.Failure(result.ErrorCode, result.ErrorMessage); + } + + private async Task MoveCardAsync(JsonElement parameters, CancellationToken cancellationToken) + { + var cardIdStr = parameters.GetProperty("cardId").GetString(); + var columnIdStr = parameters.GetProperty("columnId").GetString(); + + if (!Guid.TryParse(cardIdStr, out var cardId)) + return Result.Failure(ErrorCodes.ValidationError, "Invalid cardId"); + + if (!Guid.TryParse(columnIdStr, out var columnId)) + return Result.Failure(ErrorCodes.ValidationError, "Invalid columnId"); + + // Get current cards in target column to determine position + var targetColumn = await _unitOfWork.Columns.GetByIdWithCardsAsync(columnId, cancellationToken); + if (targetColumn == null) + return Result.Failure(ErrorCodes.NotFound, $"Column {columnId} not found"); + + var position = targetColumn.Cards.Any() ? targetColumn.Cards.Max(c => c.Position) + 1 : 0; + var dto = new MoveCardDto(columnId, position); + var result = await _cardService.MoveCardAsync(cardId, dto, cancellationToken); + + return result.IsSuccess ? Result.Success() : Result.Failure(result.ErrorCode, result.ErrorMessage); + } + + private async Task ArchiveCardAsync(JsonElement parameters, CancellationToken cancellationToken) + { + var cardIdStr = parameters.GetProperty("cardId").GetString(); + if (!Guid.TryParse(cardIdStr, out var cardId)) + return Result.Failure(ErrorCodes.ValidationError, "Invalid cardId"); + + var dto = new UpdateCardDto(null, null, null, true, null, null); + var result = await _cardService.UpdateCardAsync(cardId, dto, cancellationToken); + + return result.IsSuccess ? Result.Success() : Result.Failure(result.ErrorCode, result.ErrorMessage); + } + + private async Task ExecuteBoardOperationAsync(string actionType, ProposalOperationDto operation, CancellationToken cancellationToken) + { + var parameters = JsonSerializer.Deserialize(operation.Parameters); + + switch (actionType) + { + case "update": + return await UpdateBoardAsync(parameters, cancellationToken); + + default: + return Result.Failure(ErrorCodes.ValidationError, $"Unsupported board action: {actionType}"); + } + } + + private async Task UpdateBoardAsync(JsonElement parameters, CancellationToken cancellationToken) + { + var boardIdStr = parameters.GetProperty("boardId").GetString(); + if (!Guid.TryParse(boardIdStr, out var boardId)) + return Result.Failure(ErrorCodes.ValidationError, "Invalid boardId"); + + var name = parameters.TryGetProperty("name", out var nameProp) ? nameProp.GetString() : null; + var description = parameters.TryGetProperty("description", out var descProp) ? descProp.GetString() : null; + + var dto = new UpdateBoardDto(name, description, null); + var result = await _boardService.UpdateBoardAsync(boardId, dto, cancellationToken); + + return result.IsSuccess ? Result.Success() : Result.Failure(result.ErrorCode, result.ErrorMessage); + } + + private async Task ExecuteColumnOperationAsync(string actionType, ProposalOperationDto operation, CancellationToken cancellationToken) + { + var parameters = JsonSerializer.Deserialize(operation.Parameters); + + switch (actionType) + { + case "reorder": + return await ReorderColumnAsync(parameters, cancellationToken); + + default: + return Result.Failure(ErrorCodes.ValidationError, $"Unsupported column action: {actionType}"); + } + } + + private async Task ReorderColumnAsync(JsonElement parameters, CancellationToken cancellationToken) + { + var columnIdStr = parameters.GetProperty("columnId").GetString(); + var newPosition = parameters.GetProperty("position").GetInt32(); + + if (!Guid.TryParse(columnIdStr, out var columnId)) + return Result.Failure(ErrorCodes.ValidationError, "Invalid columnId"); + + var dto = new UpdateColumnDto(null, newPosition, null); + var result = await _columnService.UpdateColumnAsync(columnId, dto, cancellationToken); + + return result.IsSuccess ? Result.Success() : Result.Failure(result.ErrorCode, result.ErrorMessage); + } + + private async Task CreateAuditLogAsync(ProposalOperationDto operation, ProposalDto proposal, CancellationToken cancellationToken) + { + var actionMap = new Dictionary + { + { "create", AuditAction.Created }, + { "update", AuditAction.Updated }, + { "archive", AuditAction.Archived }, + { "move", AuditAction.Moved } + }; + + var auditAction = actionMap.ContainsKey(operation.ActionType.ToLowerInvariant()) + ? actionMap[operation.ActionType.ToLowerInvariant()] + : AuditAction.Updated; + + var entityId = !string.IsNullOrEmpty(operation.TargetId) && Guid.TryParse(operation.TargetId, out var id) + ? id + : Guid.NewGuid(); // For creates, we'd need to capture the created ID + + var changes = $"Automation Proposal {proposal.Id}: {operation.ActionType} {operation.TargetType}. Parameters: {operation.Parameters}"; + + var auditLog = new AuditLog( + operation.TargetType, + entityId, + auditAction, + proposal.RequestedByUserId, + changes + ); + + await _unitOfWork.AuditLogs.AddAsync(auditLog, cancellationToken); + } + + private async Task UpdateProposalStatusAsync(Guid proposalId, ProposalStatus status, string? failureReason, CancellationToken cancellationToken) + { + var proposal = await _unitOfWork.AutomationProposals.GetByIdAsync(proposalId, cancellationToken); + if (proposal == null) + return Result.Failure(ErrorCodes.NotFound, $"Proposal with ID {proposalId} not found"); + + try + { + if (status == ProposalStatus.Applied) + { + proposal.MarkAsApplied(); + } + else if (status == ProposalStatus.Failed) + { + proposal.MarkAsFailed(failureReason ?? "Unknown error"); + } + + await _unitOfWork.SaveChangesAsync(cancellationToken); + return Result.Success(); + } + catch (DomainException ex) + { + return Result.Failure(ex.ErrorCode, ex.Message); + } + } +} diff --git a/backend/src/Taskdeck.Application/Services/AutomationPlannerService.cs b/backend/src/Taskdeck.Application/Services/AutomationPlannerService.cs new file mode 100644 index 000000000..30fe0e38a --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/AutomationPlannerService.cs @@ -0,0 +1,252 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using Taskdeck.Application.DTOs; +using Taskdeck.Application.Interfaces; +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Exceptions; + +namespace Taskdeck.Application.Services; + +public class AutomationPlannerService : IAutomationPlannerService +{ + private readonly IAutomationProposalService _proposalService; + private readonly IAutomationPolicyEngine _policyEngine; + private readonly IUnitOfWork _unitOfWork; + + public AutomationPlannerService( + IAutomationProposalService proposalService, + IAutomationPolicyEngine policyEngine, + IUnitOfWork unitOfWork) + { + _proposalService = proposalService; + _policyEngine = policyEngine; + _unitOfWork = unitOfWork; + } + + public async Task> ParseInstructionAsync(string instruction, Guid userId, Guid? boardId = null, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(instruction)) + return Result.Failure(ErrorCodes.ValidationError, "Instruction cannot be empty"); + + if (userId == Guid.Empty) + return Result.Failure(ErrorCodes.ValidationError, "UserId cannot be empty"); + + try + { + var operations = new List(); + var instructionLower = instruction.ToLowerInvariant(); + var sequence = 0; + + // Pattern: "create card 'title' in column 'column name'" or "create card 'title'" + var createCardMatch = Regex.Match(instruction, @"create card ['""]([^'""]+)['""](?:\s+in column ['""]([^'""]+)['""])?(?:\s+with description ['""]([^'""]+)['""])?", RegexOptions.IgnoreCase); + if (createCardMatch.Success) + { + var title = createCardMatch.Groups[1].Value; + var columnName = createCardMatch.Groups.Count > 2 ? createCardMatch.Groups[2].Value : null; + var description = createCardMatch.Groups.Count > 3 ? createCardMatch.Groups[3].Value : null; + + if (!boardId.HasValue) + return Result.Failure(ErrorCodes.ValidationError, "Board ID is required for card operations"); + + // Find column ID if column name is specified + Guid? columnId = null; + if (!string.IsNullOrEmpty(columnName)) + { + var columns = await _unitOfWork.Columns.GetByBoardIdAsync(boardId.Value, cancellationToken); + var column = columns.FirstOrDefault(c => c.Name.Equals(columnName, StringComparison.OrdinalIgnoreCase)); + if (column == null) + return Result.Failure(ErrorCodes.NotFound, $"Column '{columnName}' not found in board"); + + columnId = column.Id; + } + else + { + // Use first column as default + var columns = await _unitOfWork.Columns.GetByBoardIdAsync(boardId.Value, cancellationToken); + var firstColumn = columns.OrderBy(c => c.Position).FirstOrDefault(); + if (firstColumn == null) + return Result.Failure(ErrorCodes.NotFound, "No columns found in board"); + + columnId = firstColumn.Id; + } + + var parameters = JsonSerializer.Serialize(new + { + title, + description, + columnId, + boardId + }); + + operations.Add(new CreateProposalOperationDto( + sequence++, + "create", + "card", + parameters, + Guid.NewGuid().ToString() + )); + } + + // Pattern: "move card {id} to column 'column name'" + var moveCardMatch = Regex.Match(instruction, @"move card ([a-f0-9-]+) to column ['""]([^'""]+)['""]", RegexOptions.IgnoreCase); + if (moveCardMatch.Success) + { + var cardIdStr = moveCardMatch.Groups[1].Value; + var columnName = moveCardMatch.Groups[2].Value; + + if (!Guid.TryParse(cardIdStr, out var cardId)) + return Result.Failure(ErrorCodes.ValidationError, $"Invalid card ID: {cardIdStr}"); + + if (!boardId.HasValue) + return Result.Failure(ErrorCodes.ValidationError, "Board ID is required for card operations"); + + // Find column + var columns = await _unitOfWork.Columns.GetByBoardIdAsync(boardId.Value, cancellationToken); + var column = columns.FirstOrDefault(c => c.Name.Equals(columnName, StringComparison.OrdinalIgnoreCase)); + if (column == null) + return Result.Failure(ErrorCodes.NotFound, $"Column '{columnName}' not found in board"); + + var parameters = JsonSerializer.Serialize(new + { + cardId, + columnId = column.Id + }); + + operations.Add(new CreateProposalOperationDto( + sequence++, + "move", + "card", + parameters, + Guid.NewGuid().ToString(), + TargetId: cardId.ToString() + )); + } + + // Pattern: "archive card {id}" or "archive cards matching 'pattern'" + var archiveCardMatch = Regex.Match(instruction, @"archive card ([a-f0-9-]+)", RegexOptions.IgnoreCase); + if (archiveCardMatch.Success) + { + var cardIdStr = archiveCardMatch.Groups[1].Value; + if (!Guid.TryParse(cardIdStr, out var cardId)) + return Result.Failure(ErrorCodes.ValidationError, $"Invalid card ID: {cardIdStr}"); + + var parameters = JsonSerializer.Serialize(new { cardId }); + + operations.Add(new CreateProposalOperationDto( + sequence++, + "archive", + "card", + parameters, + Guid.NewGuid().ToString(), + TargetId: cardId.ToString() + )); + } + + // Pattern: "archive cards matching 'pattern'" + var archiveCardsMatch = Regex.Match(instruction, @"archive cards matching ['""]([^'""]+)['""]", RegexOptions.IgnoreCase); + if (archiveCardsMatch.Success) + { + var pattern = archiveCardsMatch.Groups[1].Value; + + if (!boardId.HasValue) + return Result.Failure(ErrorCodes.ValidationError, "Board ID is required for card operations"); + + // Find matching cards + var cards = await _unitOfWork.Cards.GetByBoardIdAsync(boardId.Value, cancellationToken); + var matchingCards = cards.Where(c => + c.Title.Contains(pattern, StringComparison.OrdinalIgnoreCase)).ToList(); + + if (!matchingCards.Any()) + return Result.Failure(ErrorCodes.NotFound, $"No cards matching '{pattern}' found"); + + foreach (var card in matchingCards) + { + var parameters = JsonSerializer.Serialize(new { cardId = card.Id }); + operations.Add(new CreateProposalOperationDto( + sequence++, + "archive", + "card", + parameters, + Guid.NewGuid().ToString(), + TargetId: card.Id.ToString() + )); + } + } + + // Pattern: "update card {id} title 'new title'" or "update card {id} description 'new desc'" + var updateCardMatch = Regex.Match(instruction, @"update card ([a-f0-9-]+)\s+(title|description) ['""]([^'""]+)['""]", RegexOptions.IgnoreCase); + if (updateCardMatch.Success) + { + var cardIdStr = updateCardMatch.Groups[1].Value; + var field = updateCardMatch.Groups[2].Value.ToLower(); + var value = updateCardMatch.Groups[3].Value; + + if (!Guid.TryParse(cardIdStr, out var cardId)) + return Result.Failure(ErrorCodes.ValidationError, $"Invalid card ID: {cardIdStr}"); + + var parameters = field == "title" + ? JsonSerializer.Serialize(new { cardId, title = value }) + : JsonSerializer.Serialize(new { cardId, description = value }); + + operations.Add(new CreateProposalOperationDto( + sequence++, + "update", + "card", + parameters, + Guid.NewGuid().ToString(), + TargetId: cardId.ToString() + )); + } + + if (!operations.Any()) + return Result.Failure(ErrorCodes.ValidationError, + "Could not parse instruction. Supported patterns: 'create card \"title\"', 'move card {id} to column \"name\"', 'archive card {id}', 'archive cards matching \"pattern\"', 'update card {id} title/description \"value\"'"); + + // Classify risk + var operationDtos = operations.Select(o => new ProposalOperationDto( + Guid.NewGuid(), + Guid.Empty, + o.Sequence, + o.ActionType, + o.TargetType, + o.TargetId, + o.Parameters, + o.IdempotencyKey, + o.ExpectedVersion + )).ToList(); + + var riskLevel = _policyEngine.ClassifyRisk(operationDtos); + + // Create proposal + var createDto = new CreateProposalDto( + ProposalSourceType.Manual, + userId, + instruction.Length > 500 ? instruction.Substring(0, 497) + "..." : instruction, + riskLevel, + Guid.NewGuid().ToString(), + boardId, + null, + 1440, + operations + ); + + var result = await _proposalService.CreateProposalAsync(createDto, cancellationToken); + if (!result.IsSuccess) + return Result.Failure(result.ErrorCode, result.ErrorMessage); + + // Validate permissions + var permissionResult = await _policyEngine.ValidatePermissionsAsync(userId, boardId, operationDtos, cancellationToken); + if (!permissionResult.IsSuccess) + { + return Result.Failure(permissionResult.ErrorCode, permissionResult.ErrorMessage); + } + + return Result.Success(result.Value); + } + catch (Exception ex) + { + return Result.Failure(ErrorCodes.UnexpectedError, $"Failed to parse instruction: {ex.Message}"); + } + } +} diff --git a/backend/src/Taskdeck.Application/Services/AutomationPolicyEngine.cs b/backend/src/Taskdeck.Application/Services/AutomationPolicyEngine.cs new file mode 100644 index 000000000..ac9f87343 --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/AutomationPolicyEngine.cs @@ -0,0 +1,133 @@ +using Taskdeck.Application.DTOs; +using Taskdeck.Application.Interfaces; +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Exceptions; + +namespace Taskdeck.Application.Services; + +public class AutomationPolicyEngine : IAutomationPolicyEngine +{ + private readonly IUnitOfWork _unitOfWork; + private const int MaxOperationCount = 50; + private const int MaxParametersLength = 10000; + + public AutomationPolicyEngine(IUnitOfWork unitOfWork) + { + _unitOfWork = unitOfWork; + } + + public RiskLevel ClassifyRisk(IEnumerable operations) + { + var opList = operations.ToList(); + + if (!opList.Any()) + return RiskLevel.Low; + + var hasDelete = opList.Any(o => o.ActionType.Contains("delete", StringComparison.OrdinalIgnoreCase)); + var hasArchive = opList.Any(o => o.ActionType.Contains("archive", StringComparison.OrdinalIgnoreCase)); + var hasUpdate = opList.Any(o => o.ActionType.Contains("update", StringComparison.OrdinalIgnoreCase)); + var hasBoardOperation = opList.Any(o => o.TargetType.Equals("board", StringComparison.OrdinalIgnoreCase)); + var operationCount = opList.Count; + + // Critical: Delete board or many operations + if (hasBoardOperation && hasDelete) + return RiskLevel.Critical; + + if (operationCount > 20) + return RiskLevel.Critical; + + // High: Delete operations, board updates, or many operations + if (hasDelete || (hasBoardOperation && hasUpdate)) + return RiskLevel.High; + + if (operationCount > 10) + return RiskLevel.High; + + // Medium: Archive operations or moderate operation count + if (hasArchive) + return RiskLevel.Medium; + + if (operationCount > 5) + return RiskLevel.Medium; + + // Low: Simple creates and updates with few operations + return RiskLevel.Low; + } + + public async Task ValidatePermissionsAsync(Guid userId, Guid? boardId, IEnumerable operations, CancellationToken cancellationToken = default) + { + if (userId == Guid.Empty) + return Result.Failure(ErrorCodes.ValidationError, "UserId cannot be empty"); + + // Verify user exists + var user = await _unitOfWork.Users.GetByIdAsync(userId, cancellationToken); + if (user == null) + return Result.Failure(ErrorCodes.NotFound, $"User with ID {userId} not found"); + + var opList = operations.ToList(); + if (!opList.Any()) + return Result.Success(); + + // If board-scoped, verify board exists and user has access + if (boardId.HasValue) + { + var board = await _unitOfWork.Boards.GetByIdAsync(boardId.Value, cancellationToken); + if (board == null) + return Result.Failure(ErrorCodes.NotFound, $"Board with ID {boardId} not found"); + + var hasAccess = await _unitOfWork.BoardAccesses.HasAccessAsync(boardId.Value, userId, null, cancellationToken); + if (!hasAccess) + return Result.Failure(ErrorCodes.Forbidden, $"User does not have access to board {boardId}"); + } + + // Validate each operation targets entities within the board scope + foreach (var operation in opList) + { + if (boardId.HasValue && operation.TargetType.Equals("card", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(operation.TargetId)) + { + if (Guid.TryParse(operation.TargetId, out var cardId)) + { + var card = await _unitOfWork.Cards.GetByIdAsync(cardId, cancellationToken); + if (card != null && card.BoardId != boardId.Value) + return Result.Failure(ErrorCodes.Forbidden, $"Card {cardId} does not belong to board {boardId}"); + } + } + } + + return Result.Success(); + } + + public Result ValidatePolicy(ProposalDto proposal) + { + if (proposal == null) + return Result.Failure(ErrorCodes.ValidationError, "Proposal cannot be null"); + + if (proposal.Operations == null || !proposal.Operations.Any()) + return Result.Failure(ErrorCodes.ValidationError, "Proposal must contain at least one operation"); + + if (proposal.Operations.Count > MaxOperationCount) + return Result.Failure(ErrorCodes.ValidationError, $"Proposal exceeds maximum operation count of {MaxOperationCount}"); + + // Validate operation sequences are unique and non-negative + var sequences = proposal.Operations.Select(o => o.Sequence).ToList(); + if (sequences.Distinct().Count() != sequences.Count) + return Result.Failure(ErrorCodes.ValidationError, "Operation sequences must be unique"); + + if (sequences.Any(s => s < 0)) + return Result.Failure(ErrorCodes.ValidationError, "Operation sequences must be non-negative"); + + // Validate parameters size + foreach (var operation in proposal.Operations) + { + if (operation.Parameters.Length > MaxParametersLength) + return Result.Failure(ErrorCodes.ValidationError, $"Operation parameters exceed maximum length of {MaxParametersLength}"); + } + + // Validate proposal hasn't expired + if (DateTime.UtcNow > proposal.ExpiresAt) + return Result.Failure(ErrorCodes.ValidationError, "Proposal has expired"); + + return Result.Success(); + } +} diff --git a/backend/src/Taskdeck.Application/Services/IAutomationExecutorService.cs b/backend/src/Taskdeck.Application/Services/IAutomationExecutorService.cs new file mode 100644 index 000000000..3ef5150df --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/IAutomationExecutorService.cs @@ -0,0 +1,8 @@ +using Taskdeck.Domain.Common; + +namespace Taskdeck.Application.Services; + +public interface IAutomationExecutorService +{ + Task ExecuteProposalAsync(Guid proposalId, string idempotencyKey, CancellationToken cancellationToken = default); +} diff --git a/backend/src/Taskdeck.Application/Services/IAutomationPlannerService.cs b/backend/src/Taskdeck.Application/Services/IAutomationPlannerService.cs new file mode 100644 index 000000000..4a6d60dd4 --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/IAutomationPlannerService.cs @@ -0,0 +1,9 @@ +using Taskdeck.Application.DTOs; +using Taskdeck.Domain.Common; + +namespace Taskdeck.Application.Services; + +public interface IAutomationPlannerService +{ + Task> ParseInstructionAsync(string instruction, Guid userId, Guid? boardId = null, CancellationToken cancellationToken = default); +} diff --git a/backend/src/Taskdeck.Application/Services/IAutomationPolicyEngine.cs b/backend/src/Taskdeck.Application/Services/IAutomationPolicyEngine.cs new file mode 100644 index 000000000..3b44ce646 --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/IAutomationPolicyEngine.cs @@ -0,0 +1,12 @@ +using Taskdeck.Application.DTOs; +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Entities; + +namespace Taskdeck.Application.Services; + +public interface IAutomationPolicyEngine +{ + RiskLevel ClassifyRisk(IEnumerable operations); + Task ValidatePermissionsAsync(Guid userId, Guid? boardId, IEnumerable operations, CancellationToken cancellationToken = default); + Result ValidatePolicy(ProposalDto proposal); +} diff --git a/backend/tests/Taskdeck.Application.Tests/Services/AutomationExecutorServiceTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/AutomationExecutorServiceTests.cs new file mode 100644 index 000000000..934b31cf9 --- /dev/null +++ b/backend/tests/Taskdeck.Application.Tests/Services/AutomationExecutorServiceTests.cs @@ -0,0 +1,232 @@ +using FluentAssertions; +using Moq; +using Taskdeck.Application.DTOs; +using Taskdeck.Application.Interfaces; +using Taskdeck.Application.Services; +using Taskdeck.Application.Tests.TestUtilities; +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Exceptions; +using Xunit; + +namespace Taskdeck.Application.Tests.Services; + +public class AutomationExecutorServiceTests +{ + private readonly Mock _unitOfWorkMock; + private readonly Mock _proposalServiceMock; + private readonly Mock _policyEngineMock; + private readonly Mock _cardServiceMock; + private readonly Mock _boardServiceMock; + private readonly Mock _columnServiceMock; + private readonly Mock _proposalRepoMock; + private readonly Mock _auditLogRepoMock; + private readonly AutomationExecutorService _service; + + public AutomationExecutorServiceTests() + { + _unitOfWorkMock = new Mock(); + _proposalServiceMock = new Mock(); + _policyEngineMock = new Mock(); + _proposalRepoMock = new Mock(); + _auditLogRepoMock = new Mock(); + + _unitOfWorkMock.Setup(u => u.AutomationProposals).Returns(_proposalRepoMock.Object); + _unitOfWorkMock.Setup(u => u.AuditLogs).Returns(_auditLogRepoMock.Object); + + // Create mocks for services - they need IUnitOfWork in constructor + _cardServiceMock = new Mock(_unitOfWorkMock.Object); + _boardServiceMock = new Mock(_unitOfWorkMock.Object); + _columnServiceMock = new Mock(_unitOfWorkMock.Object); + + _service = new AutomationExecutorService( + _unitOfWorkMock.Object, + _proposalServiceMock.Object, + _policyEngineMock.Object, + _cardServiceMock.Object, + _boardServiceMock.Object, + _columnServiceMock.Object); + } + + #region ExecuteProposal Tests + + [Fact] + public async Task ExecuteProposal_ShouldReturnFailure_ForEmptyProposalId() + { + // Act + var result = await _service.ExecuteProposalAsync(Guid.Empty, "key1"); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } + + [Fact] + public async Task ExecuteProposal_ShouldReturnFailure_ForEmptyIdempotencyKey() + { + // Arrange + var proposalId = Guid.NewGuid(); + + // Act + var result = await _service.ExecuteProposalAsync(proposalId, ""); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + result.ErrorMessage.Should().Contain("IdempotencyKey"); + } + + [Fact] + public async Task ExecuteProposal_ShouldReturnFailure_ForNonexistentProposal() + { + // Arrange + var proposalId = Guid.NewGuid(); + + _proposalServiceMock.Setup(s => s.GetProposalByIdAsync(proposalId, default)) + .ReturnsAsync(Result.Failure(ErrorCodes.NotFound, "Not found")); + + // Act + var result = await _service.ExecuteProposalAsync(proposalId, "key1"); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.NotFound); + } + + [Fact] + public async Task ExecuteProposal_ShouldReturnFailure_ForNonApprovedProposal() + { + // Arrange + var proposalId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var proposal = new ProposalDto( + proposalId, + ProposalSourceType.Manual, + null, + null, + userId, + ProposalStatus.PendingReview, // Not approved + RiskLevel.Low, + "Test", + null, + null, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow, + DateTime.UtcNow.AddDays(1), + null, + null, + null, + null, + "corr1", + new List() + ); + + _proposalServiceMock.Setup(s => s.GetProposalByIdAsync(proposalId, default)) + .ReturnsAsync(Result.Success(proposal)); + + // Act + var result = await _service.ExecuteProposalAsync(proposalId, "key1"); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.InvalidOperation); + result.ErrorMessage.Should().Contain("Cannot execute proposal"); + } + + [Fact] + public async Task ExecuteProposal_ShouldReturnFailure_WhenPolicyValidationFails() + { + // Arrange + var proposalId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var operations = new List + { + new ProposalOperationDto(Guid.NewGuid(), proposalId, 0, "create", "card", null, "{\"title\":\"Test\"}", "key1", null) + }; + + var proposal = new ProposalDto( + proposalId, + ProposalSourceType.Manual, + null, + null, + userId, + ProposalStatus.Approved, + RiskLevel.Low, + "Test", + null, + null, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow, + DateTime.UtcNow.AddDays(-1), // Expired + null, + null, + null, + null, + "corr1", + operations + ); + + _proposalServiceMock.Setup(s => s.GetProposalByIdAsync(proposalId, default)) + .ReturnsAsync(Result.Success(proposal)); + _policyEngineMock.Setup(e => e.ValidatePolicy(proposal)) + .Returns(Result.Failure(ErrorCodes.ValidationError, "Expired")); + + // Act + var result = await _service.ExecuteProposalAsync(proposalId, "key1"); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } + + [Fact] + public async Task ExecuteProposal_ShouldReturnFailure_WhenPermissionValidationFails() + { + // Arrange + var proposalId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var operations = new List + { + new ProposalOperationDto(Guid.NewGuid(), proposalId, 0, "create", "card", null, "{\"title\":\"Test\"}", "key1", null) + }; + + var proposal = new ProposalDto( + proposalId, + ProposalSourceType.Manual, + null, + boardId, + userId, + ProposalStatus.Approved, + RiskLevel.Low, + "Test", + null, + null, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow, + DateTime.UtcNow.AddDays(1), + null, + null, + null, + null, + "corr1", + operations + ); + + _proposalServiceMock.Setup(s => s.GetProposalByIdAsync(proposalId, default)) + .ReturnsAsync(Result.Success(proposal)); + _policyEngineMock.Setup(e => e.ValidatePolicy(proposal)) + .Returns(Result.Success()); + _policyEngineMock.Setup(e => e.ValidatePermissionsAsync(userId, boardId, operations, default)) + .ReturnsAsync(Result.Failure(ErrorCodes.Forbidden, "No access")); + + // Act + var result = await _service.ExecuteProposalAsync(proposalId, "key1"); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.Forbidden); + } + + #endregion +} diff --git a/backend/tests/Taskdeck.Application.Tests/Services/AutomationPlannerServiceTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/AutomationPlannerServiceTests.cs new file mode 100644 index 000000000..67e10db4d --- /dev/null +++ b/backend/tests/Taskdeck.Application.Tests/Services/AutomationPlannerServiceTests.cs @@ -0,0 +1,461 @@ +using FluentAssertions; +using Moq; +using Taskdeck.Application.DTOs; +using Taskdeck.Application.Interfaces; +using Taskdeck.Application.Services; +using Taskdeck.Application.Tests.TestUtilities; +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Exceptions; +using Xunit; + +namespace Taskdeck.Application.Tests.Services; + +public class AutomationPlannerServiceTests +{ + private readonly Mock _proposalServiceMock; + private readonly Mock _policyEngineMock; + private readonly Mock _unitOfWorkMock; + private readonly Mock _columnRepoMock; + private readonly Mock _cardRepoMock; + private readonly AutomationPlannerService _service; + + public AutomationPlannerServiceTests() + { + _proposalServiceMock = new Mock(); + _policyEngineMock = new Mock(); + _unitOfWorkMock = new Mock(); + _columnRepoMock = new Mock(); + _cardRepoMock = new Mock(); + + _unitOfWorkMock.Setup(u => u.Columns).Returns(_columnRepoMock.Object); + _unitOfWorkMock.Setup(u => u.Cards).Returns(_cardRepoMock.Object); + + _service = new AutomationPlannerService( + _proposalServiceMock.Object, + _policyEngineMock.Object, + _unitOfWorkMock.Object); + } + + #region ParseInstruction Tests + + [Fact] + public async Task ParseInstruction_ShouldReturnFailure_ForEmptyInstruction() + { + // Arrange + var userId = Guid.NewGuid(); + + // Act + var result = await _service.ParseInstructionAsync("", userId); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + result.ErrorMessage.Should().Contain("cannot be empty"); + } + + [Fact] + public async Task ParseInstruction_ShouldReturnFailure_ForEmptyUserId() + { + // Act + var result = await _service.ParseInstructionAsync("create card 'test'", Guid.Empty); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } + + [Fact] + public async Task ParseInstruction_ShouldCreateProposal_ForCreateCardInstruction() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var columnId = Guid.NewGuid(); + var column = TestDataBuilder.CreateColumn(boardId, "To Do", 0); + + _columnRepoMock.Setup(r => r.GetByBoardIdAsync(boardId, default)) + .ReturnsAsync(new List { column }); + + var expectedProposal = new ProposalDto( + Guid.NewGuid(), + ProposalSourceType.Manual, + null, + boardId, + userId, + ProposalStatus.PendingReview, + RiskLevel.Low, + "create card 'Test Task'", + null, + null, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow, + DateTime.UtcNow.AddDays(1), + null, + null, + null, + null, + "corr1", + new List() + ); + + _policyEngineMock.Setup(e => e.ClassifyRisk(It.IsAny>())) + .Returns(RiskLevel.Low); + _proposalServiceMock.Setup(s => s.CreateProposalAsync(It.IsAny(), default)) + .ReturnsAsync(Result.Success(expectedProposal)); + _policyEngineMock.Setup(e => e.ValidatePermissionsAsync(userId, boardId, It.IsAny>(), default)) + .ReturnsAsync(Result.Success()); + + // Act + var result = await _service.ParseInstructionAsync("create card 'Test Task'", userId, boardId); + + // Assert + result.IsSuccess.Should().BeTrue(); + _proposalServiceMock.Verify(s => s.CreateProposalAsync( + It.Is(dto => + dto.RequestedByUserId == userId && + dto.BoardId == boardId && + dto.Operations != null && + dto.Operations.Count == 1 && + dto.Operations[0].ActionType == "create" && + dto.Operations[0].TargetType == "card" + ), default), Times.Once); + } + + [Fact] + public async Task ParseInstruction_ShouldCreateProposal_ForCreateCardWithColumnName() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var column = TestDataBuilder.CreateColumn(boardId, "In Progress", 1); + + _columnRepoMock.Setup(r => r.GetByBoardIdAsync(boardId, default)) + .ReturnsAsync(new List { column }); + + var expectedProposal = new ProposalDto( + Guid.NewGuid(), + ProposalSourceType.Manual, + null, + boardId, + userId, + ProposalStatus.PendingReview, + RiskLevel.Low, + "create card 'Task' in column 'In Progress'", + null, + null, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow, + DateTime.UtcNow.AddDays(1), + null, + null, + null, + null, + "corr1", + new List() + ); + + _policyEngineMock.Setup(e => e.ClassifyRisk(It.IsAny>())) + .Returns(RiskLevel.Low); + _proposalServiceMock.Setup(s => s.CreateProposalAsync(It.IsAny(), default)) + .ReturnsAsync(Result.Success(expectedProposal)); + _policyEngineMock.Setup(e => e.ValidatePermissionsAsync(userId, boardId, It.IsAny>(), default)) + .ReturnsAsync(Result.Success()); + + // Act + var result = await _service.ParseInstructionAsync("create card 'Task' in column 'In Progress'", userId, boardId); + + // Assert + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task ParseInstruction_ShouldReturnFailure_ForCreateCardWithoutBoardId() + { + // Arrange + var userId = Guid.NewGuid(); + + // Act + var result = await _service.ParseInstructionAsync("create card 'Test Task'", userId); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + result.ErrorMessage.Should().Contain("Board ID is required"); + } + + [Fact] + public async Task ParseInstruction_ShouldCreateProposal_ForMoveCardInstruction() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var cardId = Guid.NewGuid(); + var column = TestDataBuilder.CreateColumn(boardId, "Done", 2); + + _columnRepoMock.Setup(r => r.GetByBoardIdAsync(boardId, default)) + .ReturnsAsync(new List { column }); + + var expectedProposal = new ProposalDto( + Guid.NewGuid(), + ProposalSourceType.Manual, + null, + boardId, + userId, + ProposalStatus.PendingReview, + RiskLevel.Low, + $"move card {cardId} to column 'Done'", + null, + null, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow, + DateTime.UtcNow.AddDays(1), + null, + null, + null, + null, + "corr1", + new List() + ); + + _policyEngineMock.Setup(e => e.ClassifyRisk(It.IsAny>())) + .Returns(RiskLevel.Low); + _proposalServiceMock.Setup(s => s.CreateProposalAsync(It.IsAny(), default)) + .ReturnsAsync(Result.Success(expectedProposal)); + _policyEngineMock.Setup(e => e.ValidatePermissionsAsync(userId, boardId, It.IsAny>(), default)) + .ReturnsAsync(Result.Success()); + + // Act + var result = await _service.ParseInstructionAsync($"move card {cardId} to column 'Done'", userId, boardId); + + // Assert + result.IsSuccess.Should().BeTrue(); + _proposalServiceMock.Verify(s => s.CreateProposalAsync( + It.Is(dto => + dto.Operations != null && + dto.Operations.Count == 1 && + dto.Operations[0].ActionType == "move" && + dto.Operations[0].TargetType == "card" + ), default), Times.Once); + } + + [Fact] + public async Task ParseInstruction_ShouldCreateProposal_ForArchiveCardInstruction() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var cardId = Guid.NewGuid(); + + var expectedProposal = new ProposalDto( + Guid.NewGuid(), + ProposalSourceType.Manual, + null, + boardId, + userId, + ProposalStatus.PendingReview, + RiskLevel.Medium, + $"archive card {cardId}", + null, + null, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow, + DateTime.UtcNow.AddDays(1), + null, + null, + null, + null, + "corr1", + new List() + ); + + _policyEngineMock.Setup(e => e.ClassifyRisk(It.IsAny>())) + .Returns(RiskLevel.Medium); + _proposalServiceMock.Setup(s => s.CreateProposalAsync(It.IsAny(), default)) + .ReturnsAsync(Result.Success(expectedProposal)); + _policyEngineMock.Setup(e => e.ValidatePermissionsAsync(userId, boardId, It.IsAny>(), default)) + .ReturnsAsync(Result.Success()); + + // Act + var result = await _service.ParseInstructionAsync($"archive card {cardId}", userId, boardId); + + // Assert + result.IsSuccess.Should().BeTrue(); + _proposalServiceMock.Verify(s => s.CreateProposalAsync( + It.Is(dto => + dto.Operations != null && + dto.Operations.Count == 1 && + dto.Operations[0].ActionType == "archive" + ), default), Times.Once); + } + + [Fact] + public async Task ParseInstruction_ShouldCreateProposal_ForArchiveCardsMatchingInstruction() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var card1 = TestDataBuilder.CreateCard(boardId, Guid.NewGuid(), "Old Task 1"); + var card2 = TestDataBuilder.CreateCard(boardId, Guid.NewGuid(), "Old Task 2"); + var card3 = TestDataBuilder.CreateCard(boardId, Guid.NewGuid(), "New Task"); + + _cardRepoMock.Setup(r => r.GetByBoardIdAsync(boardId, default)) + .ReturnsAsync(new List { card1, card2, card3 }); + + var expectedProposal = new ProposalDto( + Guid.NewGuid(), + ProposalSourceType.Manual, + null, + boardId, + userId, + ProposalStatus.PendingReview, + RiskLevel.Medium, + "archive cards matching 'Old'", + null, + null, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow, + DateTime.UtcNow.AddDays(1), + null, + null, + null, + null, + "corr1", + new List() + ); + + _policyEngineMock.Setup(e => e.ClassifyRisk(It.IsAny>())) + .Returns(RiskLevel.Medium); + _proposalServiceMock.Setup(s => s.CreateProposalAsync(It.IsAny(), default)) + .ReturnsAsync(Result.Success(expectedProposal)); + _policyEngineMock.Setup(e => e.ValidatePermissionsAsync(userId, boardId, It.IsAny>(), default)) + .ReturnsAsync(Result.Success()); + + // Act + var result = await _service.ParseInstructionAsync("archive cards matching 'Old'", userId, boardId); + + // Assert + result.IsSuccess.Should().BeTrue(); + _proposalServiceMock.Verify(s => s.CreateProposalAsync( + It.Is(dto => + dto.Operations != null && + dto.Operations.Count == 2 // Only card1 and card2 match + ), default), Times.Once); + } + + [Fact] + public async Task ParseInstruction_ShouldCreateProposal_ForUpdateCardTitleInstruction() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var cardId = Guid.NewGuid(); + + var expectedProposal = new ProposalDto( + Guid.NewGuid(), + ProposalSourceType.Manual, + null, + boardId, + userId, + ProposalStatus.PendingReview, + RiskLevel.Low, + $"update card {cardId} title 'New Title'", + null, + null, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow, + DateTime.UtcNow.AddDays(1), + null, + null, + null, + null, + "corr1", + new List() + ); + + _policyEngineMock.Setup(e => e.ClassifyRisk(It.IsAny>())) + .Returns(RiskLevel.Low); + _proposalServiceMock.Setup(s => s.CreateProposalAsync(It.IsAny(), default)) + .ReturnsAsync(Result.Success(expectedProposal)); + _policyEngineMock.Setup(e => e.ValidatePermissionsAsync(userId, boardId, It.IsAny>(), default)) + .ReturnsAsync(Result.Success()); + + // Act + var result = await _service.ParseInstructionAsync($"update card {cardId} title 'New Title'", userId, boardId); + + // Assert + result.IsSuccess.Should().BeTrue(); + _proposalServiceMock.Verify(s => s.CreateProposalAsync( + It.Is(dto => + dto.Operations != null && + dto.Operations.Count == 1 && + dto.Operations[0].ActionType == "update" + ), default), Times.Once); + } + + [Fact] + public async Task ParseInstruction_ShouldReturnFailure_ForUnrecognizedPattern() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + + // Act + var result = await _service.ParseInstructionAsync("do something random", userId, boardId); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + result.ErrorMessage.Should().Contain("Could not parse instruction"); + } + + [Fact] + public async Task ParseInstruction_ShouldReturnFailure_WhenPermissionValidationFails() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var column = TestDataBuilder.CreateColumn(boardId, "To Do", 0); + + _columnRepoMock.Setup(r => r.GetByBoardIdAsync(boardId, default)) + .ReturnsAsync(new List { column }); + + var expectedProposal = new ProposalDto( + Guid.NewGuid(), + ProposalSourceType.Manual, + null, + boardId, + userId, + ProposalStatus.PendingReview, + RiskLevel.Low, + "create card 'Test'", + null, + null, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow, + DateTime.UtcNow.AddDays(1), + null, + null, + null, + null, + "corr1", + new List() + ); + + _policyEngineMock.Setup(e => e.ClassifyRisk(It.IsAny>())) + .Returns(RiskLevel.Low); + _proposalServiceMock.Setup(s => s.CreateProposalAsync(It.IsAny(), default)) + .ReturnsAsync(Result.Success(expectedProposal)); + _policyEngineMock.Setup(e => e.ValidatePermissionsAsync(userId, boardId, It.IsAny>(), default)) + .ReturnsAsync(Result.Failure(ErrorCodes.Forbidden, "No access")); + + // Act + var result = await _service.ParseInstructionAsync("create card 'Test'", userId, boardId); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.Forbidden); + } + + #endregion +} diff --git a/backend/tests/Taskdeck.Application.Tests/Services/AutomationPolicyEngineTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/AutomationPolicyEngineTests.cs new file mode 100644 index 000000000..dfaf81900 --- /dev/null +++ b/backend/tests/Taskdeck.Application.Tests/Services/AutomationPolicyEngineTests.cs @@ -0,0 +1,483 @@ +using FluentAssertions; +using Moq; +using Taskdeck.Application.DTOs; +using Taskdeck.Application.Interfaces; +using Taskdeck.Application.Services; +using Taskdeck.Application.Tests.TestUtilities; +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Exceptions; +using Xunit; + +namespace Taskdeck.Application.Tests.Services; + +public class AutomationPolicyEngineTests +{ + private readonly Mock _unitOfWorkMock; + private readonly Mock _userRepoMock; + private readonly Mock _boardRepoMock; + private readonly Mock _boardAccessRepoMock; + private readonly Mock _cardRepoMock; + private readonly AutomationPolicyEngine _engine; + + public AutomationPolicyEngineTests() + { + _unitOfWorkMock = new Mock(); + _userRepoMock = new Mock(); + _boardRepoMock = new Mock(); + _boardAccessRepoMock = new Mock(); + _cardRepoMock = new Mock(); + + _unitOfWorkMock.Setup(u => u.Users).Returns(_userRepoMock.Object); + _unitOfWorkMock.Setup(u => u.Boards).Returns(_boardRepoMock.Object); + _unitOfWorkMock.Setup(u => u.BoardAccesses).Returns(_boardAccessRepoMock.Object); + _unitOfWorkMock.Setup(u => u.Cards).Returns(_cardRepoMock.Object); + + _engine = new AutomationPolicyEngine(_unitOfWorkMock.Object); + } + + #region ClassifyRisk Tests + + [Fact] + public void ClassifyRisk_ShouldReturnLow_ForEmptyOperations() + { + // Arrange + var operations = new List(); + + // Act + var risk = _engine.ClassifyRisk(operations); + + // Assert + risk.Should().Be(RiskLevel.Low); + } + + [Fact] + public void ClassifyRisk_ShouldReturnLow_ForSimpleCardCreate() + { + // Arrange + var operations = new List + { + new ProposalOperationDto(Guid.NewGuid(), Guid.NewGuid(), 0, "create", "card", null, "{}", "key1", null) + }; + + // Act + var risk = _engine.ClassifyRisk(operations); + + // Assert + risk.Should().Be(RiskLevel.Low); + } + + [Fact] + public void ClassifyRisk_ShouldReturnMedium_ForArchiveOperation() + { + // Arrange + var operations = new List + { + new ProposalOperationDto(Guid.NewGuid(), Guid.NewGuid(), 0, "archive", "card", "card1", "{}", "key1", null) + }; + + // Act + var risk = _engine.ClassifyRisk(operations); + + // Assert + risk.Should().Be(RiskLevel.Medium); + } + + [Fact] + public void ClassifyRisk_ShouldReturnMedium_ForManyOperations() + { + // Arrange + var operations = Enumerable.Range(0, 7) + .Select(i => new ProposalOperationDto(Guid.NewGuid(), Guid.NewGuid(), i, "create", "card", null, "{}", $"key{i}", null)) + .ToList(); + + // Act + var risk = _engine.ClassifyRisk(operations); + + // Assert + risk.Should().Be(RiskLevel.Medium); + } + + [Fact] + public void ClassifyRisk_ShouldReturnHigh_ForDeleteOperation() + { + // Arrange + var operations = new List + { + new ProposalOperationDto(Guid.NewGuid(), Guid.NewGuid(), 0, "delete", "card", "card1", "{}", "key1", null) + }; + + // Act + var risk = _engine.ClassifyRisk(operations); + + // Assert + risk.Should().Be(RiskLevel.High); + } + + [Fact] + public void ClassifyRisk_ShouldReturnHigh_ForBoardUpdate() + { + // Arrange + var operations = new List + { + new ProposalOperationDto(Guid.NewGuid(), Guid.NewGuid(), 0, "update", "board", "board1", "{}", "key1", null) + }; + + // Act + var risk = _engine.ClassifyRisk(operations); + + // Assert + risk.Should().Be(RiskLevel.High); + } + + [Fact] + public void ClassifyRisk_ShouldReturnCritical_ForBoardDelete() + { + // Arrange + var operations = new List + { + new ProposalOperationDto(Guid.NewGuid(), Guid.NewGuid(), 0, "delete", "board", "board1", "{}", "key1", null) + }; + + // Act + var risk = _engine.ClassifyRisk(operations); + + // Assert + risk.Should().Be(RiskLevel.Critical); + } + + [Fact] + public void ClassifyRisk_ShouldReturnCritical_ForManyOperations() + { + // Arrange + var operations = Enumerable.Range(0, 25) + .Select(i => new ProposalOperationDto(Guid.NewGuid(), Guid.NewGuid(), i, "create", "card", null, "{}", $"key{i}", null)) + .ToList(); + + // Act + var risk = _engine.ClassifyRisk(operations); + + // Assert + risk.Should().Be(RiskLevel.Critical); + } + + #endregion + + #region ValidatePermissions Tests + + [Fact] + public async Task ValidatePermissions_ShouldReturnSuccess_ForValidUser() + { + // Arrange + var userId = Guid.NewGuid(); + var user = new User("testuser", "test@example.com", "hashedPassword"); + var operations = new List(); + + _userRepoMock.Setup(r => r.GetByIdAsync(userId, default)) + .ReturnsAsync(user); + + // Act + var result = await _engine.ValidatePermissionsAsync(userId, null, operations); + + // Assert + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task ValidatePermissions_ShouldReturnFailure_ForInvalidUserId() + { + // Arrange + var operations = new List(); + + // Act + var result = await _engine.ValidatePermissionsAsync(Guid.Empty, null, operations); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } + + [Fact] + public async Task ValidatePermissions_ShouldReturnFailure_ForNonexistentUser() + { + // Arrange + var userId = Guid.NewGuid(); + var operations = new List + { + new ProposalOperationDto(Guid.NewGuid(), Guid.NewGuid(), 0, "create", "card", null, "{}", "key1", null) + }; + + _userRepoMock.Setup(r => r.GetByIdAsync(userId, default)) + .ReturnsAsync((User?)null); + + // Act + var result = await _engine.ValidatePermissionsAsync(userId, null, operations); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.NotFound); + } + + [Fact] + public async Task ValidatePermissions_ShouldReturnFailure_ForNonexistentBoard() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var user = new User("testuser", "test@example.com", "hashedPassword"); + var operations = new List + { + new ProposalOperationDto(Guid.NewGuid(), Guid.NewGuid(), 0, "create", "card", null, "{}", "key1", null) + }; + + _userRepoMock.Setup(r => r.GetByIdAsync(userId, default)) + .ReturnsAsync(user); + _boardRepoMock.Setup(r => r.GetByIdAsync(boardId, default)) + .ReturnsAsync((Board?)null); + + // Act + var result = await _engine.ValidatePermissionsAsync(userId, boardId, operations); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.NotFound); + } + + [Fact] + public async Task ValidatePermissions_ShouldReturnFailure_ForUnauthorizedBoardAccess() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var user = new User("testuser", "test@example.com", "hashedPassword"); + var board = TestDataBuilder.CreateBoard(); + var operations = new List + { + new ProposalOperationDto(Guid.NewGuid(), Guid.NewGuid(), 0, "create", "card", null, "{}", "key1", null) + }; + + _userRepoMock.Setup(r => r.GetByIdAsync(userId, default)) + .ReturnsAsync(user); + _boardRepoMock.Setup(r => r.GetByIdAsync(boardId, default)) + .ReturnsAsync(board); + _boardAccessRepoMock.Setup(r => r.HasAccessAsync(boardId, userId, It.IsAny(), default)) + .ReturnsAsync(false); + + // Act + var result = await _engine.ValidatePermissionsAsync(userId, boardId, operations); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.Forbidden); + } + + #endregion + + #region ValidatePolicy Tests + + [Fact] + public void ValidatePolicy_ShouldReturnFailure_ForNullProposal() + { + // Act + var result = _engine.ValidatePolicy(null!); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } + + [Fact] + public void ValidatePolicy_ShouldReturnFailure_ForEmptyOperations() + { + // Arrange + var proposal = new ProposalDto( + Guid.NewGuid(), + ProposalSourceType.Manual, + null, + null, + Guid.NewGuid(), + ProposalStatus.PendingReview, + RiskLevel.Low, + "Test", + null, + null, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow, + DateTime.UtcNow.AddDays(1), + null, + null, + null, + null, + "corr1", + new List() + ); + + // Act + var result = _engine.ValidatePolicy(proposal); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } + + [Fact] + public void ValidatePolicy_ShouldReturnFailure_ForTooManyOperations() + { + // Arrange + var operations = Enumerable.Range(0, 51) + .Select(i => new ProposalOperationDto(Guid.NewGuid(), Guid.NewGuid(), i, "create", "card", null, "{}", $"key{i}", null)) + .ToList(); + + var proposal = new ProposalDto( + Guid.NewGuid(), + ProposalSourceType.Manual, + null, + null, + Guid.NewGuid(), + ProposalStatus.PendingReview, + RiskLevel.Low, + "Test", + null, + null, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow, + DateTime.UtcNow.AddDays(1), + null, + null, + null, + null, + "corr1", + operations + ); + + // Act + var result = _engine.ValidatePolicy(proposal); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + result.ErrorMessage.Should().Contain("maximum operation count"); + } + + [Fact] + public void ValidatePolicy_ShouldReturnFailure_ForDuplicateSequences() + { + // Arrange + var operations = new List + { + new ProposalOperationDto(Guid.NewGuid(), Guid.NewGuid(), 0, "create", "card", null, "{}", "key1", null), + new ProposalOperationDto(Guid.NewGuid(), Guid.NewGuid(), 0, "create", "card", null, "{}", "key2", null) + }; + + var proposal = new ProposalDto( + Guid.NewGuid(), + ProposalSourceType.Manual, + null, + null, + Guid.NewGuid(), + ProposalStatus.PendingReview, + RiskLevel.Low, + "Test", + null, + null, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow, + DateTime.UtcNow.AddDays(1), + null, + null, + null, + null, + "corr1", + operations + ); + + // Act + var result = _engine.ValidatePolicy(proposal); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + result.ErrorMessage.Should().Contain("sequences must be unique"); + } + + [Fact] + public void ValidatePolicy_ShouldReturnFailure_ForExpiredProposal() + { + // Arrange + var operations = new List + { + new ProposalOperationDto(Guid.NewGuid(), Guid.NewGuid(), 0, "create", "card", null, "{}", "key1", null) + }; + + var proposal = new ProposalDto( + Guid.NewGuid(), + ProposalSourceType.Manual, + null, + null, + Guid.NewGuid(), + ProposalStatus.PendingReview, + RiskLevel.Low, + "Test", + null, + null, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow, + DateTime.UtcNow.AddDays(-1), // Expired + null, + null, + null, + null, + "corr1", + operations + ); + + // Act + var result = _engine.ValidatePolicy(proposal); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + result.ErrorMessage.Should().Contain("expired"); + } + + [Fact] + public void ValidatePolicy_ShouldReturnSuccess_ForValidProposal() + { + // Arrange + var operations = new List + { + new ProposalOperationDto(Guid.NewGuid(), Guid.NewGuid(), 0, "create", "card", null, "{}", "key1", null), + new ProposalOperationDto(Guid.NewGuid(), Guid.NewGuid(), 1, "update", "card", "card1", "{}", "key2", null) + }; + + var proposal = new ProposalDto( + Guid.NewGuid(), + ProposalSourceType.Manual, + null, + null, + Guid.NewGuid(), + ProposalStatus.PendingReview, + RiskLevel.Low, + "Test", + null, + null, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow, + DateTime.UtcNow.AddDays(1), + null, + null, + null, + null, + "corr1", + operations + ); + + // Act + var result = _engine.ValidatePolicy(proposal); + + // Assert + result.IsSuccess.Should().BeTrue(); + } + + #endregion +} From b53c679dce7ea253ab57466cca89b72aa23a7d7c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 20:41:45 +0000 Subject: [PATCH 12/30] Add API controllers for AutomationProposals and Archive with integration tests Co-authored-by: Chris0Jeky <59696583+Chris0Jeky@users.noreply.github.com> --- .../Controllers/ArchiveController.cs | 142 +++++++++++ .../AutomationProposalsController.cs | 205 ++++++++++++++++ backend/src/Taskdeck.Api/Program.cs | 9 + .../Taskdeck.Api.Tests/ArchiveApiTests.cs | 71 ++++++ .../AutomationProposalsApiTests.cs | 227 ++++++++++++++++++ 5 files changed, 654 insertions(+) create mode 100644 backend/src/Taskdeck.Api/Controllers/ArchiveController.cs create mode 100644 backend/src/Taskdeck.Api/Controllers/AutomationProposalsController.cs create mode 100644 backend/tests/Taskdeck.Api.Tests/ArchiveApiTests.cs create mode 100644 backend/tests/Taskdeck.Api.Tests/AutomationProposalsApiTests.cs diff --git a/backend/src/Taskdeck.Api/Controllers/ArchiveController.cs b/backend/src/Taskdeck.Api/Controllers/ArchiveController.cs new file mode 100644 index 000000000..944955f8c --- /dev/null +++ b/backend/src/Taskdeck.Api/Controllers/ArchiveController.cs @@ -0,0 +1,142 @@ +using Microsoft.AspNetCore.Mvc; +using Taskdeck.Application.DTOs; +using Taskdeck.Application.Services; +using Taskdeck.Domain.Entities; + +namespace Taskdeck.Api.Controllers; + +/// +/// API endpoints for managing archived items and restoring them. +/// +[ApiController] +[Route("api/archive")] +public class ArchiveController : ControllerBase +{ + private readonly IArchiveRecoveryService _archiveService; + + public ArchiveController(IArchiveRecoveryService archiveService) + { + _archiveService = archiveService; + } + + /// + /// Gets a list of archived items with optional filters. + /// + /// Filter by entity type (e.g., "Card", "Board") + /// Filter by board ID + /// Filter by restore status + /// Maximum number of results (default: 100) + /// Cancellation token + /// List of archive items + [HttpGet("items")] + public async Task GetArchiveItems( + [FromQuery] string? entityType, + [FromQuery] Guid? boardId, + [FromQuery] RestoreStatus? status, + [FromQuery] int limit = 100, + CancellationToken cancellationToken = default) + { + var result = await _archiveService.GetArchiveItemsAsync( + entityType, + boardId, + status, + limit, + cancellationToken); + + if (result.IsSuccess) + return Ok(result.Value); + + return result.ErrorCode switch + { + "NotFound" => NotFound(new { errorCode = result.ErrorCode, message = result.ErrorMessage }), + "ValidationError" => BadRequest(new { errorCode = result.ErrorCode, message = result.ErrorMessage }), + _ => Problem(result.ErrorMessage, statusCode: 500) + }; + } + + /// + /// Gets a specific archive item by ID. + /// + /// Archive item ID + /// Cancellation token + /// Archive item details + [HttpGet("items/{id}")] + public async Task GetArchiveItem(Guid id, CancellationToken cancellationToken = default) + { + var result = await _archiveService.GetArchiveItemByIdAsync(id, cancellationToken); + + if (!result.IsSuccess) + { + return result.ErrorCode == "NotFound" + ? NotFound(new { errorCode = result.ErrorCode, message = result.ErrorMessage }) + : Problem(result.ErrorMessage, statusCode: 500); + } + + return Ok(result.Value); + } + + /// + /// Restores an archived item. + /// + /// Entity type (e.g., "Card", "Board") + /// Entity ID to restore + /// User ID performing the restore + /// Restore options + /// Cancellation token + /// Restore result + [HttpPost("{entityType}/{entityId}/restore")] + public async Task RestoreArchivedItem( + string entityType, + Guid entityId, + [FromQuery] Guid restoredByUserId, + [FromBody] RestoreArchiveItemDto dto, + CancellationToken cancellationToken = default) + { + // Find the archive item by entity type and ID + var archiveItems = await _archiveService.GetArchiveItemsAsync( + entityType, + null, + null, + 1000, + cancellationToken); + + if (!archiveItems.IsSuccess) + { + return archiveItems.ErrorCode switch + { + "NotFound" => NotFound(new { errorCode = archiveItems.ErrorCode, message = archiveItems.ErrorMessage }), + "ValidationError" => BadRequest(new { errorCode = archiveItems.ErrorCode, message = archiveItems.ErrorMessage }), + _ => Problem(archiveItems.ErrorMessage, statusCode: 500) + }; + } + + var archiveItem = archiveItems.Value.FirstOrDefault(a => a.EntityId == entityId && a.RestoreStatus == RestoreStatus.Available); + if (archiveItem == null) + { + return NotFound(new + { + errorCode = "NotFound", + message = $"No archived {entityType} found with ID {entityId}" + }); + } + + var result = await _archiveService.RestoreArchiveItemAsync( + archiveItem.Id, + dto, + restoredByUserId, + cancellationToken); + + if (!result.IsSuccess) + { + return result.ErrorCode switch + { + "NotFound" => NotFound(new { errorCode = result.ErrorCode, message = result.ErrorMessage }), + "ValidationError" => BadRequest(new { errorCode = result.ErrorCode, message = result.ErrorMessage }), + "Conflict" => Conflict(new { errorCode = result.ErrorCode, message = result.ErrorMessage }), + _ => Problem(result.ErrorMessage, statusCode: 500) + }; + } + + return Ok(result.Value); + } +} diff --git a/backend/src/Taskdeck.Api/Controllers/AutomationProposalsController.cs b/backend/src/Taskdeck.Api/Controllers/AutomationProposalsController.cs new file mode 100644 index 000000000..9be1eb5ac --- /dev/null +++ b/backend/src/Taskdeck.Api/Controllers/AutomationProposalsController.cs @@ -0,0 +1,205 @@ +using Microsoft.AspNetCore.Mvc; +using Taskdeck.Application.DTOs; +using Taskdeck.Application.Services; +using Taskdeck.Domain.Entities; + +namespace Taskdeck.Api.Controllers; + +/// +/// API endpoints for managing automation proposals and their lifecycle. +/// +[ApiController] +[Route("api/automation/proposals")] +public class AutomationProposalsController : ControllerBase +{ + private readonly IAutomationProposalService _proposalService; + + public AutomationProposalsController(IAutomationProposalService proposalService) + { + _proposalService = proposalService; + } + + /// + /// Gets a list of automation proposals with optional filters. + /// + /// Filter by proposal status + /// Filter by board ID + /// Filter by user ID + /// Filter by risk level + /// Maximum number of results (default: 100) + /// Cancellation token + /// List of proposals + [HttpGet] + public async Task GetProposals( + [FromQuery] ProposalStatus? status, + [FromQuery] Guid? boardId, + [FromQuery] Guid? userId, + [FromQuery] RiskLevel? riskLevel, + [FromQuery] int limit = 100, + CancellationToken cancellationToken = default) + { + var filter = new ProposalFilterDto(status, boardId, userId, riskLevel, limit); + var result = await _proposalService.GetProposalsAsync(filter, cancellationToken); + + if (result.IsSuccess) + return Ok(result.Value); + + return result.ErrorCode switch + { + "NotFound" => NotFound(new { errorCode = result.ErrorCode, message = result.ErrorMessage }), + "ValidationError" => BadRequest(new { errorCode = result.ErrorCode, message = result.ErrorMessage }), + _ => Problem(result.ErrorMessage, statusCode: 500) + }; + } + + /// + /// Gets a specific automation proposal by ID with all operations. + /// + /// Proposal ID + /// Cancellation token + /// Proposal details + [HttpGet("{id}")] + public async Task GetProposal(Guid id, CancellationToken cancellationToken = default) + { + var result = await _proposalService.GetProposalByIdAsync(id, cancellationToken); + + if (!result.IsSuccess) + { + return result.ErrorCode == "NotFound" + ? NotFound(new { errorCode = result.ErrorCode, message = result.ErrorMessage }) + : Problem(result.ErrorMessage, statusCode: 500); + } + + return Ok(result.Value); + } + + /// + /// Creates a new automation proposal with operations. + /// + /// Proposal creation request + /// Cancellation token + /// Created proposal + [HttpPost] + public async Task CreateProposal([FromBody] CreateProposalDto dto, CancellationToken cancellationToken = default) + { + var result = await _proposalService.CreateProposalAsync(dto, cancellationToken); + + if (!result.IsSuccess) + { + return result.ErrorCode switch + { + "ValidationError" => BadRequest(new { errorCode = result.ErrorCode, message = result.ErrorMessage }), + "NotFound" => NotFound(new { errorCode = result.ErrorCode, message = result.ErrorMessage }), + _ => Problem(result.ErrorMessage, statusCode: 500) + }; + } + + return CreatedAtAction(nameof(GetProposal), new { id = result.Value.Id }, result.Value); + } + + /// + /// Approves a pending automation proposal. + /// + /// Proposal ID + /// User ID approving the proposal + /// Cancellation token + /// Updated proposal + [HttpPost("{id}/approve")] + public async Task ApproveProposal( + Guid id, + [FromQuery] Guid decidedByUserId, + CancellationToken cancellationToken = default) + { + var result = await _proposalService.ApproveProposalAsync(id, decidedByUserId, cancellationToken); + + if (!result.IsSuccess) + { + return result.ErrorCode switch + { + "NotFound" => NotFound(new { errorCode = result.ErrorCode, message = result.ErrorMessage }), + "ValidationError" => BadRequest(new { errorCode = result.ErrorCode, message = result.ErrorMessage }), + "Conflict" => Conflict(new { errorCode = result.ErrorCode, message = result.ErrorMessage }), + _ => Problem(result.ErrorMessage, statusCode: 500) + }; + } + + return Ok(result.Value); + } + + /// + /// Rejects a pending automation proposal. + /// + /// Proposal ID + /// User ID rejecting the proposal + /// Rejection details (reason required for High/Critical risk) + /// Cancellation token + /// Updated proposal + [HttpPost("{id}/reject")] + public async Task RejectProposal( + Guid id, + [FromQuery] Guid decidedByUserId, + [FromBody] UpdateProposalStatusDto dto, + CancellationToken cancellationToken = default) + { + var result = await _proposalService.RejectProposalAsync(id, decidedByUserId, dto, cancellationToken); + + if (!result.IsSuccess) + { + return result.ErrorCode switch + { + "NotFound" => NotFound(new { errorCode = result.ErrorCode, message = result.ErrorMessage }), + "ValidationError" => BadRequest(new { errorCode = result.ErrorCode, message = result.ErrorMessage }), + "Conflict" => Conflict(new { errorCode = result.ErrorCode, message = result.ErrorMessage }), + _ => Problem(result.ErrorMessage, statusCode: 500) + }; + } + + return Ok(result.Value); + } + + /// + /// Executes an approved automation proposal by marking it as applied. + /// + /// Proposal ID + /// Cancellation token + /// Updated proposal + [HttpPost("{id}/execute")] + public async Task ExecuteProposal(Guid id, CancellationToken cancellationToken = default) + { + var result = await _proposalService.MarkAsAppliedAsync(id, cancellationToken); + + if (!result.IsSuccess) + { + return result.ErrorCode switch + { + "NotFound" => NotFound(new { errorCode = result.ErrorCode, message = result.ErrorMessage }), + "ValidationError" => BadRequest(new { errorCode = result.ErrorCode, message = result.ErrorMessage }), + "Conflict" => Conflict(new { errorCode = result.ErrorCode, message = result.ErrorMessage }), + _ => Problem(result.ErrorMessage, statusCode: 500) + }; + } + + return Ok(result.Value); + } + + /// + /// Gets a diff preview for a proposal showing what changes will be made. + /// + /// Proposal ID + /// Cancellation token + /// Diff preview text + [HttpGet("{id}/diff")] + public async Task GetProposalDiff(Guid id, CancellationToken cancellationToken = default) + { + var result = await _proposalService.GetProposalDiffAsync(id, cancellationToken); + + if (!result.IsSuccess) + { + return result.ErrorCode == "NotFound" + ? NotFound(new { errorCode = result.ErrorCode, message = result.ErrorMessage }) + : Problem(result.ErrorMessage, statusCode: 500); + } + + return Ok(new { diff = result.Value }); + } +} diff --git a/backend/src/Taskdeck.Api/Program.cs b/backend/src/Taskdeck.Api/Program.cs index 6bfb64534..9ca45c6dc 100644 --- a/backend/src/Taskdeck.Api/Program.cs +++ b/backend/src/Taskdeck.Api/Program.cs @@ -28,6 +28,15 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// Add IUserContext for claim-based identity +builder.Services.AddHttpContextAccessor(); +builder.Services.AddScoped(); // Add JwtSettings (required by AuthenticationService) var jwtSettings = builder.Configuration.GetSection("Jwt").Get() ?? new JwtSettings(); diff --git a/backend/tests/Taskdeck.Api.Tests/ArchiveApiTests.cs b/backend/tests/Taskdeck.Api.Tests/ArchiveApiTests.cs new file mode 100644 index 000000000..3337752b8 --- /dev/null +++ b/backend/tests/Taskdeck.Api.Tests/ArchiveApiTests.cs @@ -0,0 +1,71 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Taskdeck.Application.DTOs; +using Taskdeck.Application.Services; +using Taskdeck.Domain.Entities; +using Xunit; + +namespace Taskdeck.Api.Tests; + +public class ArchiveApiTests : IClassFixture +{ + private readonly HttpClient _client; + + public ArchiveApiTests(TestWebApplicationFactory factory) + { + _client = factory.CreateClient(); + } + + [Fact] + public async Task GetArchiveItems_ShouldReturnList() + { + var response = await _client.GetAsync("/api/archive/items"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var items = await response.Content.ReadFromJsonAsync>(); + items.Should().NotBeNull(); + } + + [Fact] + public async Task GetArchiveItem_ShouldReturnNotFound_WhenItemDoesNotExist() + { + var response = await _client.GetAsync($"/api/archive/items/{Guid.NewGuid()}"); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + + var error = await response.Content.ReadFromJsonAsync(); + error.GetProperty("errorCode").GetString().Should().Be("NotFound"); + } + + [Fact] + public async Task RestoreArchivedItem_WhenNotFound_ShouldReturnNotFound() + { + var restoreDto = new RestoreArchiveItemDto( + TargetBoardId: Guid.NewGuid(), + RestoreMode: RestoreMode.InPlace, + ConflictStrategy: ConflictStrategy.Fail + ); + + var response = await _client.PostAsJsonAsync( + $"/api/archive/Card/{Guid.NewGuid()}/restore?restoredByUserId={Guid.NewGuid()}", + restoreDto); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + + var error = await response.Content.ReadFromJsonAsync(); + error.GetProperty("errorCode").GetString().Should().Be("NotFound"); + } + + [Fact] + public async Task GetArchiveItems_WithLimit_ShouldRespectLimit() + { + var response = await _client.GetAsync("/api/archive/items?limit=5"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var items = await response.Content.ReadFromJsonAsync>(); + items.Should().NotBeNull(); + items!.Count.Should().BeLessOrEqualTo(5); + } +} diff --git a/backend/tests/Taskdeck.Api.Tests/AutomationProposalsApiTests.cs b/backend/tests/Taskdeck.Api.Tests/AutomationProposalsApiTests.cs new file mode 100644 index 000000000..342ccb36b --- /dev/null +++ b/backend/tests/Taskdeck.Api.Tests/AutomationProposalsApiTests.cs @@ -0,0 +1,227 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using Taskdeck.Application.DTOs; +using Taskdeck.Domain.Entities; +using Xunit; + +namespace Taskdeck.Api.Tests; + +public class AutomationProposalsApiTests : IClassFixture +{ + private readonly HttpClient _client; + + public AutomationProposalsApiTests(TestWebApplicationFactory factory) + { + _client = factory.CreateClient(); + } + + [Fact] + public async Task CreateProposal_ThenGetProposal_ShouldReturnCreatedProposal() + { + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var correlationId = Guid.NewGuid().ToString(); + + var createRequest = new CreateProposalDto( + SourceType: ProposalSourceType.Chat, + RequestedByUserId: userId, + Summary: "Test automation proposal", + RiskLevel: RiskLevel.Low, + CorrelationId: correlationId, + BoardId: boardId, + Operations: new List + { + new( + Sequence: 1, + ActionType: "CreateCard", + TargetType: "Card", + Parameters: "{\"title\":\"Test Card\"}", + IdempotencyKey: Guid.NewGuid().ToString() + ) + } + ); + + var createResponse = await _client.PostAsJsonAsync("/api/automation/proposals", createRequest); + createResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + var createdProposal = await createResponse.Content.ReadFromJsonAsync(); + createdProposal.Should().NotBeNull(); + createdProposal!.Summary.Should().Be(createRequest.Summary); + createdProposal.Status.Should().Be(ProposalStatus.PendingReview); + createdProposal.RiskLevel.Should().Be(RiskLevel.Low); + createdProposal.Operations.Should().HaveCount(1); + + var getResponse = await _client.GetAsync($"/api/automation/proposals/{createdProposal.Id}"); + getResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var retrievedProposal = await getResponse.Content.ReadFromJsonAsync(); + retrievedProposal.Should().NotBeNull(); + retrievedProposal!.Id.Should().Be(createdProposal.Id); + retrievedProposal.Summary.Should().Be(createRequest.Summary); + } + + [Fact] + public async Task GetProposals_WithFilters_ShouldReturnFilteredResults() + { + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + + var proposal1 = await CreateTestProposal(userId, boardId, RiskLevel.Low); + var proposal2 = await CreateTestProposal(userId, boardId, RiskLevel.High); + + var response = await _client.GetAsync($"/api/automation/proposals?boardId={boardId}&status={ProposalStatus.PendingReview}"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var proposals = await response.Content.ReadFromJsonAsync>(); + proposals.Should().NotBeNull(); + proposals.Should().NotBeEmpty(); + proposals.Should().Contain(p => p.Id == proposal1.Id); + proposals.Should().Contain(p => p.Id == proposal2.Id); + } + + [Fact] + public async Task ApproveProposal_ShouldUpdateStatus() + { + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var proposal = await CreateTestProposal(userId, boardId, RiskLevel.Low); + + var approveResponse = await _client.PostAsync($"/api/automation/proposals/{proposal.Id}/approve?decidedByUserId={userId}", null); + approveResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var approvedProposal = await approveResponse.Content.ReadFromJsonAsync(); + approvedProposal.Should().NotBeNull(); + approvedProposal!.Status.Should().Be(ProposalStatus.Approved); + approvedProposal.DecidedByUserId.Should().Be(userId); + approvedProposal.DecidedAt.Should().NotBeNull(); + } + + [Fact] + public async Task RejectProposal_ShouldUpdateStatus() + { + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var proposal = await CreateTestProposal(userId, boardId, RiskLevel.Low); + + var rejectDto = new UpdateProposalStatusDto(Reason: "Not needed"); + var rejectResponse = await _client.PostAsJsonAsync($"/api/automation/proposals/{proposal.Id}/reject?decidedByUserId={userId}", rejectDto); + rejectResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var rejectedProposal = await rejectResponse.Content.ReadFromJsonAsync(); + rejectedProposal.Should().NotBeNull(); + rejectedProposal!.Status.Should().Be(ProposalStatus.Rejected); + rejectedProposal.DecidedByUserId.Should().Be(userId); + } + + [Fact] + public async Task ExecuteProposal_WhenApproved_ShouldMarkAsApplied() + { + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var proposal = await CreateTestProposal(userId, boardId, RiskLevel.Low); + + await _client.PostAsync($"/api/automation/proposals/{proposal.Id}/approve?decidedByUserId={userId}", null); + + var executeResponse = await _client.PostAsync($"/api/automation/proposals/{proposal.Id}/execute", null); + executeResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var executedProposal = await executeResponse.Content.ReadFromJsonAsync(); + executedProposal.Should().NotBeNull(); + executedProposal!.Status.Should().Be(ProposalStatus.Applied); + executedProposal.AppliedAt.Should().NotBeNull(); + } + + [Fact] + public async Task ExecuteProposal_WhenNotApproved_ShouldReturnConflict() + { + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var proposal = await CreateTestProposal(userId, boardId, RiskLevel.Low); + + var executeResponse = await _client.PostAsync($"/api/automation/proposals/{proposal.Id}/execute", null); + executeResponse.StatusCode.Should().Be(HttpStatusCode.Conflict); + + var error = await executeResponse.Content.ReadFromJsonAsync(); + error.GetProperty("errorCode").GetString().Should().Be("Conflict"); + } + + [Fact] + public async Task GetProposalDiff_ShouldReturnDiffPreview() + { + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var proposal = await CreateTestProposal(userId, boardId, RiskLevel.Low); + + var diffResponse = await _client.GetAsync($"/api/automation/proposals/{proposal.Id}/diff"); + diffResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var diffResult = await diffResponse.Content.ReadFromJsonAsync(); + diffResult.TryGetProperty("diff", out var diff).Should().BeTrue(); + } + + [Fact] + public async Task GetProposal_ShouldReturnNotFound_WhenProposalDoesNotExist() + { + var response = await _client.GetAsync($"/api/automation/proposals/{Guid.NewGuid()}"); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + + var error = await response.Content.ReadFromJsonAsync(); + error.GetProperty("errorCode").GetString().Should().Be("NotFound"); + } + + [Fact] + public async Task ApproveProposal_ShouldReturnNotFound_WhenProposalDoesNotExist() + { + var response = await _client.PostAsync($"/api/automation/proposals/{Guid.NewGuid()}/approve?decidedByUserId={Guid.NewGuid()}", null); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + + var error = await response.Content.ReadFromJsonAsync(); + error.GetProperty("errorCode").GetString().Should().Be("NotFound"); + } + + [Fact] + public async Task CreateProposal_WithEmptySummary_ShouldReturnBadRequest() + { + var createRequest = new CreateProposalDto( + SourceType: ProposalSourceType.Chat, + RequestedByUserId: Guid.NewGuid(), + Summary: string.Empty, + RiskLevel: RiskLevel.Low, + CorrelationId: Guid.NewGuid().ToString() + ); + + var response = await _client.PostAsJsonAsync("/api/automation/proposals", createRequest); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + + var error = await response.Content.ReadFromJsonAsync(); + error.GetProperty("errorCode").GetString().Should().Be("ValidationError"); + } + + private async Task CreateTestProposal(Guid userId, Guid boardId, RiskLevel riskLevel) + { + var createRequest = new CreateProposalDto( + SourceType: ProposalSourceType.Chat, + RequestedByUserId: userId, + Summary: $"Test proposal {Guid.NewGuid()}", + RiskLevel: riskLevel, + CorrelationId: Guid.NewGuid().ToString(), + BoardId: boardId, + Operations: new List + { + new( + Sequence: 1, + ActionType: "CreateCard", + TargetType: "Card", + Parameters: "{\"title\":\"Test\"}", + IdempotencyKey: Guid.NewGuid().ToString() + ) + } + ); + + var response = await _client.PostAsJsonAsync("/api/automation/proposals", createRequest); + response.EnsureSuccessStatusCode(); + return (await response.Content.ReadFromJsonAsync())!; + } +} From 89c8c057f1ff6cc19f72e37bc5a5d628d8c098dc Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Fri, 13 Feb 2026 04:59:47 +0000 Subject: [PATCH 13/30] Add limit parameter to GetByBoardIdAsync Add an optional int limit (default 100) to IAutomationProposalRepository.GetByBoardIdAsync to match other query methods and allow limiting the number of returned proposals. The CancellationToken parameter is unchanged. --- .../Interfaces/IAutomationProposalRepository.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/Taskdeck.Application/Interfaces/IAutomationProposalRepository.cs b/backend/src/Taskdeck.Application/Interfaces/IAutomationProposalRepository.cs index 2acc3b480..4794910a4 100644 --- a/backend/src/Taskdeck.Application/Interfaces/IAutomationProposalRepository.cs +++ b/backend/src/Taskdeck.Application/Interfaces/IAutomationProposalRepository.cs @@ -5,7 +5,7 @@ namespace Taskdeck.Application.Interfaces; public interface IAutomationProposalRepository : IRepository { Task> GetByStatusAsync(ProposalStatus status, int limit = 100, CancellationToken cancellationToken = default); - Task> GetByBoardIdAsync(Guid boardId, CancellationToken cancellationToken = default); + Task> GetByBoardIdAsync(Guid boardId, int limit = 100, CancellationToken cancellationToken = default); Task> GetByUserIdAsync(Guid userId, int limit = 100, CancellationToken cancellationToken = default); Task> GetByRiskLevelAsync(RiskLevel riskLevel, int limit = 100, CancellationToken cancellationToken = default); Task GetBySourceReferenceAsync(ProposalSourceType sourceType, string referenceId, CancellationToken cancellationToken = default); From 16f45d938e6eb08c570c243b3808d3bc13cc1200 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Fri, 13 Feb 2026 05:00:00 +0000 Subject: [PATCH 14/30] Add GetArchiveItemByEntityAsync method Introduce GetArchiveItemByEntityAsync to IArchiveRecoveryService to allow fetching an archived item by entity type and ID. The method returns Task> and accepts a CancellationToken, enabling lookups by entity attributes for restore or display flows. --- .../Taskdeck.Application/Services/IArchiveRecoveryService.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/backend/src/Taskdeck.Application/Services/IArchiveRecoveryService.cs b/backend/src/Taskdeck.Application/Services/IArchiveRecoveryService.cs index 5e895f2be..37b406855 100644 --- a/backend/src/Taskdeck.Application/Services/IArchiveRecoveryService.cs +++ b/backend/src/Taskdeck.Application/Services/IArchiveRecoveryService.cs @@ -21,6 +21,11 @@ Task> GetArchiveItemByIdAsync( Guid id, CancellationToken cancellationToken = default); + Task> GetArchiveItemByEntityAsync( + string entityType, + Guid entityId, + CancellationToken cancellationToken = default); + Task> RestoreArchiveItemAsync( Guid id, RestoreArchiveItemDto dto, From 2df3e7b0e19e1669cdca97ef5eb2c4695f5bc314 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Fri, 13 Feb 2026 05:00:12 +0000 Subject: [PATCH 15/30] Include Operations in AutomationProposal queries Eager-load the Operations navigation property across AutomationProposal repository queries to avoid N+1 issues. Adds an overridden GetByIdAsync that includes Operations, and adds a limit parameter (default 100) to GetByBoardIdAsync with Take(limit) for consistency with other query methods. --- .../AutomationProposalRepository.cs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/backend/src/Taskdeck.Infrastructure/Repositories/AutomationProposalRepository.cs b/backend/src/Taskdeck.Infrastructure/Repositories/AutomationProposalRepository.cs index 86a7af600..195662dfe 100644 --- a/backend/src/Taskdeck.Infrastructure/Repositories/AutomationProposalRepository.cs +++ b/backend/src/Taskdeck.Infrastructure/Repositories/AutomationProposalRepository.cs @@ -11,26 +11,37 @@ public AutomationProposalRepository(TaskdeckDbContext context) : base(context) { } + public override async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _dbSet + .Include(p => p.Operations) + .FirstOrDefaultAsync(p => p.Id == id, cancellationToken); + } + public async Task> GetByStatusAsync(ProposalStatus status, int limit = 100, CancellationToken cancellationToken = default) { return await _dbSet + .Include(p => p.Operations) .Where(p => p.Status == status) .OrderByDescending(p => p.CreatedAt) .Take(limit) .ToListAsync(cancellationToken); } - public async Task> GetByBoardIdAsync(Guid boardId, CancellationToken cancellationToken = default) + public async Task> GetByBoardIdAsync(Guid boardId, int limit = 100, CancellationToken cancellationToken = default) { return await _dbSet + .Include(p => p.Operations) .Where(p => p.BoardId == boardId) .OrderByDescending(p => p.CreatedAt) + .Take(limit) .ToListAsync(cancellationToken); } public async Task> GetByUserIdAsync(Guid userId, int limit = 100, CancellationToken cancellationToken = default) { return await _dbSet + .Include(p => p.Operations) .Where(p => p.RequestedByUserId == userId) .OrderByDescending(p => p.CreatedAt) .Take(limit) @@ -40,6 +51,7 @@ public async Task> GetByUserIdAsync(Guid userId, public async Task> GetByRiskLevelAsync(RiskLevel riskLevel, int limit = 100, CancellationToken cancellationToken = default) { return await _dbSet + .Include(p => p.Operations) .Where(p => p.RiskLevel == riskLevel) .OrderByDescending(p => p.CreatedAt) .Take(limit) @@ -49,12 +61,14 @@ public async Task> GetByRiskLevelAsync(RiskLevel public async Task GetBySourceReferenceAsync(ProposalSourceType sourceType, string referenceId, CancellationToken cancellationToken = default) { return await _dbSet + .Include(p => p.Operations) .FirstOrDefaultAsync(p => p.SourceType == sourceType && p.SourceReferenceId == referenceId, cancellationToken); } public async Task GetByCorrelationIdAsync(string correlationId, CancellationToken cancellationToken = default) { return await _dbSet + .Include(p => p.Operations) .FirstOrDefaultAsync(p => p.CorrelationId == correlationId, cancellationToken); } @@ -62,6 +76,7 @@ public async Task> GetExpiredAsync(CancellationT { var now = DateTime.UtcNow; return await _dbSet + .Include(p => p.Operations) .Where(p => p.Status == ProposalStatus.PendingReview && p.ExpiresAt < now) .ToListAsync(cancellationToken); } From 80862a96c16cc46775c857bce8b943e8880d942d Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Fri, 13 Feb 2026 05:00:28 +0000 Subject: [PATCH 16/30] Set default limit and refine proposal filters Introduce a local limit (default 100 when filter.Limit <= 0) and pass it to repository queries to avoid unbounded result sets. Simplify in-memory filtering by applying each optional filter (Status, BoardId, UserId, RiskLevel) independently rather than only when combined with Status, and cap the final results with Take(limit). Small comment and formatting tweaks in AutomationProposalService. --- .../Services/AutomationProposalService.cs | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/backend/src/Taskdeck.Application/Services/AutomationProposalService.cs b/backend/src/Taskdeck.Application/Services/AutomationProposalService.cs index 1642ddda6..57c5a4ba8 100644 --- a/backend/src/Taskdeck.Application/Services/AutomationProposalService.cs +++ b/backend/src/Taskdeck.Application/Services/AutomationProposalService.cs @@ -72,42 +72,48 @@ public async Task> GetProposalByIdAsync(Guid id, Cancellatio public async Task>> GetProposalsAsync(ProposalFilterDto? filter = null, CancellationToken cancellationToken = default) { filter ??= new ProposalFilterDto(); + var limit = filter.Limit <= 0 ? 100 : filter.Limit; IEnumerable proposals; // Apply filters in order of specificity if (filter.Status.HasValue) { - proposals = await _unitOfWork.AutomationProposals.GetByStatusAsync(filter.Status.Value, filter.Limit, cancellationToken); + proposals = await _unitOfWork.AutomationProposals.GetByStatusAsync(filter.Status.Value, limit, cancellationToken); } else if (filter.BoardId.HasValue) { - proposals = await _unitOfWork.AutomationProposals.GetByBoardIdAsync(filter.BoardId.Value, cancellationToken); + proposals = await _unitOfWork.AutomationProposals.GetByBoardIdAsync(filter.BoardId.Value, limit, cancellationToken); } else if (filter.UserId.HasValue) { - proposals = await _unitOfWork.AutomationProposals.GetByUserIdAsync(filter.UserId.Value, filter.Limit, cancellationToken); + proposals = await _unitOfWork.AutomationProposals.GetByUserIdAsync(filter.UserId.Value, limit, cancellationToken); } else if (filter.RiskLevel.HasValue) { - proposals = await _unitOfWork.AutomationProposals.GetByRiskLevelAsync(filter.RiskLevel.Value, filter.Limit, cancellationToken); + proposals = await _unitOfWork.AutomationProposals.GetByRiskLevelAsync(filter.RiskLevel.Value, limit, cancellationToken); } else { // Get all by status Pending if no filters provided - proposals = await _unitOfWork.AutomationProposals.GetByStatusAsync(ProposalStatus.PendingReview, filter.Limit, cancellationToken); + proposals = await _unitOfWork.AutomationProposals.GetByStatusAsync(ProposalStatus.PendingReview, limit, cancellationToken); } - // Apply additional filters in memory if multiple filters are specified - if (filter.Status.HasValue && filter.BoardId.HasValue) + // Apply remaining filters in-memory when multiple filters are specified. + if (filter.Status.HasValue) + proposals = proposals.Where(p => p.Status == filter.Status.Value); + + if (filter.BoardId.HasValue) proposals = proposals.Where(p => p.BoardId == filter.BoardId.Value); - if (filter.Status.HasValue && filter.UserId.HasValue) + if (filter.UserId.HasValue) proposals = proposals.Where(p => p.RequestedByUserId == filter.UserId.Value); - if (filter.Status.HasValue && filter.RiskLevel.HasValue) + if (filter.RiskLevel.HasValue) proposals = proposals.Where(p => p.RiskLevel == filter.RiskLevel.Value); + proposals = proposals.Take(limit); + return Result.Success(proposals.Select(MapToDto)); } From 198716e74d1f62e36c828f748f449dad35d6a21d Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Fri, 13 Feb 2026 05:01:01 +0000 Subject: [PATCH 17/30] Add entity lookup, validations, and restore checks Add input validation for list parameters (limit range 1..1000) and normalize/validate entityType. Introduce GetArchiveItemByEntityAsync to look up archive items by entity type+ID with validation and not-found handling. Fix authorization handling to propagate errors, and move target-board existence/archived checks into the column/card restore branches so restoring to archived or missing boards is prevented. Improve overall error messages for invalid inputs. --- .../Services/ArchiveRecoveryService.cs | 76 ++++++++++++++++--- 1 file changed, 64 insertions(+), 12 deletions(-) diff --git a/backend/src/Taskdeck.Application/Services/ArchiveRecoveryService.cs b/backend/src/Taskdeck.Application/Services/ArchiveRecoveryService.cs index 932b15fdf..9f6a590d4 100644 --- a/backend/src/Taskdeck.Application/Services/ArchiveRecoveryService.cs +++ b/backend/src/Taskdeck.Application/Services/ArchiveRecoveryService.cs @@ -66,6 +66,24 @@ public async Task>> GetArchiveItemsAsync( { try { + if (limit <= 0 || limit > 1000) + { + return Result.Failure>( + ErrorCodes.ValidationError, + "Limit must be between 1 and 1000"); + } + + if (!string.IsNullOrWhiteSpace(entityType)) + { + entityType = entityType.Trim().ToLowerInvariant(); + if (entityType != "board" && entityType != "column" && entityType != "card") + { + return Result.Failure>( + ErrorCodes.ValidationError, + "EntityType must be 'board', 'column', or 'card'"); + } + } + IEnumerable items; if (entityType != null && boardId != null && status != null) @@ -131,6 +149,27 @@ public async Task> GetArchiveItemByIdAsync( return Result.Success(MapToDto(archiveItem)); } + public async Task> GetArchiveItemByEntityAsync( + string entityType, + Guid entityId, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(entityType)) + return Result.Failure(ErrorCodes.ValidationError, "EntityType cannot be empty"); + if (entityId == Guid.Empty) + return Result.Failure(ErrorCodes.ValidationError, "EntityId cannot be empty"); + + var normalizedType = entityType.Trim().ToLowerInvariant(); + if (normalizedType != "board" && normalizedType != "column" && normalizedType != "card") + return Result.Failure(ErrorCodes.ValidationError, "EntityType must be 'board', 'column', or 'card'"); + + var archiveItem = await _unitOfWork.ArchiveItems.GetByEntityAsync(normalizedType, entityId, cancellationToken); + if (archiveItem == null) + return Result.Failure(ErrorCodes.NotFound, $"Archive item for {normalizedType} with entity ID {entityId} not found"); + + return Result.Success(MapToDto(archiveItem)); + } + public async Task> RestoreArchiveItemAsync( Guid id, RestoreArchiveItemDto dto, @@ -156,7 +195,14 @@ public async Task> RestoreArchiveItemAsync( if (_authorizationService != null) { var canWriteResult = await _authorizationService.CanWriteBoardAsync(restoredByUserId, targetBoardId); - if (canWriteResult.IsSuccess && !canWriteResult.Value) + if (!canWriteResult.IsSuccess) + { + return Result.Failure( + canWriteResult.ErrorCode, + canWriteResult.ErrorMessage); + } + + if (!canWriteResult.Value) { return Result.Failure( ErrorCodes.Forbidden, @@ -164,17 +210,7 @@ public async Task> RestoreArchiveItemAsync( } } - // 4. Validate target board exists - var targetBoard = await _unitOfWork.Boards.GetByIdAsync(targetBoardId, cancellationToken); - if (targetBoard == null) - return Result.Failure(ErrorCodes.NotFound, $"Target board with ID {targetBoardId} not found"); - - if (targetBoard.IsArchived) - return Result.Failure( - ErrorCodes.InvalidOperation, - "Cannot restore to an archived board"); - - // 5. Validate and restore based on entity type + // 4. Validate and restore based on entity type Result restoreResult; switch (archiveItem.EntityType) { @@ -182,11 +218,27 @@ public async Task> RestoreArchiveItemAsync( restoreResult = await RestoreBoardAsync(archiveItem, dto, restoredByUserId, cancellationToken); break; case "column": + { + var targetBoard = await _unitOfWork.Boards.GetByIdAsync(targetBoardId, cancellationToken); + if (targetBoard == null) + return Result.Failure(ErrorCodes.NotFound, $"Target board with ID {targetBoardId} not found"); + if (targetBoard.IsArchived) + return Result.Failure(ErrorCodes.InvalidOperation, "Cannot restore to an archived board"); + restoreResult = await RestoreColumnAsync(archiveItem, targetBoardId, dto, restoredByUserId, cancellationToken); break; + } case "card": + { + var targetBoard = await _unitOfWork.Boards.GetByIdAsync(targetBoardId, cancellationToken); + if (targetBoard == null) + return Result.Failure(ErrorCodes.NotFound, $"Target board with ID {targetBoardId} not found"); + if (targetBoard.IsArchived) + return Result.Failure(ErrorCodes.InvalidOperation, "Cannot restore to an archived board"); + restoreResult = await RestoreCardAsync(archiveItem, targetBoardId, dto, restoredByUserId, cancellationToken); break; + } default: return Result.Failure( ErrorCodes.ValidationError, From 8449bb2df652e5ad15a5a40903f9d0221fad03e5 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Fri, 13 Feb 2026 05:01:15 +0000 Subject: [PATCH 18/30] Use proposal status for idempotency Remove the in-memory _executedOperations dictionary and its contains/add checks. After loading a proposal, return success if proposal.Status == ProposalStatus.Applied to provide idempotent behavior across requests/processes. This avoids relying on transient in-memory state (which could leak memory and fails in multi-process deployments) while preserving existing validation and workflow checks. --- .../Services/AutomationExecutorService.cs | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/backend/src/Taskdeck.Application/Services/AutomationExecutorService.cs b/backend/src/Taskdeck.Application/Services/AutomationExecutorService.cs index 6087d6fdf..31118e98a 100644 --- a/backend/src/Taskdeck.Application/Services/AutomationExecutorService.cs +++ b/backend/src/Taskdeck.Application/Services/AutomationExecutorService.cs @@ -16,7 +16,6 @@ public class AutomationExecutorService : IAutomationExecutorService private readonly CardService _cardService; private readonly BoardService _boardService; private readonly ColumnService _columnService; - private readonly Dictionary> _executedOperations = new(); public AutomationExecutorService( IUnitOfWork unitOfWork, @@ -42,13 +41,6 @@ public async Task ExecuteProposalAsync(Guid proposalId, string idempoten if (string.IsNullOrWhiteSpace(idempotencyKey)) return Result.Failure(ErrorCodes.ValidationError, "IdempotencyKey cannot be empty"); - // Check idempotency - has this proposal been executed with this key? - if (_executedOperations.ContainsKey(idempotencyKey) && - _executedOperations[idempotencyKey].Contains(proposalId.ToString())) - { - return Result.Success(); // Already executed, return success - } - // Get proposal var proposalResult = await _proposalService.GetProposalByIdAsync(proposalId, cancellationToken); if (!proposalResult.IsSuccess) @@ -56,6 +48,10 @@ public async Task ExecuteProposalAsync(Guid proposalId, string idempoten var proposal = proposalResult.Value; + // Idempotent behavior across requests/processes: already-applied proposals are treated as success. + if (proposal.Status == ProposalStatus.Applied) + return Result.Success(); + // Verify proposal is approved if (proposal.Status != ProposalStatus.Approved) return Result.Failure(ErrorCodes.InvalidOperation, $"Cannot execute proposal in status {proposal.Status}"); @@ -117,11 +113,6 @@ public async Task ExecuteProposalAsync(Guid proposalId, string idempoten if (!markResult.IsSuccess) return Result.Failure(markResult.ErrorCode, markResult.ErrorMessage); - // Record idempotency - if (!_executedOperations.ContainsKey(idempotencyKey)) - _executedOperations[idempotencyKey] = new HashSet(); - _executedOperations[idempotencyKey].Add(proposalId.ToString()); - return Result.Success(); } catch (Exception ex) From fde4b6981480391103dbfbd459723c689ee242bf Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Fri, 13 Feb 2026 05:01:53 +0000 Subject: [PATCH 19/30] Require auth; add executor and idempotency Enforce authorization on AutomationProposalsController and inject IAutomationExecutorService and IUserContext. Use a TryGetCurrentUserId helper to populate requested/decidedByUserId for Create/Approve/Reject flows and centralize authentication error responses. Add idempotency (Idempotency-Key) validation and delegate execution to the executor service, then return the updated proposal via GetProposalById. Expand error mapping (including InvalidOperation, AuthenticationFailed, Unauthorized, Forbidden) for consistent HTTP responses. --- .../AutomationProposalsController.cs | 106 ++++++++++++++++-- 1 file changed, 98 insertions(+), 8 deletions(-) diff --git a/backend/src/Taskdeck.Api/Controllers/AutomationProposalsController.cs b/backend/src/Taskdeck.Api/Controllers/AutomationProposalsController.cs index 9be1eb5ac..622983e1d 100644 --- a/backend/src/Taskdeck.Api/Controllers/AutomationProposalsController.cs +++ b/backend/src/Taskdeck.Api/Controllers/AutomationProposalsController.cs @@ -1,7 +1,10 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Taskdeck.Application.DTOs; +using Taskdeck.Application.Interfaces; using Taskdeck.Application.Services; using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Exceptions; namespace Taskdeck.Api.Controllers; @@ -9,14 +12,22 @@ namespace Taskdeck.Api.Controllers; /// API endpoints for managing automation proposals and their lifecycle. /// [ApiController] +[Authorize] [Route("api/automation/proposals")] public class AutomationProposalsController : ControllerBase { private readonly IAutomationProposalService _proposalService; + private readonly IAutomationExecutorService _executorService; + private readonly IUserContext _userContext; - public AutomationProposalsController(IAutomationProposalService proposalService) + public AutomationProposalsController( + IAutomationProposalService proposalService, + IAutomationExecutorService executorService, + IUserContext userContext) { _proposalService = proposalService; + _executorService = executorService; + _userContext = userContext; } /// @@ -82,7 +93,15 @@ public async Task GetProposal(Guid id, CancellationToken cancella [HttpPost] public async Task CreateProposal([FromBody] CreateProposalDto dto, CancellationToken cancellationToken = default) { - var result = await _proposalService.CreateProposalAsync(dto, cancellationToken); + if (!TryGetCurrentUserId(out var requestedByUserId, out var errorResult)) + return errorResult!; + + var createDto = dto with + { + RequestedByUserId = requestedByUserId + }; + + var result = await _proposalService.CreateProposalAsync(createDto, cancellationToken); if (!result.IsSuccess) { @@ -90,6 +109,9 @@ public async Task CreateProposal([FromBody] CreateProposalDto dto { "ValidationError" => BadRequest(new { errorCode = result.ErrorCode, message = result.ErrorMessage }), "NotFound" => NotFound(new { errorCode = result.ErrorCode, message = result.ErrorMessage }), + "AuthenticationFailed" => Unauthorized(new { errorCode = result.ErrorCode, message = result.ErrorMessage }), + "Unauthorized" => Unauthorized(new { errorCode = result.ErrorCode, message = result.ErrorMessage }), + "Forbidden" => StatusCode(403, new { errorCode = result.ErrorCode, message = result.ErrorMessage }), _ => Problem(result.ErrorMessage, statusCode: 500) }; } @@ -101,15 +123,16 @@ public async Task CreateProposal([FromBody] CreateProposalDto dto /// Approves a pending automation proposal. /// /// Proposal ID - /// User ID approving the proposal /// Cancellation token /// Updated proposal [HttpPost("{id}/approve")] public async Task ApproveProposal( Guid id, - [FromQuery] Guid decidedByUserId, CancellationToken cancellationToken = default) { + if (!TryGetCurrentUserId(out var decidedByUserId, out var errorResult)) + return errorResult!; + var result = await _proposalService.ApproveProposalAsync(id, decidedByUserId, cancellationToken); if (!result.IsSuccess) @@ -119,6 +142,10 @@ public async Task ApproveProposal( "NotFound" => NotFound(new { errorCode = result.ErrorCode, message = result.ErrorMessage }), "ValidationError" => BadRequest(new { errorCode = result.ErrorCode, message = result.ErrorMessage }), "Conflict" => Conflict(new { errorCode = result.ErrorCode, message = result.ErrorMessage }), + ErrorCodes.InvalidOperation => Conflict(new { errorCode = result.ErrorCode, message = result.ErrorMessage }), + "AuthenticationFailed" => Unauthorized(new { errorCode = result.ErrorCode, message = result.ErrorMessage }), + "Unauthorized" => Unauthorized(new { errorCode = result.ErrorCode, message = result.ErrorMessage }), + "Forbidden" => StatusCode(403, new { errorCode = result.ErrorCode, message = result.ErrorMessage }), _ => Problem(result.ErrorMessage, statusCode: 500) }; } @@ -130,17 +157,18 @@ public async Task ApproveProposal( /// Rejects a pending automation proposal. /// /// Proposal ID - /// User ID rejecting the proposal /// Rejection details (reason required for High/Critical risk) /// Cancellation token /// Updated proposal [HttpPost("{id}/reject")] public async Task RejectProposal( Guid id, - [FromQuery] Guid decidedByUserId, [FromBody] UpdateProposalStatusDto dto, CancellationToken cancellationToken = default) { + if (!TryGetCurrentUserId(out var decidedByUserId, out var errorResult)) + return errorResult!; + var result = await _proposalService.RejectProposalAsync(id, decidedByUserId, dto, cancellationToken); if (!result.IsSuccess) @@ -150,6 +178,10 @@ public async Task RejectProposal( "NotFound" => NotFound(new { errorCode = result.ErrorCode, message = result.ErrorMessage }), "ValidationError" => BadRequest(new { errorCode = result.ErrorCode, message = result.ErrorMessage }), "Conflict" => Conflict(new { errorCode = result.ErrorCode, message = result.ErrorMessage }), + ErrorCodes.InvalidOperation => Conflict(new { errorCode = result.ErrorCode, message = result.ErrorMessage }), + "AuthenticationFailed" => Unauthorized(new { errorCode = result.ErrorCode, message = result.ErrorMessage }), + "Unauthorized" => Unauthorized(new { errorCode = result.ErrorCode, message = result.ErrorMessage }), + "Forbidden" => StatusCode(403, new { errorCode = result.ErrorCode, message = result.ErrorMessage }), _ => Problem(result.ErrorMessage, statusCode: 500) }; } @@ -158,7 +190,7 @@ public async Task RejectProposal( } /// - /// Executes an approved automation proposal by marking it as applied. + /// Executes an approved automation proposal through the automation executor. /// /// Proposal ID /// Cancellation token @@ -166,7 +198,33 @@ public async Task RejectProposal( [HttpPost("{id}/execute")] public async Task ExecuteProposal(Guid id, CancellationToken cancellationToken = default) { - var result = await _proposalService.MarkAsAppliedAsync(id, cancellationToken); + if (!Request.Headers.TryGetValue("Idempotency-Key", out var idempotencyHeader) || + string.IsNullOrWhiteSpace(idempotencyHeader)) + { + return BadRequest(new + { + errorCode = ErrorCodes.ValidationError, + message = "Idempotency-Key header is required" + }); + } + + var executionResult = await _executorService.ExecuteProposalAsync(id, idempotencyHeader.ToString(), cancellationToken); + if (!executionResult.IsSuccess) + { + return executionResult.ErrorCode switch + { + "NotFound" => NotFound(new { errorCode = executionResult.ErrorCode, message = executionResult.ErrorMessage }), + "ValidationError" => BadRequest(new { errorCode = executionResult.ErrorCode, message = executionResult.ErrorMessage }), + "Conflict" => Conflict(new { errorCode = executionResult.ErrorCode, message = executionResult.ErrorMessage }), + ErrorCodes.InvalidOperation => Conflict(new { errorCode = executionResult.ErrorCode, message = executionResult.ErrorMessage }), + "AuthenticationFailed" => Unauthorized(new { errorCode = executionResult.ErrorCode, message = executionResult.ErrorMessage }), + "Unauthorized" => Unauthorized(new { errorCode = executionResult.ErrorCode, message = executionResult.ErrorMessage }), + "Forbidden" => StatusCode(403, new { errorCode = executionResult.ErrorCode, message = executionResult.ErrorMessage }), + _ => Problem(executionResult.ErrorMessage, statusCode: 500) + }; + } + + var result = await _proposalService.GetProposalByIdAsync(id, cancellationToken); if (!result.IsSuccess) { @@ -175,6 +233,10 @@ public async Task ExecuteProposal(Guid id, CancellationToken canc "NotFound" => NotFound(new { errorCode = result.ErrorCode, message = result.ErrorMessage }), "ValidationError" => BadRequest(new { errorCode = result.ErrorCode, message = result.ErrorMessage }), "Conflict" => Conflict(new { errorCode = result.ErrorCode, message = result.ErrorMessage }), + ErrorCodes.InvalidOperation => Conflict(new { errorCode = result.ErrorCode, message = result.ErrorMessage }), + "AuthenticationFailed" => Unauthorized(new { errorCode = result.ErrorCode, message = result.ErrorMessage }), + "Unauthorized" => Unauthorized(new { errorCode = result.ErrorCode, message = result.ErrorMessage }), + "Forbidden" => StatusCode(403, new { errorCode = result.ErrorCode, message = result.ErrorMessage }), _ => Problem(result.ErrorMessage, statusCode: 500) }; } @@ -202,4 +264,32 @@ public async Task GetProposalDiff(Guid id, CancellationToken canc return Ok(new { diff = result.Value }); } + + private bool TryGetCurrentUserId(out Guid userId, out IActionResult? errorResult) + { + userId = Guid.Empty; + errorResult = null; + + if (!_userContext.IsAuthenticated || string.IsNullOrWhiteSpace(_userContext.UserId)) + { + errorResult = Unauthorized(new + { + errorCode = ErrorCodes.AuthenticationFailed, + message = "Authenticated user context is required" + }); + return false; + } + + if (!Guid.TryParse(_userContext.UserId, out userId)) + { + errorResult = Unauthorized(new + { + errorCode = ErrorCodes.AuthenticationFailed, + message = "Authenticated user id claim is invalid" + }); + return false; + } + + return true; + } } From 344b70eb052d0766b8b353a223a0be76cedeb8c4 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Fri, 13 Feb 2026 05:02:27 +0000 Subject: [PATCH 20/30] Require auth and use user context for restore Enforce authorization on archive endpoints and switch RestoreArchivedItem to use the authenticated user from IUserContext instead of a query param. Validate and normalize entityType (board/column/card), call GetArchiveItemByEntityAsync, and add robust error handling for authentication/authorization/forbidden/invalid operations. Introduces helper methods TryNormalizeEntityType and TryGetCurrentUserId and injects IUserContext into the controller. --- .../Controllers/ArchiveController.cs | 115 ++++++++++++++---- 1 file changed, 94 insertions(+), 21 deletions(-) diff --git a/backend/src/Taskdeck.Api/Controllers/ArchiveController.cs b/backend/src/Taskdeck.Api/Controllers/ArchiveController.cs index 944955f8c..55c0d1b28 100644 --- a/backend/src/Taskdeck.Api/Controllers/ArchiveController.cs +++ b/backend/src/Taskdeck.Api/Controllers/ArchiveController.cs @@ -1,7 +1,10 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Taskdeck.Application.DTOs; +using Taskdeck.Application.Interfaces; using Taskdeck.Application.Services; using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Exceptions; namespace Taskdeck.Api.Controllers; @@ -9,20 +12,25 @@ namespace Taskdeck.Api.Controllers; /// API endpoints for managing archived items and restoring them. /// [ApiController] +[Authorize] [Route("api/archive")] public class ArchiveController : ControllerBase { private readonly IArchiveRecoveryService _archiveService; + private readonly IUserContext _userContext; - public ArchiveController(IArchiveRecoveryService archiveService) + public ArchiveController( + IArchiveRecoveryService archiveService, + IUserContext userContext) { _archiveService = archiveService; + _userContext = userContext; } /// /// Gets a list of archived items with optional filters. /// - /// Filter by entity type (e.g., "Card", "Board") + /// Filter by entity type (board, column, card) /// Filter by board ID /// Filter by restore status /// Maximum number of results (default: 100) @@ -78,9 +86,8 @@ public async Task GetArchiveItem(Guid id, CancellationToken cance /// /// Restores an archived item. /// - /// Entity type (e.g., "Card", "Board") + /// Entity type (board, column, card) /// Entity ID to restore - /// User ID performing the restore /// Restore options /// Cancellation token /// Restore result @@ -88,35 +95,40 @@ public async Task GetArchiveItem(Guid id, CancellationToken cance public async Task RestoreArchivedItem( string entityType, Guid entityId, - [FromQuery] Guid restoredByUserId, [FromBody] RestoreArchiveItemDto dto, CancellationToken cancellationToken = default) { - // Find the archive item by entity type and ID - var archiveItems = await _archiveService.GetArchiveItemsAsync( - entityType, - null, - null, - 1000, + if (!TryNormalizeEntityType(entityType, out var normalizedEntityType, out var invalidTypeResult)) + return invalidTypeResult!; + + if (!TryGetCurrentUserId(out var restoredByUserId, out var userErrorResult)) + return userErrorResult!; + + var archiveItemResult = await _archiveService.GetArchiveItemByEntityAsync( + normalizedEntityType, + entityId, cancellationToken); - if (!archiveItems.IsSuccess) + if (!archiveItemResult.IsSuccess) { - return archiveItems.ErrorCode switch + return archiveItemResult.ErrorCode switch { - "NotFound" => NotFound(new { errorCode = archiveItems.ErrorCode, message = archiveItems.ErrorMessage }), - "ValidationError" => BadRequest(new { errorCode = archiveItems.ErrorCode, message = archiveItems.ErrorMessage }), - _ => Problem(archiveItems.ErrorMessage, statusCode: 500) + "NotFound" => NotFound(new { errorCode = archiveItemResult.ErrorCode, message = archiveItemResult.ErrorMessage }), + "ValidationError" => BadRequest(new { errorCode = archiveItemResult.ErrorCode, message = archiveItemResult.ErrorMessage }), + "AuthenticationFailed" => Unauthorized(new { errorCode = archiveItemResult.ErrorCode, message = archiveItemResult.ErrorMessage }), + "Unauthorized" => Unauthorized(new { errorCode = archiveItemResult.ErrorCode, message = archiveItemResult.ErrorMessage }), + "Forbidden" => StatusCode(403, new { errorCode = archiveItemResult.ErrorCode, message = archiveItemResult.ErrorMessage }), + _ => Problem(archiveItemResult.ErrorMessage, statusCode: 500) }; } - var archiveItem = archiveItems.Value.FirstOrDefault(a => a.EntityId == entityId && a.RestoreStatus == RestoreStatus.Available); - if (archiveItem == null) + var archiveItem = archiveItemResult.Value; + if (archiveItem.RestoreStatus != RestoreStatus.Available) { - return NotFound(new + return Conflict(new { - errorCode = "NotFound", - message = $"No archived {entityType} found with ID {entityId}" + errorCode = ErrorCodes.InvalidOperation, + message = $"Archive item for {normalizedEntityType} with ID {entityId} is in status {archiveItem.RestoreStatus}" }); } @@ -133,10 +145,71 @@ public async Task RestoreArchivedItem( "NotFound" => NotFound(new { errorCode = result.ErrorCode, message = result.ErrorMessage }), "ValidationError" => BadRequest(new { errorCode = result.ErrorCode, message = result.ErrorMessage }), "Conflict" => Conflict(new { errorCode = result.ErrorCode, message = result.ErrorMessage }), + ErrorCodes.InvalidOperation => Conflict(new { errorCode = result.ErrorCode, message = result.ErrorMessage }), + "AuthenticationFailed" => Unauthorized(new { errorCode = result.ErrorCode, message = result.ErrorMessage }), + "Unauthorized" => Unauthorized(new { errorCode = result.ErrorCode, message = result.ErrorMessage }), + "Forbidden" => StatusCode(403, new { errorCode = result.ErrorCode, message = result.ErrorMessage }), _ => Problem(result.ErrorMessage, statusCode: 500) }; } return Ok(result.Value); } + + private static bool TryNormalizeEntityType(string entityType, out string normalizedEntityType, out IActionResult? errorResult) + { + normalizedEntityType = string.Empty; + errorResult = null; + + if (string.IsNullOrWhiteSpace(entityType)) + { + errorResult = new BadRequestObjectResult(new + { + errorCode = ErrorCodes.ValidationError, + message = "EntityType is required" + }); + return false; + } + + normalizedEntityType = entityType.Trim().ToLowerInvariant(); + if (normalizedEntityType != "board" && normalizedEntityType != "column" && normalizedEntityType != "card") + { + errorResult = new BadRequestObjectResult(new + { + errorCode = ErrorCodes.ValidationError, + message = "EntityType must be 'board', 'column', or 'card'" + }); + return false; + } + + return true; + } + + private bool TryGetCurrentUserId(out Guid userId, out IActionResult? errorResult) + { + userId = Guid.Empty; + errorResult = null; + + if (!_userContext.IsAuthenticated || string.IsNullOrWhiteSpace(_userContext.UserId)) + { + errorResult = Unauthorized(new + { + errorCode = ErrorCodes.AuthenticationFailed, + message = "Authenticated user context is required" + }); + return false; + } + + if (!Guid.TryParse(_userContext.UserId, out userId)) + { + errorResult = Unauthorized(new + { + errorCode = ErrorCodes.AuthenticationFailed, + message = "Authenticated user id claim is invalid" + }); + return false; + } + + return true; + } } From dea845d5ff782d6a67729e355cd8de6e8c8cf3d4 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Fri, 13 Feb 2026 05:02:41 +0000 Subject: [PATCH 21/30] Use interfaces for planner & executor DI Register AutomationPlannerService and AutomationExecutorService against their interfaces (IAutomationPlannerService, IAutomationExecutorService) in Program.cs instead of registering the concrete types directly. This ties the DI container to abstractions, improving testability and allowing easier implementation swaps. --- backend/src/Taskdeck.Api/Program.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/Taskdeck.Api/Program.cs b/backend/src/Taskdeck.Api/Program.cs index 9ca45c6dc..e2f50e309 100644 --- a/backend/src/Taskdeck.Api/Program.cs +++ b/backend/src/Taskdeck.Api/Program.cs @@ -30,8 +30,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); // Add IUserContext for claim-based identity From 203c714e6cd4f5cad5d167c647f3ffddc921b6e8 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Fri, 13 Feb 2026 05:02:56 +0000 Subject: [PATCH 22/30] Include page size in GetByBoardIdAsync mock Update AutomationProposalServiceTests to match the updated GetByBoardIdAsync signature by adding the page size argument (100) to the mock setup. This ensures the test mock matches the method's new parameters and returns the expected proposals. --- .../Services/AutomationProposalServiceTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/Taskdeck.Application.Tests/Services/AutomationProposalServiceTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/AutomationProposalServiceTests.cs index 5a360aa2c..03f3ad00a 100644 --- a/backend/tests/Taskdeck.Application.Tests/Services/AutomationProposalServiceTests.cs +++ b/backend/tests/Taskdeck.Application.Tests/Services/AutomationProposalServiceTests.cs @@ -493,7 +493,7 @@ public async Task GetProposalsAsync_ShouldFilterByBoardId_WhenProvided() new AutomationProposal(ProposalSourceType.Chat, Guid.NewGuid(), "Test", RiskLevel.Low, Guid.NewGuid().ToString(), boardId) }; - _proposalRepoMock.Setup(r => r.GetByBoardIdAsync(boardId, default)) + _proposalRepoMock.Setup(r => r.GetByBoardIdAsync(boardId, 100, default)) .ReturnsAsync(proposals); // Act From 0e1ab70cc6a4d91dc3b155009361a00091b83a07 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Fri, 13 Feb 2026 05:03:41 +0000 Subject: [PATCH 23/30] Register IAuthorizationService in DI container Add a scoped registration in Program.cs that maps IAuthorizationService to the existing AuthorizationService using GetRequiredService. This allows components to depend on the IAuthorizationService interface and reuse the already-registered AuthorizationService implementation. --- backend/src/Taskdeck.Api/Program.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/Taskdeck.Api/Program.cs b/backend/src/Taskdeck.Api/Program.cs index e2f50e309..b18b58637 100644 --- a/backend/src/Taskdeck.Api/Program.cs +++ b/backend/src/Taskdeck.Api/Program.cs @@ -23,6 +23,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(sp => sp.GetRequiredService()); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); From bd1b04d5361358558900ac9f82406581b6e231e3 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Fri, 13 Feb 2026 05:04:10 +0000 Subject: [PATCH 24/30] Use auth and owned boards in automation tests Update automation API tests to register/authenticate test users and create owned boards before exercising endpoints. Add AuthenticateAsync and CreateOwnedBoardAsync helpers and include Authorization header. Remove decidedByUserId query param from approve/reject calls, send Idempotency-Key header when executing proposals, and update expected errorCode from "Conflict" to "InvalidOperation". Also adjust proposal action payload to target a board (include TargetId) and add System.Net.Http.Headers import. --- .../AutomationProposalsApiTests.cs | 103 +++++++++++++----- 1 file changed, 77 insertions(+), 26 deletions(-) diff --git a/backend/tests/Taskdeck.Api.Tests/AutomationProposalsApiTests.cs b/backend/tests/Taskdeck.Api.Tests/AutomationProposalsApiTests.cs index 342ccb36b..3073c20b1 100644 --- a/backend/tests/Taskdeck.Api.Tests/AutomationProposalsApiTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/AutomationProposalsApiTests.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; using FluentAssertions; @@ -20,8 +21,8 @@ public AutomationProposalsApiTests(TestWebApplicationFactory factory) [Fact] public async Task CreateProposal_ThenGetProposal_ShouldReturnCreatedProposal() { - var userId = Guid.NewGuid(); - var boardId = Guid.NewGuid(); + var userId = await AuthenticateAsync("automation-create"); + var boardId = await CreateOwnedBoardAsync(userId); var correlationId = Guid.NewGuid().ToString(); var createRequest = new CreateProposalDto( @@ -65,8 +66,8 @@ public async Task CreateProposal_ThenGetProposal_ShouldReturnCreatedProposal() [Fact] public async Task GetProposals_WithFilters_ShouldReturnFilteredResults() { - var userId = Guid.NewGuid(); - var boardId = Guid.NewGuid(); + var userId = await AuthenticateAsync("automation-filters"); + var boardId = await CreateOwnedBoardAsync(userId); var proposal1 = await CreateTestProposal(userId, boardId, RiskLevel.Low); var proposal2 = await CreateTestProposal(userId, boardId, RiskLevel.High); @@ -84,11 +85,11 @@ public async Task GetProposals_WithFilters_ShouldReturnFilteredResults() [Fact] public async Task ApproveProposal_ShouldUpdateStatus() { - var userId = Guid.NewGuid(); - var boardId = Guid.NewGuid(); + var userId = await AuthenticateAsync("automation-approve"); + var boardId = await CreateOwnedBoardAsync(userId); var proposal = await CreateTestProposal(userId, boardId, RiskLevel.Low); - var approveResponse = await _client.PostAsync($"/api/automation/proposals/{proposal.Id}/approve?decidedByUserId={userId}", null); + var approveResponse = await _client.PostAsync($"/api/automation/proposals/{proposal.Id}/approve", null); approveResponse.StatusCode.Should().Be(HttpStatusCode.OK); var approvedProposal = await approveResponse.Content.ReadFromJsonAsync(); @@ -101,12 +102,12 @@ public async Task ApproveProposal_ShouldUpdateStatus() [Fact] public async Task RejectProposal_ShouldUpdateStatus() { - var userId = Guid.NewGuid(); - var boardId = Guid.NewGuid(); + var userId = await AuthenticateAsync("automation-reject"); + var boardId = await CreateOwnedBoardAsync(userId); var proposal = await CreateTestProposal(userId, boardId, RiskLevel.Low); var rejectDto = new UpdateProposalStatusDto(Reason: "Not needed"); - var rejectResponse = await _client.PostAsJsonAsync($"/api/automation/proposals/{proposal.Id}/reject?decidedByUserId={userId}", rejectDto); + var rejectResponse = await _client.PostAsJsonAsync($"/api/automation/proposals/{proposal.Id}/reject", rejectDto); rejectResponse.StatusCode.Should().Be(HttpStatusCode.OK); var rejectedProposal = await rejectResponse.Content.ReadFromJsonAsync(); @@ -118,13 +119,15 @@ public async Task RejectProposal_ShouldUpdateStatus() [Fact] public async Task ExecuteProposal_WhenApproved_ShouldMarkAsApplied() { - var userId = Guid.NewGuid(); - var boardId = Guid.NewGuid(); + var userId = await AuthenticateAsync("automation-exec-applied"); + var boardId = await CreateOwnedBoardAsync(userId); var proposal = await CreateTestProposal(userId, boardId, RiskLevel.Low); - await _client.PostAsync($"/api/automation/proposals/{proposal.Id}/approve?decidedByUserId={userId}", null); + await _client.PostAsync($"/api/automation/proposals/{proposal.Id}/approve", null); - var executeResponse = await _client.PostAsync($"/api/automation/proposals/{proposal.Id}/execute", null); + var executeRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/automation/proposals/{proposal.Id}/execute"); + executeRequest.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString()); + var executeResponse = await _client.SendAsync(executeRequest); executeResponse.StatusCode.Should().Be(HttpStatusCode.OK); var executedProposal = await executeResponse.Content.ReadFromJsonAsync(); @@ -136,22 +139,24 @@ public async Task ExecuteProposal_WhenApproved_ShouldMarkAsApplied() [Fact] public async Task ExecuteProposal_WhenNotApproved_ShouldReturnConflict() { - var userId = Guid.NewGuid(); - var boardId = Guid.NewGuid(); + var userId = await AuthenticateAsync("automation-exec-conflict"); + var boardId = await CreateOwnedBoardAsync(userId); var proposal = await CreateTestProposal(userId, boardId, RiskLevel.Low); - var executeResponse = await _client.PostAsync($"/api/automation/proposals/{proposal.Id}/execute", null); + var executeRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/automation/proposals/{proposal.Id}/execute"); + executeRequest.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString()); + var executeResponse = await _client.SendAsync(executeRequest); executeResponse.StatusCode.Should().Be(HttpStatusCode.Conflict); var error = await executeResponse.Content.ReadFromJsonAsync(); - error.GetProperty("errorCode").GetString().Should().Be("Conflict"); + error.GetProperty("errorCode").GetString().Should().Be("InvalidOperation"); } [Fact] public async Task GetProposalDiff_ShouldReturnDiffPreview() { - var userId = Guid.NewGuid(); - var boardId = Guid.NewGuid(); + var userId = await AuthenticateAsync("automation-diff"); + var boardId = await CreateOwnedBoardAsync(userId); var proposal = await CreateTestProposal(userId, boardId, RiskLevel.Low); var diffResponse = await _client.GetAsync($"/api/automation/proposals/{proposal.Id}/diff"); @@ -164,6 +169,8 @@ public async Task GetProposalDiff_ShouldReturnDiffPreview() [Fact] public async Task GetProposal_ShouldReturnNotFound_WhenProposalDoesNotExist() { + await AuthenticateAsync("automation-get-notfound"); + var response = await _client.GetAsync($"/api/automation/proposals/{Guid.NewGuid()}"); response.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -174,7 +181,9 @@ public async Task GetProposal_ShouldReturnNotFound_WhenProposalDoesNotExist() [Fact] public async Task ApproveProposal_ShouldReturnNotFound_WhenProposalDoesNotExist() { - var response = await _client.PostAsync($"/api/automation/proposals/{Guid.NewGuid()}/approve?decidedByUserId={Guid.NewGuid()}", null); + await AuthenticateAsync("automation-approve-notfound"); + + var response = await _client.PostAsync($"/api/automation/proposals/{Guid.NewGuid()}/approve", null); response.StatusCode.Should().Be(HttpStatusCode.NotFound); var error = await response.Content.ReadFromJsonAsync(); @@ -184,9 +193,11 @@ public async Task ApproveProposal_ShouldReturnNotFound_WhenProposalDoesNotExist( [Fact] public async Task CreateProposal_WithEmptySummary_ShouldReturnBadRequest() { + var userId = await AuthenticateAsync("automation-create-invalid"); + var createRequest = new CreateProposalDto( SourceType: ProposalSourceType.Chat, - RequestedByUserId: Guid.NewGuid(), + RequestedByUserId: userId, Summary: string.Empty, RiskLevel: RiskLevel.Low, CorrelationId: Guid.NewGuid().ToString() @@ -212,10 +223,11 @@ private async Task CreateTestProposal(Guid userId, Guid boardId, Ri { new( Sequence: 1, - ActionType: "CreateCard", - TargetType: "Card", - Parameters: "{\"title\":\"Test\"}", - IdempotencyKey: Guid.NewGuid().ToString() + ActionType: "update", + TargetType: "board", + Parameters: $"{{\"boardId\":\"{boardId}\",\"name\":\"Automated update {Guid.NewGuid():N}\"}}", + IdempotencyKey: Guid.NewGuid().ToString(), + TargetId: boardId.ToString() ) } ); @@ -224,4 +236,43 @@ private async Task CreateTestProposal(Guid userId, Guid boardId, Ri response.EnsureSuccessStatusCode(); return (await response.Content.ReadFromJsonAsync())!; } + + private async Task AuthenticateAsync(string stem) + { + var suffix = Guid.NewGuid().ToString("N")[..8]; + var username = $"{stem}_{suffix}"; + var email = $"{stem}_{suffix}@example.com"; + const string password = "password123"; + + var response = await _client.PostAsJsonAsync( + "/api/auth/register", + new CreateUserDto(username, email, password)); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", payload!.Token); + return payload.User.Id; + } + + private async Task CreateOwnedBoardAsync(Guid ownerId) + { + var response = await _client.PostAsJsonAsync( + $"/api/import/boards?userId={ownerId}", + new ImportBoardDto( + $"automation-board-{Guid.NewGuid():N}", + null, + Array.Empty(), + Array.Empty(), + Array.Empty())); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var result = await response.Content.ReadFromJsonAsync(); + result.Should().NotBeNull(); + result!.Success.Should().BeTrue(); + result.BoardId.Should().NotBeNull(); + + return result.BoardId!.Value; + } } From 7894b2cdabf141307b0ed6f1e916ed249292428c Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Fri, 13 Feb 2026 05:04:36 +0000 Subject: [PATCH 25/30] Use auth helper in Archive API tests Add AuthenticateAsync helper to register a test user, obtain a token and set the Authorization header for the test HttpClient. Update archive tests to call AuthenticateAsync before requests and adjust the restore test request path (removed restoredByUserId query). Clean up unused usings and add System.Net.Http.Headers for setting the bearer token. --- .../Taskdeck.Api.Tests/ArchiveApiTests.cs | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/backend/tests/Taskdeck.Api.Tests/ArchiveApiTests.cs b/backend/tests/Taskdeck.Api.Tests/ArchiveApiTests.cs index 3337752b8..c14e4a46f 100644 --- a/backend/tests/Taskdeck.Api.Tests/ArchiveApiTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/ArchiveApiTests.cs @@ -1,10 +1,9 @@ using System.Net; +using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; using FluentAssertions; -using Microsoft.Extensions.DependencyInjection; using Taskdeck.Application.DTOs; -using Taskdeck.Application.Services; using Taskdeck.Domain.Entities; using Xunit; @@ -22,6 +21,8 @@ public ArchiveApiTests(TestWebApplicationFactory factory) [Fact] public async Task GetArchiveItems_ShouldReturnList() { + await AuthenticateAsync("archive-list"); + var response = await _client.GetAsync("/api/archive/items"); response.StatusCode.Should().Be(HttpStatusCode.OK); @@ -32,6 +33,8 @@ public async Task GetArchiveItems_ShouldReturnList() [Fact] public async Task GetArchiveItem_ShouldReturnNotFound_WhenItemDoesNotExist() { + await AuthenticateAsync("archive-item-notfound"); + var response = await _client.GetAsync($"/api/archive/items/{Guid.NewGuid()}"); response.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -42,6 +45,8 @@ public async Task GetArchiveItem_ShouldReturnNotFound_WhenItemDoesNotExist() [Fact] public async Task RestoreArchivedItem_WhenNotFound_ShouldReturnNotFound() { + await AuthenticateAsync("archive-restore-notfound"); + var restoreDto = new RestoreArchiveItemDto( TargetBoardId: Guid.NewGuid(), RestoreMode: RestoreMode.InPlace, @@ -49,7 +54,7 @@ public async Task RestoreArchivedItem_WhenNotFound_ShouldReturnNotFound() ); var response = await _client.PostAsJsonAsync( - $"/api/archive/Card/{Guid.NewGuid()}/restore?restoredByUserId={Guid.NewGuid()}", + $"/api/archive/Card/{Guid.NewGuid()}/restore", restoreDto); response.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -61,6 +66,8 @@ public async Task RestoreArchivedItem_WhenNotFound_ShouldReturnNotFound() [Fact] public async Task GetArchiveItems_WithLimit_ShouldRespectLimit() { + await AuthenticateAsync("archive-limit"); + var response = await _client.GetAsync("/api/archive/items?limit=5"); response.StatusCode.Should().Be(HttpStatusCode.OK); @@ -68,4 +75,23 @@ public async Task GetArchiveItems_WithLimit_ShouldRespectLimit() items.Should().NotBeNull(); items!.Count.Should().BeLessOrEqualTo(5); } + + private async Task AuthenticateAsync(string stem) + { + var suffix = Guid.NewGuid().ToString("N")[..8]; + var username = $"{stem}_{suffix}"; + var email = $"{stem}_{suffix}@example.com"; + const string password = "password123"; + + var response = await _client.PostAsJsonAsync( + "/api/auth/register", + new CreateUserDto(username, email, password)); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", payload!.Token); + return payload.User.Id; + } } From 944ac7a72454134f2bf728bd5b082d8bb3d5175b Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Fri, 13 Feb 2026 05:06:32 +0000 Subject: [PATCH 26/30] Apply ordering/limit after query materialization Move OrderByDescending(p => p.CreatedAt).Take(limit) to after ToListAsync in repository methods (GetByStatusAsync, GetByBoardIdAsync, GetByUserIdAsync, GetByRiskLevelAsync). The queries now materialize results first (preserving Include(p => p.Operations)) and then apply in-memory ordering/limiting to avoid EF translation/client-evaluation issues while keeping the external behavior and limit parameter. --- .../AutomationProposalRepository.cs | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/backend/src/Taskdeck.Infrastructure/Repositories/AutomationProposalRepository.cs b/backend/src/Taskdeck.Infrastructure/Repositories/AutomationProposalRepository.cs index 195662dfe..e59d26fa7 100644 --- a/backend/src/Taskdeck.Infrastructure/Repositories/AutomationProposalRepository.cs +++ b/backend/src/Taskdeck.Infrastructure/Repositories/AutomationProposalRepository.cs @@ -20,42 +20,50 @@ public AutomationProposalRepository(TaskdeckDbContext context) : base(context) public async Task> GetByStatusAsync(ProposalStatus status, int limit = 100, CancellationToken cancellationToken = default) { - return await _dbSet + var proposals = await _dbSet .Include(p => p.Operations) .Where(p => p.Status == status) - .OrderByDescending(p => p.CreatedAt) - .Take(limit) .ToListAsync(cancellationToken); + + return proposals + .OrderByDescending(p => p.CreatedAt) + .Take(limit); } public async Task> GetByBoardIdAsync(Guid boardId, int limit = 100, CancellationToken cancellationToken = default) { - return await _dbSet + var proposals = await _dbSet .Include(p => p.Operations) .Where(p => p.BoardId == boardId) - .OrderByDescending(p => p.CreatedAt) - .Take(limit) .ToListAsync(cancellationToken); + + return proposals + .OrderByDescending(p => p.CreatedAt) + .Take(limit); } public async Task> GetByUserIdAsync(Guid userId, int limit = 100, CancellationToken cancellationToken = default) { - return await _dbSet + var proposals = await _dbSet .Include(p => p.Operations) .Where(p => p.RequestedByUserId == userId) - .OrderByDescending(p => p.CreatedAt) - .Take(limit) .ToListAsync(cancellationToken); + + return proposals + .OrderByDescending(p => p.CreatedAt) + .Take(limit); } public async Task> GetByRiskLevelAsync(RiskLevel riskLevel, int limit = 100, CancellationToken cancellationToken = default) { - return await _dbSet + var proposals = await _dbSet .Include(p => p.Operations) .Where(p => p.RiskLevel == riskLevel) - .OrderByDescending(p => p.CreatedAt) - .Take(limit) .ToListAsync(cancellationToken); + + return proposals + .OrderByDescending(p => p.CreatedAt) + .Take(limit); } public async Task GetBySourceReferenceAsync(ProposalSourceType sourceType, string referenceId, CancellationToken cancellationToken = default) From 398e2fcad5182ae75360ed63d1c298e493858e10 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Fri, 13 Feb 2026 05:06:41 +0000 Subject: [PATCH 27/30] Generate diff preview from proposal operations If an explicit DiffPreview exists (non-whitespace) return it; otherwise generate a human-readable preview from the proposal's Operations. Operations are ordered by Sequence and formatted as "{Sequence}. {ActionType} {TargetType[:TargetId]}" joined with newlines. If no operations are present, the method still returns the existing "Diff preview not available" failure. Also tightened the DiffPreview check to IsNullOrWhiteSpace. --- .../Services/AutomationProposalService.cs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/backend/src/Taskdeck.Application/Services/AutomationProposalService.cs b/backend/src/Taskdeck.Application/Services/AutomationProposalService.cs index 57c5a4ba8..788942a9f 100644 --- a/backend/src/Taskdeck.Application/Services/AutomationProposalService.cs +++ b/backend/src/Taskdeck.Application/Services/AutomationProposalService.cs @@ -223,10 +223,23 @@ public async Task> GetProposalDiffAsync(Guid id, CancellationToke if (proposal == null) return Result.Failure(ErrorCodes.NotFound, $"Proposal with ID {id} not found"); - if (string.IsNullOrEmpty(proposal.DiffPreview)) + if (!string.IsNullOrWhiteSpace(proposal.DiffPreview)) + return Result.Success(proposal.DiffPreview); + + if (proposal.Operations.Count == 0) return Result.Failure(ErrorCodes.NotFound, "Diff preview not available for this proposal"); - return Result.Success(proposal.DiffPreview); + var generatedDiff = string.Join( + Environment.NewLine, + proposal.Operations + .OrderBy(o => o.Sequence) + .Select(o => + { + var target = string.IsNullOrWhiteSpace(o.TargetId) ? o.TargetType : $"{o.TargetType}:{o.TargetId}"; + return $"{o.Sequence}. {o.ActionType} {target}"; + })); + + return Result.Success(generatedDiff); } private static ProposalDto MapToDto(AutomationProposal proposal) From cdff00d2c07a4986f8c2d180ee40b9b5e5cd3441 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Fri, 13 Feb 2026 05:07:34 +0000 Subject: [PATCH 28/30] Add test for invalid archive restore type Adds a unit test that verifies the restore endpoint returns BadRequest when an invalid entity type is used. The test authenticates, posts a RestoreArchiveItemDto to /api/archive/not-a-type/{id}/restore and asserts a 400 response with errorCode "ValidationError". This ensures the API validates entity type inputs for archive restore operations. --- .../Taskdeck.Api.Tests/ArchiveApiTests.cs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/backend/tests/Taskdeck.Api.Tests/ArchiveApiTests.cs b/backend/tests/Taskdeck.Api.Tests/ArchiveApiTests.cs index c14e4a46f..eb5326cc9 100644 --- a/backend/tests/Taskdeck.Api.Tests/ArchiveApiTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/ArchiveApiTests.cs @@ -63,6 +63,26 @@ public async Task RestoreArchivedItem_WhenNotFound_ShouldReturnNotFound() error.GetProperty("errorCode").GetString().Should().Be("NotFound"); } + [Fact] + public async Task RestoreArchivedItem_WithInvalidEntityType_ShouldReturnBadRequest() + { + await AuthenticateAsync("archive-restore-invalid-type"); + + var restoreDto = new RestoreArchiveItemDto( + TargetBoardId: Guid.NewGuid(), + RestoreMode: RestoreMode.InPlace, + ConflictStrategy: ConflictStrategy.Fail + ); + + var response = await _client.PostAsJsonAsync( + $"/api/archive/not-a-type/{Guid.NewGuid()}/restore", + restoreDto); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + var error = await response.Content.ReadFromJsonAsync(); + error.GetProperty("errorCode").GetString().Should().Be("ValidationError"); + } + [Fact] public async Task GetArchiveItems_WithLimit_ShouldRespectLimit() { From 2b2b6fd9214430639aafd6652e7816b1ce253f30 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Fri, 13 Feb 2026 05:07:41 +0000 Subject: [PATCH 29/30] Add API tests for automation proposals Add two integration tests to AutomationProposalsApiTests: - ExecuteProposal_WithoutIdempotencyKey_ShouldReturnBadRequest: approves a proposal then attempts to execute it without an idempotency key and asserts a 400 BadRequest with errorCode "ValidationError". - ApproveProposal_ShouldReturnUnauthorized_WhenNotAuthenticated: clears auth header and asserts approving a proposal returns 401 Unauthorized. These tests exercise validation and authentication behavior for the automation proposals endpoints. --- .../AutomationProposalsApiTests.cs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/backend/tests/Taskdeck.Api.Tests/AutomationProposalsApiTests.cs b/backend/tests/Taskdeck.Api.Tests/AutomationProposalsApiTests.cs index 3073c20b1..eccc42bdb 100644 --- a/backend/tests/Taskdeck.Api.Tests/AutomationProposalsApiTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/AutomationProposalsApiTests.cs @@ -152,6 +152,31 @@ public async Task ExecuteProposal_WhenNotApproved_ShouldReturnConflict() error.GetProperty("errorCode").GetString().Should().Be("InvalidOperation"); } + [Fact] + public async Task ExecuteProposal_WithoutIdempotencyKey_ShouldReturnBadRequest() + { + var userId = await AuthenticateAsync("automation-exec-no-idempotency"); + var boardId = await CreateOwnedBoardAsync(userId); + var proposal = await CreateTestProposal(userId, boardId, RiskLevel.Low); + + await _client.PostAsync($"/api/automation/proposals/{proposal.Id}/approve", null); + + var executeResponse = await _client.PostAsync($"/api/automation/proposals/{proposal.Id}/execute", null); + executeResponse.StatusCode.Should().Be(HttpStatusCode.BadRequest); + + var error = await executeResponse.Content.ReadFromJsonAsync(); + error.GetProperty("errorCode").GetString().Should().Be("ValidationError"); + } + + [Fact] + public async Task ApproveProposal_ShouldReturnUnauthorized_WhenNotAuthenticated() + { + _client.DefaultRequestHeaders.Authorization = null; + + var response = await _client.PostAsync($"/api/automation/proposals/{Guid.NewGuid()}/approve", null); + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + [Fact] public async Task GetProposalDiff_ShouldReturnDiffPreview() { From 861b375d4844822fc29dc01729a2093b98257741 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Fri, 13 Feb 2026 05:07:53 +0000 Subject: [PATCH 30/30] Add test for failed authorization during restore Add a unit test RestoreArchiveItemAsync_ShouldReturnFailure_WhenAuthorizationCheckFails to ArchiveRecoveryServiceTests. The test sets up an archived item and mocks the authorization service to return a NotFound error for CanWriteBoardAsync, then asserts the service returns a failure with ErrorCode NotFound and an error message indicating the board is missing. This covers the authorization failure path in RestoreArchiveItemAsync. --- .../Services/ArchiveRecoveryServiceTests.cs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/backend/tests/Taskdeck.Application.Tests/Services/ArchiveRecoveryServiceTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/ArchiveRecoveryServiceTests.cs index fe4aa78bc..903a3e52f 100644 --- a/backend/tests/Taskdeck.Application.Tests/Services/ArchiveRecoveryServiceTests.cs +++ b/backend/tests/Taskdeck.Application.Tests/Services/ArchiveRecoveryServiceTests.cs @@ -372,6 +372,30 @@ public async Task RestoreArchiveItemAsync_ShouldReturnForbidden_WhenUserLacksPer result.ErrorCode.Should().Be(ErrorCodes.Forbidden); } + [Fact] + public async Task RestoreArchiveItemAsync_ShouldReturnFailure_WhenAuthorizationCheckFails() + { + // Arrange + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var snapshotJson = JsonSerializer.Serialize(new { Name = "Test", Description = (string?)null }); + var item = CreateArchiveItem("board", Guid.NewGuid(), boardId, "Test", userId, RestoreStatus.Available, snapshotJson); + var dto = new RestoreArchiveItemDto(null, RestoreMode.InPlace, ConflictStrategy.Fail); + + _archiveItemRepoMock.Setup(r => r.GetByIdAsync(item.Id, default)) + .ReturnsAsync(item); + _authorizationServiceMock.Setup(s => s.CanWriteBoardAsync(userId, boardId)) + .ReturnsAsync(Result.Failure(ErrorCodes.NotFound, "Board missing")); + + // Act + var result = await _service.RestoreArchiveItemAsync(item.Id, dto, userId); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.NotFound); + result.ErrorMessage.Should().Contain("missing"); + } + [Fact] public async Task RestoreArchiveItemAsync_ShouldReturnNotFound_WhenTargetBoardDoesNotExist() {